Merge branch 'master' of https://github.com/Ark0N/Codeman into followups

This commit is contained in:
Devvyn
2026-09-16 15:23:52 +08:00
48 changed files with 1602 additions and 181 deletions
+3
View File
@@ -68,6 +68,9 @@ export class MockSession extends EventEmitter {
this.lastSubmitAt = Date.now();
}
/** Mirrors Session.trackUserInput (the send-key route feeds it around the write path). */
trackUserInput(_data: string): void {}
private _muxName: string | null = null;
constructor(id: string = 'mock-session-id') {
+60
View File
@@ -0,0 +1,60 @@
/**
* @fileoverview PUT /api/sessions/:id/name hands the name to the user (#376).
*
* A rename flips `nameSource` to `manual`, persists it and broadcasts it, so
* auto-naming can never overwrite a name a person chose, on this server or
* on the one that restores the session after a restart.
*
* Uses app.inject() — no real HTTP ports needed.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { Session } from '../../src/session.js';
import { SseEvent } from '../../src/web/sse-events.js';
describe('PUT /api/sessions/:id/name', () => {
let harness: RouteTestHarness;
let session: Session;
const updateSessionName = vi.fn(() => true);
beforeAll(async () => {
harness = await createRouteTestHarness(registerSessionRoutes);
// A REAL session, since the ownership flag lives on the class, not the mock.
session = new Session({ id: 'name-route-test', workingDir: '/tmp', name: 'w1-demo' });
harness.ctx.sessions.set(session.id, session as never);
(harness.ctx.mux as Record<string, unknown>).updateSessionName = updateSessionName;
});
afterAll(async () => {
await harness.app.close();
});
it('flips a placeholder to manual, then persists and broadcasts the ownership', async () => {
expect(session.nameSource).toBe('placeholder');
const res = await harness.app.inject({
method: 'PUT',
url: `/api/sessions/${session.id}/name`,
payload: { name: 'my window' },
});
expect(res.statusCode).toBe(200);
// The harness registers the bare route; the {success,data} envelope is a server-level hook.
expect(res.json()).toMatchObject({ name: 'my window' });
expect(session.name).toBe('my window');
expect(session.nameSource).toBe('manual');
expect(session.applyAutoName('w1-demo: fix it')).toBe(false);
expect(session.name).toBe('my window');
expect(updateSessionName).toHaveBeenCalledWith(session.id, 'my window');
expect(harness.ctx.persistSessionState).toHaveBeenCalledWith(session);
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
SseEvent.SessionUpdated,
expect.objectContaining({ id: session.id, name: 'my window', nameSource: 'manual' })
);
// What the restore path will read back: the persisted state carries the flag.
expect(session.toState().nameSource).toBe('manual');
});
});
+274
View File
@@ -0,0 +1,274 @@
/**
* @fileoverview Auto-naming a session after its first prompt (#376).
*
* The tracker sits on the raw keystroke stream, so most of these pin the
* per-key rules that a review of the first cut found missing: a bare Esc ate
* the next prompt's first character, a wheel report mid-word dropped half the
* prompt, pasted newlines counted as Enter, and every prompt renamed the tab.
*
* Port: N/A (no server needed)
*/
import { describe, it, expect, vi } from 'vitest';
import { Session } from '../src/session.js';
import {
SubmittedPromptTracker,
deriveAutoSessionName,
composeAutoSessionName,
isGeneratedSessionName,
} from '../src/session-auto-name.js';
describe('SubmittedPromptTracker', () => {
it('reports the draft on Enter across arbitrary chunks, honouring backspace', () => {
const tracker = new SubmittedPromptTracker();
expect(tracker.feed('fix the')).toEqual([]);
expect(tracker.feed(' login bugs\x7f')).toEqual([]);
expect(tracker.feed('\r')).toEqual(['fix the login bug']);
expect(tracker.feed('\r')).toEqual([]);
expect(tracker.feed('修复登录跳转\x08问题\r')).toEqual(['修复登录跳问题']);
});
it('treats a bare Esc as the Esc key, not the start of a sequence', () => {
const tracker = new SubmittedPromptTracker();
tracker.feed('\x1b');
expect(tracker.feed('fix the login bug\r')).toEqual(['fix the login bug']);
tracker.feed('\x1b');
expect(tracker.feed('修复登录\r')).toEqual(['修复登录']);
// Esc then digits and punctuation used to grow the escape buffer without bound.
tracker.feed('\x1b');
expect(tracker.feed('12345, ok?\r')).toEqual(['12345, ok?']);
// A double Esc is two Esc keys, each its own write (in ONE chunk, `ESC s`
// is Alt+s by the terminal's own encoding and stays swallowed).
tracker.feed('\x1b');
tracker.feed('\x1b');
expect(tracker.feed('still here\r')).toEqual(['still here']);
});
it('swallows Alt chords and turns Alt+Enter into a newline in the draft', () => {
const tracker = new SubmittedPromptTracker();
expect(tracker.feed('fix\x1bb the\x1b\rbug\r')).toEqual(['fix the bug']);
});
it('ignores cursor keys, mouse and focus reports, Shift+Tab and Tab', () => {
const tracker = new SubmittedPromptTracker();
expect(tracker.feed('fix the \x1b[<64;10;5M\x1b[<65;10;5mlogin bug\r')).toEqual(['fix the login bug']);
expect(tracker.feed('look at @src/ses\tsion.ts and fix it\r')).toEqual(['look at @src/session.ts and fix it']);
expect(tracker.feed('typo\x1b[D\x1b[C\x1b[H\x1b[F\x1b[3~\x1b[Z\x1b[I\x1b[O\x1bOC fixed\r')).toEqual(['typo fixed']);
expect(tracker.feed('mod\x1b[1;5D\x1b[1;2Cifiers\r')).toEqual(['modifiers']);
});
it('taints the draft on history recall so Enter submits nothing rather than a fragment', () => {
const tracker = new SubmittedPromptTracker();
expect(tracker.feed('old text\x1b[A and more\r')).toEqual([]);
expect(tracker.feed('\x1bOB\r')).toEqual([]);
expect(tracker.feed('\x1b[1;5A\r')).toEqual([]);
expect(tracker.feed('\x10x\r')).toEqual([]);
expect(tracker.feed('\x12search\r')).toEqual([]);
expect(tracker.feed('fresh prompt\r')).toEqual(['fresh prompt']);
// Ctrl+C empties the composer, which also clears the taint.
expect(tracker.feed('stale\x1b[A\x03typed after\r')).toEqual(['typed after']);
});
it('keeps bracketed-paste newlines inside the draft', () => {
const tracker = new SubmittedPromptTracker();
expect(tracker.feed('\x1b[200~line one\nline two\r\nline three\x1b[201~ plus typed\r')).toEqual([
'line one line two line three plus typed',
]);
// A paste split across chunks stays a paste.
tracker.feed('\x1b[200~first\r');
expect(tracker.feed('second\x1b[201~\r')).toEqual(['first second']);
});
it('joins a Shift+Enter / Ctrl+J newline with a space', () => {
const tracker = new SubmittedPromptTracker();
tracker.feed('Fix the login bug');
tracker.feed('\n');
expect(tracker.feed('Also add tests.\r')).toEqual(['Fix the login bug Also add tests.']);
});
it('mirrors Ctrl+W, Ctrl+U and Ctrl+C', () => {
const tracker = new SubmittedPromptTracker();
expect(tracker.feed('fix the bugs\x17bug\r')).toEqual(['fix the bug']);
expect(tracker.feed('discarded\x15kept\r')).toEqual(['kept']);
expect(tracker.feed('discarded\x03kept\r')).toEqual(['kept']);
});
it('keeps the HEAD of an over-long draft', () => {
const tracker = new SubmittedPromptTracker();
const [prompt] = tracker.feed(`${'a'.repeat(9000)}\r`);
expect(prompt).toHaveLength(8192);
// Backspaces past the cap consume the overflow before the kept text.
const [again] = tracker.feed(`${'b'.repeat(8200)}${'\x7f'.repeat(10)}\r`);
expect(again).toHaveLength(8190);
});
it('abandons a malformed escape without eating the text, and taints on an over-long one', () => {
const tracker = new SubmittedPromptTracker();
expect(tracker.feed('\x1b[修复\r')).toEqual(['修复']);
expect(tracker.feed('\x1b]0;window title\x07hello\r')).toEqual(['hello']);
// Nothing a terminal sends runs past 64 bytes; the tail is garbage, not a title.
expect(tracker.feed(`\x1b]${'x'.repeat(80)}after\r`)).toEqual([]);
expect(tracker.feed('next prompt\r')).toEqual(['next prompt']);
});
it('resumes a CSI split across chunks', () => {
const tracker = new SubmittedPromptTracker();
tracker.feed('abc\x1b[');
expect(tracker.feed('Ddef\r')).toEqual(['abcdef']);
});
});
describe('deriveAutoSessionName', () => {
it('takes the first sentence, drops the full stop, and bounds the length', () => {
expect(deriveAutoSessionName('Fix the login bug. Also add tests.')).toBe('Fix the login bug');
expect(deriveAutoSessionName(' 修复登录跳转问题。\n不要改数据库')).toBe('修复登录跳转问题');
expect(deriveAutoSessionName('Why does this crash? It worked before')).toBe('Why does this crash?');
expect(deriveAutoSessionName('Run v2.0 tests. Then deploy')).toBe('Run v2.0 tests');
expect(Array.from(deriveAutoSessionName('a'.repeat(200)) ?? '')).toHaveLength(72);
const cut = deriveAutoSessionName('word '.repeat(40).trim()) ?? '';
expect(cut.endsWith('…')).toBe(true);
expect(cut).toMatch(/^(word )+word…$/);
});
it('does not cut on an abbreviation early in the prompt', () => {
expect(deriveAutoSessionName('e.g. fix this now')).toBe('e.g. fix this now');
expect(deriveAutoSessionName('Ok. Fix the login bug')).toBe('Ok. Fix the login bug');
});
it('returns null for commands and empties, but not for paths', () => {
expect(deriveAutoSessionName('/clear')).toBeNull();
expect(deriveAutoSessionName('/model opus')).toBeNull();
expect(deriveAutoSessionName('/ralph-loop:ralph-loop')).toBeNull();
expect(deriveAutoSessionName('! npm test')).toBeNull();
expect(deriveAutoSessionName(' ')).toBeNull();
expect(deriveAutoSessionName('/home/me/notes.txt what is this')).toBe('/home/me/notes.txt what is this');
});
it('strips control bytes and ANSI before the title is persisted', () => {
expect(deriveAutoSessionName('\x1b[31m整理项目文档\x1b[0m')).toBe('整理项目文档');
expect(deriveAutoSessionName('a\x00b\tc')).toBe('a b c');
});
});
describe('composeAutoSessionName', () => {
it('keeps the placeholder as a prefix so the case and the counter survive', () => {
expect(composeAutoSessionName('w3-myapp', 'fix the login bug')).toBe('w3-myapp: fix the login bug');
expect(composeAutoSessionName('', 'fix the login bug')).toBe('fix the login bug');
});
it('honours the rename cap in UTF-16 units', () => {
const name = composeAutoSessionName('w3-myapp', '😀'.repeat(100), 40);
expect(name.length).toBeLessThanOrEqual(40);
expect(name.startsWith('w3-myapp: ')).toBe(true);
expect(name.endsWith('…')).toBe(true);
expect(composeAutoSessionName('x'.repeat(127), 'title', 128)).toBe('x'.repeat(127));
});
it('recognises only the generated w/s + number + case form', () => {
expect(isGeneratedSessionName('w12-my_case-2')).toBe(true);
expect(isGeneratedSessionName('s1-shell')).toBe(true);
expect(isGeneratedSessionName('w1-case: fix it')).toBe(false);
expect(isGeneratedSessionName('alpha')).toBe(false);
});
});
describe('Session name ownership', () => {
it('infers placeholder vs manual from the name and persists the source', () => {
const placeholder = new Session({ workingDir: '/tmp', name: 'w1-demo' });
expect(placeholder.nameSource).toBe('placeholder');
expect(placeholder.toState().nameSource).toBe('placeholder');
expect(new Session({ workingDir: '/tmp' }).nameSource).toBe('placeholder');
expect(new Session({ workingDir: '/tmp', name: 'my window' }).nameSource).toBe('manual');
expect(new Session({ workingDir: '/tmp', name: 'w1-demo: fix it' }).nameSource).toBe('manual');
// The boot restore passes the persisted source, which outranks the inference.
const recovered = new Session({ workingDir: '/tmp', name: 'w1-demo: fix it', nameSource: 'auto' });
expect(recovered.nameSource).toBe('auto');
expect(recovered.applyAutoName('w1-demo: other')).toBe(false);
});
it('names once: the first prompt takes it, later prompts and renames do not', () => {
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
expect(session.applyAutoName('w1-demo: fix the login bug')).toBe(true);
expect(session.name).toBe('w1-demo: fix the login bug');
expect(session.nameSource).toBe('auto');
expect(session.applyAutoName('w1-demo: 1')).toBe(false);
expect(session.name).toBe('w1-demo: fix the login bug');
session.name = 'mine';
expect(session.nameSource).toBe('manual');
expect(session.applyAutoName('other')).toBe(false);
expect(session.name).toBe('mine');
});
it('consumes the first prompt even when the composed name is unchanged', () => {
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
expect(session.applyAutoName('w1-demo')).toBe(false);
expect(session.nameSource).toBe('auto');
});
});
describe('Session promptSubmitted', () => {
function withFakePty(session: Session): ReturnType<typeof vi.fn> {
const write = vi.fn();
(session as unknown as { ptyProcess: { write: typeof write } }).ptyProcess = { write };
return write;
}
it('emits for user input only, after the bytes reached the PTY', () => {
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
const prompts: string[] = [];
session.on('promptSubmitted', (p: string) => prompts.push(p));
// No PTY yet: the write fails and nothing is reported.
expect(session.write('lost\r', { fromUser: true })).toBe(false);
expect(prompts).toEqual([]);
const write = withFakePty(session);
expect(session.write('Read @ralph_prompt.md and follow the instructions.\r')).toBe(true);
expect(prompts).toEqual([]);
expect(session.write('fix the ', { fromUser: true })).toBe(true);
expect(session.write('login bug\r', { fromUser: true })).toBe(true);
expect(prompts).toEqual(['fix the login bug']);
expect(write).toHaveBeenCalledTimes(3);
// The pane's last-Enter stamp is kept for EVERY write, user or not.
expect(session.lastSubmitAt).toBeGreaterThan(0);
});
it('never feeds the tracker for a shell session', () => {
const session = new Session({ workingDir: '/tmp', name: 's1-demo', mode: 'shell' });
const prompts: string[] = [];
session.on('promptSubmitted', (p: string) => prompts.push(p));
withFakePty(session);
expect(session.write('ls -la\r', { fromUser: true })).toBe(true);
session.trackUserInput('cd src\r');
expect(prompts).toEqual([]);
});
it('feeds the send-key line feed so a two-line prompt keeps its separator', () => {
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
const prompts: string[] = [];
session.on('promptSubmitted', (p: string) => prompts.push(p));
withFakePty(session);
session.write('Fix the login bug', { fromUser: true });
session.trackUserInput('\n');
session.write('Also add tests.\r', { fromUser: true });
expect(prompts).toEqual(['Fix the login bug Also add tests.']);
});
it('reports through writeViaMux only when the mux accepted the input', async () => {
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
const prompts: string[] = [];
session.on('promptSubmitted', (p: string) => prompts.push(p));
const sendInput = vi.fn(async () => false);
(session as unknown as { _mux: unknown; _muxSession: unknown })._mux = { sendInput };
(session as unknown as { _mux: unknown; _muxSession: unknown })._muxSession = { sessionId: session.id };
expect(await session.writeViaMux('dropped\r', { fromUser: true })).toBe(false);
expect(prompts).toEqual([]);
sendInput.mockResolvedValue(true);
expect(await session.writeViaMux('delivered\r', { fromUser: true })).toBe(true);
expect(prompts).toEqual(['delivered']);
expect(await session.writeViaMux('/clear\r')).toBe(true);
expect(prompts).toEqual(['delivered']);
});
});
+67
View File
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest';
import { Session } from '../src/session.js';
import { createSessionListeners } from '../src/web/session-listener-wiring.js';
import { SseEvent } from '../src/web/sse-events.js';
describe('session listener wiring', () => {
it('forwards the attachment request source through registerAttachment', async () => {
@@ -20,4 +21,70 @@ describe('session listener wiring', () => {
);
expect(registerAttachment).toHaveBeenNthCalledWith(2, 'wiring-attach-source-test', '/tmp/report.pdf', 'external');
});
/** The listener reads the setting asynchronously; let its promise chain settle. */
const flush = () => new Promise((resolve) => setTimeout(resolve, 5));
function autoNameDeps(session: Session, enabled: boolean) {
const deps = {
updateSessionName: vi.fn(() => true),
persistSessionState: vi.fn(),
broadcast: vi.fn(),
getSessionStateWithRespawn: vi.fn(() => session.toState()),
isAutoNameEnabled: vi.fn(async () => enabled),
};
return {
deps,
refs: createSessionListeners(session, deps as unknown as Parameters<typeof createSessionListeners>[1]),
};
}
it('names a placeholder tab after its first real prompt, in the prefix form, once', async () => {
const session = new Session({ id: 'wiring-auto-name-test', workingDir: '/tmp', name: 'w1-demo' });
const { deps, refs } = autoNameDeps(session, true);
// A slash command yields no title and leaves the session eligible; the
// setting is not even read for it.
refs.promptSubmitted('/clear');
await flush();
expect(deps.isAutoNameEnabled).not.toHaveBeenCalled();
expect(session.name).toBe('w1-demo');
refs.promptSubmitted('整理登录模块并补充测试');
await flush();
expect(session.name).toBe('w1-demo: 整理登录模块并补充测试');
expect(session.nameSource).toBe('auto');
expect(deps.updateSessionName).toHaveBeenCalledWith('wiring-auto-name-test', 'w1-demo: 整理登录模块并补充测试');
expect(deps.persistSessionState).toHaveBeenCalledWith(session);
expect(deps.broadcast).toHaveBeenCalledWith(
SseEvent.SessionUpdated,
expect.objectContaining({ name: 'w1-demo: 整理登录模块并补充测试', nameSource: 'auto' })
);
// The second prompt never reaches the setting: the tab is named.
refs.promptSubmitted('1');
await flush();
expect(deps.isAutoNameEnabled).toHaveBeenCalledTimes(1);
expect(session.name).toBe('w1-demo: 整理登录模块并补充测试');
});
it('leaves the tab alone while the setting is off, and never touches a manual name', async () => {
const session = new Session({ id: 'wiring-auto-name-off', workingDir: '/tmp', name: 'w1-demo' });
const { deps, refs } = autoNameDeps(session, false);
refs.promptSubmitted('fix the login bug');
await flush();
expect(deps.isAutoNameEnabled).toHaveBeenCalledTimes(1);
expect(session.name).toBe('w1-demo');
// Still a placeholder: flipping the setting on names the NEXT prompt.
expect(session.nameSource).toBe('placeholder');
expect(deps.updateSessionName).not.toHaveBeenCalled();
session.name = '人工命名';
refs.promptSubmitted('新的任务不能覆盖人工命名');
await flush();
expect(deps.isAutoNameEnabled).toHaveBeenCalledTimes(1);
expect(session.name).toBe('人工命名');
expect(deps.persistSessionState).not.toHaveBeenCalled();
});
});