mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
fix(agent): verify staged text on a live codex pane and refuse popups
Stage now re-reads the pane after typing (every 50 ms, up to 1 s) and issues a receipt only for a frame with the same generation, the input revision its own write moved, and a composer hash equal to the message's. Codex repaints 30 to 60 ms after the keys, so the first capture was often stale and stage refused with the text already typed. Every refusal after typing now says the message may be in the composer and points at the Ctrl+U recovery. A visible-frame capture reads the screen and the caret in one tmux invocation, with the old two calls as the fallback. Codex draws its suggestion popups above the composer and keeps the footer, so a staged /stat verified and submit ran /status. Stage now refuses a leading / or ! and a last word starting with @ or $ before typing, and stage and submit refuse a frame with popup rows just above the composer. The pattern and characters are registry data (composerPopupLine, composerCommandPrefixes, composerCompletionSigils), read from the codex 0.162.0 bottom_pane sources. Submit waits until 250 ms after the staged keys: codex turns an Enter within 120 ms of a typed burst into a newline. POST .../input answers 409 with its seq returned while a guarded submit holds the input lease, instead of 200 for bytes it never wrote. Also align the attribution memo comment with the code (a verified file whose incremental scan fails is not re-walked, since the full walk reads a superset), and list stage as a fromUser source in CLAUDE.md.
This commit is contained in:
1 parent
c78635a3d3
commit
2832a037d6
27 files changed
+849
-74
No files matched your search
@@ -224,6 +224,22 @@ describe('remembered attribution on a large, never-pruned store', () => {
|
||||
expect(await findExactCodexConversation(f.options)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('answers unattributed, without a full walk, when the remembered file verifies but its incremental scan runs out', async () => {
|
||||
const f = await fixture();
|
||||
const path = await dated(f, '2026/10/24', OWNED);
|
||||
expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path });
|
||||
// 30 rollouts in the remembered day itself: the incremental scan reads them all,
|
||||
// so 20 entries cannot cover it, and a full walk (a superset) could not either.
|
||||
await Promise.all(
|
||||
Array.from({ length: 30 }, (_, n) =>
|
||||
dated(f, '2026/10/24', `20000000-2222-4222-8222-${String(n).padStart(12, '0')}`, { originator: 'codex_cli' })
|
||||
)
|
||||
);
|
||||
expect(await findExactCodexConversation(f.options, small)).toBeUndefined();
|
||||
// The memo was dropped, so a call with room walks the store and attributes again.
|
||||
expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path });
|
||||
});
|
||||
|
||||
it('drops the remembered file when it is replaced, and walks again', async () => {
|
||||
const f = await fixture();
|
||||
const path = await dated(f, '2026/10/24', OWNED);
|
||||
|
||||
@@ -413,16 +413,18 @@ it('refuses a message that would wrap using the actual captured indent and width
|
||||
},
|
||||
muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 },
|
||||
} as unknown as ConstructorParameters<typeof Session>[0]);
|
||||
const { GuardedStageStore } = await import('../src/agent-staging.js');
|
||||
const { GuardedStageStore, guardedComposerChrome } = await import('../src/agent-staging.js');
|
||||
const { getCli } = await import('../src/config/cli-registry/registry.js');
|
||||
try {
|
||||
const store = new GuardedStageStore();
|
||||
const target = {
|
||||
id: session.id,
|
||||
chrome: () => guardedComposerChrome(getCli('codex')),
|
||||
snapshot: () => session.guardedInputSnapshot(),
|
||||
write: (text: string) => session.stageGuardedInput(text),
|
||||
submit: (snap: Parameters<Session['submitGuardedInput']>[0]) => session.submitGuardedInput(snap),
|
||||
};
|
||||
await expect(store.stage(target, 'alice', 'x'.repeat(24))).rejects.toThrow();
|
||||
await expect(store.stage(target, 'alice', 'x'.repeat(24))).rejects.toThrow(/wrap/);
|
||||
expect(writes).toEqual([]);
|
||||
} finally {
|
||||
await session.stop(false);
|
||||
@@ -464,3 +466,70 @@ it('records a verifier re-press as the latest submit time', async () => {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('stages through a real session whose first post-write capture is the torn frame codex produces', async () => {
|
||||
// Reproduces the live 0.162.0 race: right after the keys land, the screen still
|
||||
// shows the empty composer while the caret is already past the new text. That
|
||||
// frame proves nothing; the repaint a few ms later does.
|
||||
let composer = '';
|
||||
let torn = 0;
|
||||
const writes: string[] = [];
|
||||
const session = new Session({
|
||||
workingDir: '/tmp',
|
||||
mode: 'codex',
|
||||
mux: {
|
||||
isAvailable: () => true,
|
||||
capturePaneBuffer: (
|
||||
_name: string,
|
||||
_pane?: string,
|
||||
opts?: { capturedGeometry?: { cols: number; rows: number } }
|
||||
) => {
|
||||
if (opts) opts.capturedGeometry = { cols: 100, rows: 20 };
|
||||
const shown = torn > 0 ? '' : composer;
|
||||
if (torn > 0) torn--;
|
||||
return formatPaneSnapshot([`› ${shown}`, ' GPT-6-Luna default · ~/case'], {
|
||||
cols: 100,
|
||||
rows: 20,
|
||||
cursorX: 2 + composer.length,
|
||||
cursorY: 0,
|
||||
});
|
||||
},
|
||||
sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => {
|
||||
if (options?.beforeDispatch && !options.beforeDispatch()) return false;
|
||||
writes.push(text);
|
||||
if (text !== '\r') {
|
||||
composer += text;
|
||||
torn = 2;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 },
|
||||
} as unknown as ConstructorParameters<typeof Session>[0]);
|
||||
const { GuardedStageStore, guardedComposerChrome } = await import('../src/agent-staging.js');
|
||||
const { getCli } = await import('../src/config/cli-registry/registry.js');
|
||||
const sleeps: number[] = [];
|
||||
const store = new GuardedStageStore(Date.now, 1024, 120000, {
|
||||
sleep: async (ms) => {
|
||||
sleeps.push(ms);
|
||||
},
|
||||
});
|
||||
const target = {
|
||||
id: session.id,
|
||||
chrome: () => guardedComposerChrome(getCli('codex')),
|
||||
snapshot: () => session.guardedInputSnapshot(),
|
||||
write: (text: string) => session.stageGuardedInput(text),
|
||||
submit: (snap: Parameters<Session['submitGuardedInput']>[0]) => session.submitGuardedInput(snap),
|
||||
};
|
||||
try {
|
||||
const receipt = await store.stage(target, 'alice', 'hello');
|
||||
expect(receipt.sha256).toBe(promptSha256('hello'));
|
||||
expect(sleeps).toHaveLength(2);
|
||||
await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).resolves.toMatchObject({
|
||||
submitted: true,
|
||||
});
|
||||
expect(writes).toEqual(['hello', '\r']);
|
||||
} finally {
|
||||
await session.stop(false);
|
||||
}
|
||||
});
|
||||
+186
-3
@@ -1,9 +1,13 @@
|
||||
/** @fileoverview Guarded staging one-shot and content-verification safety. */
|
||||
import { beforeEach, expect, it } from 'vitest';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { CODEX_REASONING_EFFORTS } from '../src/types/session.js';
|
||||
import {
|
||||
GuardedStageStore,
|
||||
STAGE_SETTLE_INTERVAL_MS,
|
||||
STAGE_SETTLE_TIMEOUT_MS,
|
||||
SUBMIT_QUIET_MS,
|
||||
composerTriggerRefusal,
|
||||
guardedComposerChrome,
|
||||
readGuardedComposer,
|
||||
readGuardedSnapshot,
|
||||
@@ -23,16 +27,24 @@ let composer: string | null;
|
||||
let writes: string[];
|
||||
let fail: boolean;
|
||||
let target: GuardedInputTarget;
|
||||
/** Every settle-window wait, on the fake clock: no real timer runs in this file. */
|
||||
let sleeps: number[];
|
||||
const fakeSleep = async (ms: number) => {
|
||||
sleeps.push(ms);
|
||||
time += ms;
|
||||
};
|
||||
beforeEach(() => {
|
||||
sleeps = [];
|
||||
time = 0;
|
||||
generation = 'pane-1';
|
||||
revision = 0;
|
||||
composer = '';
|
||||
writes = [];
|
||||
fail = false;
|
||||
store = new GuardedStageStore(() => time, 2, 1000);
|
||||
store = new GuardedStageStore(() => time, 2, 1000, { sleep: fakeSleep });
|
||||
target = {
|
||||
id: 's',
|
||||
chrome: () => CODEX,
|
||||
snapshot: () =>
|
||||
composer === null ? null : { generation, revision, composerSha: sha(composer), availableColumns: 100 },
|
||||
submit: async () => target.write('\r'),
|
||||
@@ -144,7 +156,7 @@ it('requires capture cursor geometry, rejecting invisible trailing edits and mov
|
||||
expect(readGuardedCodexSnapshot(frame('\x1b[38;2;2;100;100mhello\x1b[0m', 7))).toBe('hello');
|
||||
});
|
||||
it('reserves receipt capacity before concurrent stages type', async () => {
|
||||
store = new GuardedStageStore(() => time, 1, 1000);
|
||||
store = new GuardedStageStore(() => time, 1, 1000, { sleep: fakeSleep });
|
||||
let release: (() => void) | undefined;
|
||||
const firstTarget = {
|
||||
...target,
|
||||
@@ -195,3 +207,174 @@ it('takes the composer glyph and footer from the registry, and only codex declar
|
||||
unsafe.capabilities.workDetect!.composerFooterLine = '(a+)+b';
|
||||
expect(guardedComposerChrome(unsafe)).toBeNull();
|
||||
});
|
||||
|
||||
describe('the settle window after typing (a live codex pane repaints 30 to 60 ms after the keys)', () => {
|
||||
/** Serve these post-write frames in order, then the target's own snapshot. */
|
||||
function afterWrite(frames: Array<(() => ReturnType<GuardedInputTarget['snapshot']>) | null>) {
|
||||
let written = false;
|
||||
const own = target.snapshot;
|
||||
const write = target.write;
|
||||
target.write = async (text) => {
|
||||
const ok = await write(text);
|
||||
written = true;
|
||||
return ok;
|
||||
};
|
||||
target.snapshot = () => {
|
||||
if (!written || frames.length === 0) return own();
|
||||
const next = frames.shift()!;
|
||||
return next ? next() : null;
|
||||
};
|
||||
}
|
||||
const stale = () => ({ generation, revision, composerSha: sha(''), availableColumns: 100 });
|
||||
|
||||
it('names its bounds', () => {
|
||||
expect(STAGE_SETTLE_TIMEOUT_MS).toBe(1000);
|
||||
expect(STAGE_SETTLE_INTERVAL_MS).toBe(50);
|
||||
});
|
||||
it('issues a receipt once a null first frame is followed by a valid one', async () => {
|
||||
afterWrite([null]);
|
||||
const receipt = await store.stage(target, 'alice', 'hello');
|
||||
expect(receipt.sha256).toBe(sha('hello'));
|
||||
expect(sleeps).toEqual([STAGE_SETTLE_INTERVAL_MS]);
|
||||
});
|
||||
it('issues a receipt once a stale frame (the old, empty composer) is followed by a valid one', async () => {
|
||||
afterWrite([stale, stale]);
|
||||
await expect(store.stage(target, 'alice', 'hello')).resolves.toMatchObject({ submitted: false });
|
||||
expect(sleeps).toHaveLength(2);
|
||||
});
|
||||
it('refuses, saying the text may be in the composer, when no frame verifies within the window', async () => {
|
||||
target.snapshot = () => (writes.length ? null : stale());
|
||||
const refusal = await store.stage(target, 'alice', 'hello').catch((error: Error) => error.message);
|
||||
expect(refusal).toMatch(/within 1000 ms; no receipt was issued/);
|
||||
expect(refusal).toMatch(/may already be in the composer.*Ctrl\+U/);
|
||||
expect(sleeps.length).toBeGreaterThanOrEqual(STAGE_SETTLE_TIMEOUT_MS / STAGE_SETTLE_INTERVAL_MS);
|
||||
});
|
||||
it('issues no receipt for a frame whose composer text differs from the message', async () => {
|
||||
const write = target.write;
|
||||
target.write = async (text) => {
|
||||
await write(text);
|
||||
composer = 'hellp'; // codex shows something else: a transformed paste, a dropped key
|
||||
return true;
|
||||
};
|
||||
await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow(/did not show the staged message/);
|
||||
// Nothing to submit: no receipt was stored for the frame that did not match.
|
||||
composer = 'hello';
|
||||
await expect(store.submit(target, 'alice', '11111111-1111-4111-8111-111111111111', sha('hello'))).rejects.toThrow(
|
||||
/Unknown/
|
||||
);
|
||||
});
|
||||
it('refuses at once, without waiting, when another writer moved the input during the window', async () => {
|
||||
afterWrite([() => ({ generation, revision: revision + 1, composerSha: sha('hello'), availableColumns: 100 })]);
|
||||
await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow(
|
||||
/Input or pane changed while staging; no receipt was issued\. The message may already be in the composer/
|
||||
);
|
||||
expect(sleeps).toEqual([]);
|
||||
});
|
||||
it('words a failed typing delivery like the submit refusal: it cannot know whether the text landed', async () => {
|
||||
fail = true;
|
||||
await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow(
|
||||
/^Staging delivery failed or ambiguous; no receipt was issued\. The message may already be in the composer/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('popups and commands that change what Enter does (codex rust-v0.162.0 bottom_pane)', () => {
|
||||
const footer = ' gpt-5.6-terra default · ~/case';
|
||||
/** A frame as codex lays it out: transcript, popup, the composer's blank top row, composer, blank, footer. */
|
||||
const frame = (popup: string[], draft: string) =>
|
||||
['• Earlier answer from the agent.', '', ...popup, '', `› ${draft}`, '', footer].join('\n');
|
||||
|
||||
it('reads the idle frame with transcript above it', () => {
|
||||
expect(readGuardedCodexComposer(frame([], 'hello'))).toBe('hello');
|
||||
});
|
||||
it('refuses the slash-command list codex opens above `› /stat` (Enter would run /status)', () => {
|
||||
const popup = [
|
||||
'› /status show current session configuration and token usage',
|
||||
' /statusline configure which items appear in the status line',
|
||||
];
|
||||
expect(readGuardedCodexComposer(frame(popup, '/stat'))).toBeNull();
|
||||
// A wrapped description row at the bottom of the list does not hide it.
|
||||
expect(readGuardedCodexComposer(frame(['› /tier-one Use faster', ' inference'], '/tier'))).toBeNull();
|
||||
});
|
||||
it('refuses the skill, mention and file-search popups', () => {
|
||||
const skill = ['› 1password [Skill] Example skill used in tests.', '', ' enter insert · esc close'];
|
||||
const mention = [
|
||||
' Mentions',
|
||||
' All Results Filesystem Only Plugins',
|
||||
' sa',
|
||||
'› Sample Plugin Plugin with skills and an MCP server Plugin',
|
||||
' enter/tab insert · esc close · ↑/↓ select · ←/→ filter',
|
||||
];
|
||||
expect(readGuardedCodexComposer(frame(skill, '$1p'))).toBeNull();
|
||||
expect(readGuardedCodexComposer(frame(mention, '@sa'))).toBeNull();
|
||||
expect(readGuardedCodexComposer(frame([' loading...', ''], '@src'))).toBeNull();
|
||||
expect(readGuardedCodexComposer(frame(['no matches'], '/zz'))).toBeNull();
|
||||
});
|
||||
it('looks only at the block directly above the composer, not the transcript further up', () => {
|
||||
const screen = [' /status shows the status', '', '• Answer.', '', '', '', '› hello', '', footer].join('\n');
|
||||
expect(readGuardedCodexComposer(screen)).toBe('hello');
|
||||
});
|
||||
it('takes the popup pattern and the trigger characters from the registry', () => {
|
||||
const codex = getCli('codex')!.capabilities.workDetect!;
|
||||
expect(CODEX.popupLine?.source).toBe(new RegExp(codex.composerPopupLine!).source);
|
||||
expect(CODEX.commandPrefixes).toBe('/!');
|
||||
expect(CODEX.completionSigils).toBe('@$');
|
||||
// A popup pattern the ReDoS guard refuses yields no chrome, rather than chrome without it.
|
||||
const unsafe = structuredClone(getCli('codex')!);
|
||||
unsafe.capabilities.workDetect!.composerPopupLine = '(a+)+b';
|
||||
expect(guardedComposerChrome(unsafe)).toBeNull();
|
||||
});
|
||||
it.each([
|
||||
['/stat', '/'],
|
||||
['/status now', '/'],
|
||||
[' /review', '/'],
|
||||
['!ls -la', '!'],
|
||||
['review @src/app', '@'],
|
||||
['@notes', '@'],
|
||||
['run $skill', '$'],
|
||||
['costs $5', '$'],
|
||||
])('refuses %j before typing (trigger %s)', async (message, trigger) => {
|
||||
expect(composerTriggerRefusal(message, CODEX)).toContain(`"${trigger}"`);
|
||||
await expect(store.stage(target, 'alice', message)).rejects.toThrow(
|
||||
trigger === '/' || trigger === '!' ? /codeman agent send/ : /completion popup/
|
||||
);
|
||||
expect(writes).toEqual([]);
|
||||
});
|
||||
it.each(['look at @src/app.ts please', 'mail a@b.example', 'pay $5 today', 'a/b and c!'])(
|
||||
'stages %j: no trigger at the start, and the caret word opens nothing',
|
||||
async (message) => {
|
||||
expect(composerTriggerRefusal(message, CODEX)).toBeNull();
|
||||
await expect(store.stage(target, 'alice', message)).resolves.toMatchObject({ submitted: false });
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe("the quiet time before Enter (codex's 120 ms paste-burst window turns Enter into a newline)", () => {
|
||||
it('waits out the rest of the window after the staged keys before checking and sending Enter', async () => {
|
||||
expect(SUBMIT_QUIET_MS).toBeGreaterThan(120);
|
||||
const receipt = await store.stage(target, 'alice', 'hello');
|
||||
time += 40;
|
||||
await store.submit(target, 'alice', receipt.stageId, receipt.sha256);
|
||||
expect(sleeps).toEqual([SUBMIT_QUIET_MS - 40]);
|
||||
expect(writes).toEqual(['hello', '\r']);
|
||||
});
|
||||
it('does not wait once the window has passed', async () => {
|
||||
const receipt = await store.stage(target, 'alice', 'hello');
|
||||
time += SUBMIT_QUIET_MS;
|
||||
await store.submit(target, 'alice', receipt.stageId, receipt.sha256);
|
||||
expect(sleeps).toEqual([]);
|
||||
});
|
||||
it('still refuses when a keystroke lands during the wait', async () => {
|
||||
store = new GuardedStageStore(() => time, 2, 1000, {
|
||||
sleep: async (ms) => {
|
||||
await fakeSleep(ms);
|
||||
revision++; // the user typed while submit waited
|
||||
},
|
||||
});
|
||||
const receipt = await store.stage(target, 'alice', 'hello');
|
||||
await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow(
|
||||
/changed; nothing submitted/
|
||||
);
|
||||
expect(writes).toEqual(['hello']);
|
||||
});
|
||||
});
|
||||
@@ -205,6 +205,48 @@ describe('workDetect.workingLine is guarded like every other config regex', () =
|
||||
}
|
||||
});
|
||||
|
||||
it('holds the optional composerPopupLine to the same guard, and accepts the shipped one', () => {
|
||||
// Run over the rows just above the composer each time guarded input reads the pane.
|
||||
for (const composerPopupLine of ['(a+)+b', 'a'.repeat(201), '([unclosed', '']) {
|
||||
expectRejected((e) => {
|
||||
(e.capabilities as Record<string, unknown>).workDetect = {
|
||||
promptGlyph: '>',
|
||||
workingLine: 'working',
|
||||
composerPopupLine,
|
||||
};
|
||||
}, 'guarded input tests it against pane rows a clis.json override can supply');
|
||||
}
|
||||
for (const entry of STOCK_CLIS) {
|
||||
const src = entry.capabilities.workDetect?.composerPopupLine;
|
||||
if (src)
|
||||
expect(compileVersionRegex(src), `${entry.id} declares a composerPopupLine the guard refuses`).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('takes only short runs of ASCII punctuation as composer trigger characters', () => {
|
||||
for (const field of ['composerCommandPrefixes', 'composerCompletionSigils']) {
|
||||
for (const value of ['', 'a', '/a', '/ ', '/\t', '\u00a7', '!!!!!!!!!']) {
|
||||
expectRejected(
|
||||
(e) => {
|
||||
(e.capabilities as Record<string, unknown>).workDetect = {
|
||||
promptGlyph: '>',
|
||||
workingLine: 'working',
|
||||
[field]: value,
|
||||
};
|
||||
},
|
||||
`${field} ${JSON.stringify(value)} would refuse letters, spaces or an unbounded set`
|
||||
);
|
||||
}
|
||||
const entry = baseEntry();
|
||||
(entry.capabilities as Record<string, unknown>).workDetect = {
|
||||
promptGlyph: '>',
|
||||
workingLine: 'working',
|
||||
[field]: '/!@$',
|
||||
};
|
||||
expect(CliEntrySchema.safeParse(entry).success, field).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('holds the optional blockedLine to the same guard', () => {
|
||||
// Run over the foot of a capture at every settle and on a timer while a dialog is
|
||||
// up, and a clis.json override can set it.
|
||||
|
||||
@@ -250,6 +250,17 @@ it('uses real session writes to stage without Enter and refuses intervening API
|
||||
});
|
||||
expect(stale.statusCode).toBe(409);
|
||||
expect(writes.filter((s) => s === '\r')).toHaveLength(1);
|
||||
// Codex's command popup opens on `/`; Enter would run the highlighted command.
|
||||
composer = '';
|
||||
writes.length = 0;
|
||||
const slash = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${session.id}/stage`,
|
||||
payload: { message: '/stat' },
|
||||
});
|
||||
expect(slash.statusCode).toBe(409);
|
||||
expect(slash.json().error).toMatch(/starting with "\/".*codeman agent send/);
|
||||
expect(writes).toEqual([]);
|
||||
} finally {
|
||||
await session.stop(false);
|
||||
}
|
||||
|
||||
@@ -1197,6 +1197,32 @@ describe('session-routes', () => {
|
||||
expect(session.writeBuffer).toEqual(['prompt', '\r']);
|
||||
});
|
||||
|
||||
it('refuses with 409 and gives the seq back while a guarded submit holds the input lease', async () => {
|
||||
const url = `/api/sessions/${harness.ctx._sessionId}/input`;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const session = harness.ctx.sessions.get(harness.ctx._sessionId) as any;
|
||||
session.writeBuffer.length = 0;
|
||||
const post = (payload: unknown) => harness.app.inject({ method: 'POST', url, payload });
|
||||
session.guardedSubmitInFlight = true;
|
||||
for (const payload of [
|
||||
{ input: 'x' },
|
||||
{ input: 'x', useMux: true },
|
||||
{ input: 'prompt\r' },
|
||||
{ input: 'tagged', seq: 7, clientId: 'cid-guard' },
|
||||
]) {
|
||||
const refused = await post(payload);
|
||||
expect(refused.statusCode, JSON.stringify(payload)).toBe(409);
|
||||
expect(refused.json()).toMatchObject({ success: false, errorCode: 'CONFLICT' });
|
||||
expect(refused.json().error).toMatch(/guarded submit is in progress.*nothing was written/);
|
||||
}
|
||||
expect(session.writeBuffer).toEqual([]);
|
||||
// Once the lease is released the browser's retry of the same seq is applied, not
|
||||
// rejected as a duplicate.
|
||||
session.guardedSubmitInFlight = false;
|
||||
expect((await post({ input: 'tagged', seq: 7, clientId: 'cid-guard' })).statusCode).toBe(200);
|
||||
expect(session.writeBuffer).toEqual(['tagged']);
|
||||
});
|
||||
|
||||
it('always applies untagged input (curl/legacy, no dedup)', async () => {
|
||||
const url = `/api/sessions/${harness.ctx._sessionId}/input`;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
@@ -11,7 +11,13 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatCursorRestore, formatPaneSnapshot, hasVisibleContent, queryPaneCursor } from '../src/tmux-manager.js';
|
||||
import {
|
||||
formatCursorRestore,
|
||||
formatPaneSnapshot,
|
||||
hasVisibleContent,
|
||||
queryPaneCursor,
|
||||
splitFrameAndCursor,
|
||||
} from '../src/tmux-manager.js';
|
||||
|
||||
describe('tmux full-history pane capture (COD-47)', () => {
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8');
|
||||
@@ -265,3 +271,32 @@ describe('queryPaneCursor history depth', () => {
|
||||
expect(source).toContain("'#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size} #{alternate_on}'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('the visible frame and its caret come from one tmux invocation', () => {
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8');
|
||||
const methodStart = source.indexOf('capturePaneBuffer(muxName: string');
|
||||
const methodBody = source.slice(methodStart, source.indexOf('captureActivePaneBuffer(muxName: string', methodStart));
|
||||
|
||||
it('chains the capture and the cursor query with `\;` on the visible path only', () => {
|
||||
// Two separate execs raced a CLI repaint (codex redraws its composer 30-60 ms
|
||||
// after input): the frame could predate the caret, and guarded stage refused it.
|
||||
// Two backslashes in the source: one escaped backslash in the shell command, which hands tmux its `;`.
|
||||
const chained = '${captureCommand} ' + '\\'.repeat(2) + '; ${cursorQuery}';
|
||||
expect(methodBody).toContain('fullHistory ? null : this.captureFrameWithCursor(`' + chained + '`)');
|
||||
// Any doubt falls back to the two calls the capture always made.
|
||||
expect(methodBody).toContain('combined?.frame ?? execSync(captureCommand, execOpts)');
|
||||
expect(methodBody).toContain('combined\n ? combined.cursor');
|
||||
});
|
||||
it('splits the combined output into the rows and the cursor line tmux prints last', () => {
|
||||
expect(splitFrameAndCursor('› hello\n footer\n\n7 0 100 20 0 0\n')).toEqual({
|
||||
frame: '› hello\n footer\n\n',
|
||||
cursor: '7 0 100 20 0 0',
|
||||
});
|
||||
expect(splitFrameAndCursor('only\n3 0 80 24\n')).toEqual({ frame: 'only\n', cursor: '3 0 80 24' });
|
||||
});
|
||||
it('refuses output whose last line is not the cursor query, so a frame row is never taken for it', () => {
|
||||
expect(splitFrameAndCursor('› hello\n footer\n')).toBeNull();
|
||||
expect(splitFrameAndCursor('row\nworld 1 2 3 4\n')).toBeNull();
|
||||
expect(splitFrameAndCursor('')).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user