fix(cli): address #10368 R2 review findings in live-session batch

- input-prompt: convert OpenTUI display-width cursor coordinates to
  code-point positions at the component boundary — the pinned
  @opentui/core reports logicalCursor.col/offset and el.cursorOffset in
  terminal-cell units (edit-buffer.zig), while the ported ink helpers
  work in code points; wide characters previously shifted placeholder
  backspace, the backslash continuation check, completion targeting,
  and history edge compares
- input-prompt: bump both search sequence refs when Esc dismisses the
  completion dropdown so an in-flight search resolving afterwards cannot
  re-open it and hijack Enter
- live-session-model: carry the vision-bridge egress disclosure through
  the tool-result fold (ink ToolMessage renders it under the result)
- messages: recognize the producers' two-L 'cancelled' summary spelling
  so canceled tools get the CANCELED glyph with strikethrough instead of
  the red ERROR glyph
- session-switch: wrap /resume and /branch in the telemetry swap
  transaction (begin before the outgoing-session capture, commit at the
  UI re-key, abort after a rolled-back swap) — restores the usage
  aggregate on failed swaps and rejects concurrent switches
- tests: display-width fake editor, wide-char placeholder/continuation
  witnesses, Esc invalidation, fold notice, cancelled spelling, and the
  three swap-transaction lifecycle cases
This commit is contained in:
chiga0 committed 2026-08-31 03:09:48 +08:00
1 parent 65f9220ee8
commit 656e996c9a
10 files changed
+477 -36

No files matched your search

@@ -17,9 +17,13 @@ import {
LARGE_PASTE_CHAR_THRESHOLD,
LARGE_PASTE_LINE_THRESHOLD,
applyCompletion,
codePointIndexToDisplayCol,
codePointIndexToDisplayOffset,
commandCompletionItemsToSuggestions,
decideSubmit,
detectCompletionTarget,
displayColToCodePointIndex,
displayOffsetToCodePointIndex,
expandPendingPastePlaceholders,
freePastePlaceholderId,
isLargePaste,
@@ -679,3 +683,52 @@ describe('suggestion shape stability', () => {
expect(suggestions[0]?.description).toBe('cd2 description');
});
});
describe('display-width ↔ code-point cursor conversion (R2-1)', () => {
it('converts display columns on a CJK line to code-point indices', () => {
const line = '你好abc';
// 你 and 好 are 2 cells each; a/b/c are 1 each.
expect(displayColToCodePointIndex(line, 0)).toBe(0);
expect(displayColToCodePointIndex(line, 2)).toBe(1); // after 你
expect(displayColToCodePointIndex(line, 4)).toBe(2); // after 好
expect(displayColToCodePointIndex(line, 5)).toBe(3); // after a
expect(displayColToCodePointIndex(line, 7)).toBe(5); // end
expect(displayColToCodePointIndex(line, 99)).toBe(5); // clamped
});
it('converts code-point indices back to display columns', () => {
const line = '你好abc';
expect(codePointIndexToDisplayCol(line, 1)).toBe(2);
expect(codePointIndexToDisplayCol(line, 2)).toBe(4);
expect(codePointIndexToDisplayCol(line, 5)).toBe(7);
});
it('round-trips columns through both converters', () => {
const line = 'aé你😀z';
for (let i = 0; i <= 5; i++) {
expect(
displayColToCodePointIndex(line, codePointIndexToDisplayCol(line, i)),
).toBe(i);
}
});
it('converts global offsets across lines, weighing newlines as 1', () => {
const text = '你\nb';
// Cell offsets: 0 before 你, 2 after 你, 3 after the newline, 4 at end.
expect(displayOffsetToCodePointIndex(text, 0)).toBe(0);
expect(displayOffsetToCodePointIndex(text, 2)).toBe(1);
expect(displayOffsetToCodePointIndex(text, 3)).toBe(2);
expect(displayOffsetToCodePointIndex(text, 4)).toBe(3);
expect(codePointIndexToDisplayOffset(text, 1)).toBe(2);
expect(codePointIndexToDisplayOffset(text, 2)).toBe(3);
expect(codePointIndexToDisplayOffset(text, 3)).toBe(4);
});
it('keeps ASCII text transparent (offset == code-point index)', () => {
const text = 'abc\ndef';
for (let i = 0; i <= text.length; i++) {
expect(displayOffsetToCodePointIndex(text, i)).toBe(i);
expect(codePointIndexToDisplayOffset(text, i)).toBe(i);
}
});
});
@@ -47,12 +47,90 @@ import {
isValidStackedSkillPrefix,
} from '../commands/commands.js';
import { getCommandDisplayName } from '../../services/commandMetadata.js';
import { toCodePoints } from '../utils/textUtils.js';
import { getCachedStringWidth, toCodePoints } from '../utils/textUtils.js';
import type { InputHistory } from './input-history.js';
import type { RecentSlashCommands } from '../hooks/useSlashCompletion.js';
export { MAX_SUGGESTIONS_TO_SHOW };
// ── OpenTUI cursor coordinate conversion ──────────────────────────────────
//
// The pinned @opentui/core reports cursor coordinates in display-width
// (terminal-cell) units: edit-buffer.zig's Cursor documents "row, col in
// display-width coordinates" with "offset: Global display-width offset from
// buffer start", and text-buffer-iterators.zig's coordsToOffset computes
// line_start_weight + col where the rope weighs each newline as 1. The
// helpers in this module work in code-point units (toCodePoints
// coordinates, matching the ink ports they were derived from). These pure
// converters bridge the two; the component calls them at the editor
// boundary so every helper keeps its code-point contract.
const codePointWidth = (cp: string): number => getCachedStringWidth(cp);
/** Display-width column within one line → code-point index in that line. */
export function displayColToCodePointIndex(
line: string,
displayCol: number,
): number {
const codePoints = toCodePoints(line);
let width = 0;
for (let i = 0; i < codePoints.length; i++) {
if (width >= displayCol) return i;
width += codePointWidth(codePoints[i]!);
}
return codePoints.length;
}
/** Code-point index in a line → display-width column. */
export function codePointIndexToDisplayCol(
line: string,
index: number,
): number {
const codePoints = toCodePoints(line);
const limit = Math.min(index, codePoints.length);
let width = 0;
for (let i = 0; i < limit; i++) {
width += codePointWidth(codePoints[i]!);
}
return width;
}
/** Global display-width offset over the whole buffer → code-point index. */
export function displayOffsetToCodePointIndex(
text: string,
displayOffset: number,
): number {
let index = 0;
let width = 0;
for (const line of text.split('\n')) {
const codePoints = toCodePoints(line);
for (const cp of codePoints) {
if (width >= displayOffset) return index;
width += codePointWidth(cp);
index++;
}
// The rope weighs the newline itself as 1 in the global offset.
if (width >= displayOffset) return index;
width += 1;
index++;
}
return index;
}
/** Code-point index over the whole buffer → global display-width offset. */
export function codePointIndexToDisplayOffset(
text: string,
index: number,
): number {
const codePoints = toCodePoints(text);
const limit = Math.min(index, codePoints.length);
let width = 0;
for (let i = 0; i < limit; i++) {
width += codePoints[i] === '\n' ? 1 : codePointWidth(codePoints[i]!);
}
return width;
}
export enum CompletionMode {
IDLE = 'IDLE',
AT = 'AT',
+124 -14
View File
@@ -25,6 +25,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act } from 'react';
import { render } from '@testing-library/react';
import { OpenTuiInputPrompt } from './input-prompt.js';
import { cpLen, cpSlice } from '../utils/textUtils.js';
import {
codePointIndexToDisplayCol,
displayColToCodePointIndex,
} from './input-prompt-model.js';
interface FakeEditor {
plainText: string;
@@ -50,26 +55,38 @@ const mocks = vi.hoisted(() => {
editors: [] as unknown[],
pasteHandlers: [] as Array<(event: unknown) => void>,
slashCommands: [] as unknown[],
fileSearchResults: [] as string[],
fileSearchDelay: Promise.resolve() as Promise<void>,
};
function createFakeEditor() {
// The fake models the REAL editor contract: cursor coordinates are
// display-width (terminal-cell) units, exactly like the pinned
// @opentui/core's edit-buffer (row/col/offset in display width). The
// cursor position is tracked internally as a code-point column and
// converted with the production converters, so wide characters make
// reads and writes diverge from string indices like they do natively.
let text = '';
let col = 0;
let col = 0; // code-point column within row 0 (the fake is single-line)
const displayCol = () => codePointIndexToDisplayCol(text, col);
const setColFromDisplay = (display: number) => {
col = displayColToCodePointIndex(text, display);
};
const editor = {
get plainText() {
return text;
},
get logicalCursor() {
return { row: 0, col, offset: col };
return { row: 0, col: displayCol(), offset: displayCol() };
},
get lineCount() {
return text.split('\n').length;
},
get cursorOffset() {
return col;
return displayCol();
},
set cursorOffset(offset: number) {
col = offset;
setColFromDisplay(offset);
},
deleteCharBackwardCalls: 0,
deleteWordBackwardCalls: 0,
@@ -78,7 +95,7 @@ const mocks = vi.hoisted(() => {
deleteCharBackward() {
editor.deleteCharBackwardCalls += 1;
if (col > 0) {
text = text.slice(0, col - 1) + text.slice(col);
text = cpSlice(text, 0, col - 1) + cpSlice(text, col);
col -= 1;
}
return true;
@@ -86,35 +103,35 @@ const mocks = vi.hoisted(() => {
deleteWordBackward() {
// Coarse whitespace-word delete, enough to observe the wiring.
editor.deleteWordBackwardCalls += 1;
const before = text.slice(0, col);
const before = cpSlice(text, 0, col);
const match = /^(.*?)(\S+\s*)$/s.exec(before);
if (match?.[1] !== undefined) {
text = match[1] + text.slice(col);
col = match[1].length;
text = match[1] + cpSlice(text, col);
col = cpLen(match[1]);
}
return true;
},
insertText(t: string) {
editor.insertCalls.push(t);
text = text.slice(0, col) + t + text.slice(col);
col += t.length;
text = cpSlice(text, 0, col) + t + cpSlice(text, col);
col += cpLen(t);
},
setText(t: string) {
text = t;
col = t.length;
col = cpLen(t);
},
setCursor(_row: number, c: number) {
col = c;
setColFromDisplay(c);
},
setCursorByOffset(offset: number) {
col = offset;
setColFromDisplay(offset);
},
clear() {
text = '';
col = 0;
},
gotoLineEnd() {
col = text.length;
col = cpLen(text);
},
newLine() {
editor.newLineCalls += 1;
@@ -196,6 +213,24 @@ vi.mock('@opentui/react', () => ({
useTerminalDimensions: () => ({ width: 80, height: 24 }),
}));
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...actual,
FileSearchFactory: {
create: () => ({
initialize: async () => {},
search: async () => {
await mocks.state.fileSearchDelay;
return mocks.state.fileSearchResults;
},
dispose: async () => {},
}),
},
};
});
vi.mock('@opentui/react/jsx-runtime', () => mocks.buildJsxRuntime());
vi.mock('@opentui/react/jsx-dev-runtime', () => mocks.buildJsxRuntime());
vi.mock('./theme.js', () => ({
@@ -424,6 +459,40 @@ describe('OpenTuiInputPrompt submit guard', () => {
mocks.state.editors.length = 0;
mocks.state.pasteHandlers.length = 0;
mocks.state.slashCommands = [];
mocks.state.fileSearchResults = [];
mocks.state.fileSearchDelay = Promise.resolve();
});
it('Esc invalidates in-flight @ searches: a late resolve must not reopen the dropdown (R2-2)', async () => {
let releaseSearch!: () => void;
mocks.state.fileSearchResults = ['hit-file.txt'];
mocks.state.fileSearchDelay = new Promise<void>((resolve) => {
releaseSearch = resolve;
});
const submitted: string[] = [];
render(
<OpenTuiInputPrompt
onSubmit={(text) => submitted.push(text)}
userMessages={[]}
/>,
);
const editor = currentEditor();
await typeText('@x');
// Give the async initialize+search chain a tick to start.
await act(async () => {});
// Esc dismisses the dropdown while the search is still pending.
await act(async () => {
lastKeyboardHandler()(baseKeyEvent({ name: 'escape', sequence: '\x1b' }));
});
// The late resolution must not re-populate the dismissed dropdown.
releaseSearch();
await act(async () => {});
// Enter submits the typed text instead of accepting the stale hit.
await act(async () => {
lastKeyboardHandler()(baseKeyEvent({ name: 'return', sequence: '\r' }));
});
expect(submitted).toEqual(['@x']);
expect(editor.plainText).toBe('');
});
it('Enter still submits the typed text', async () => {
@@ -748,6 +817,47 @@ describe('OpenTuiInputPrompt large-paste collapsing (G10)', () => {
await emitPaste(handler, 'z'.repeat(1500));
expect(editor.plainText).toBe('[Pasted Content 1500 chars]');
});
it('backspace removes the placeholder whole after wide characters (R2-1)', async () => {
// 你好 occupies 4 display cells but 2 code points: the cursor's
// display offset (4 + placeholder width) is NOT its code-point index
// (2 + placeholder length). Placeholder deletion must convert first —
// the old code sliced with the display offset and never matched.
render(<OpenTuiInputPrompt onSubmit={() => {}} userMessages={[]} />);
const handler = mocks.state.pasteHandlers.at(-1);
if (!handler) throw new Error('no paste handler registered');
const editor = currentEditor();
await typeText('你好');
await emitPaste(handler, 'y'.repeat(1500));
expect(editor.plainText).toBe('你好[Pasted Content 1500 chars]');
const placeholder = editor.plainText.slice('你好'.length);
expect(editor.cursorOffset).toBe(4 + placeholder.length);
expect(await pressRaw('\x7f')).toBe(true);
expect(editor.plainText).toBe('你好');
expect(editor.cursorOffset).toBe(4);
expect(editor.deleteCharBackwardCalls).toBe(0);
});
it('Enter after 你好 + backslash continues the line instead of submitting (R2-1)', async () => {
// The trailing-backslash check reads the char before the caret; with
// wide characters the display offset (5) must convert to the code-point
// index (3) before the lookup, or Enter submits instead of continuing.
const submitted: string[] = [];
render(
<OpenTuiInputPrompt
onSubmit={(text) => submitted.push(text)}
userMessages={[]}
/>,
);
const editor = currentEditor();
await typeText('你好\\');
await act(async () => {
lastKeyboardHandler()(baseKeyEvent({ name: 'return', sequence: '\r' }));
});
expect(submitted).toEqual([]);
expect(editor.newLineCalls).toBe(1);
expect(editor.deleteCharBackwardCalls).toBe(1);
});
});
describe('OpenTuiInputPrompt Enter accepts completions (G-13)', () => {
+46 -17
View File
@@ -65,7 +65,7 @@ import path from 'node:path';
import type { CommandContext, SlashCommand } from '../commands/types.js';
import type { RecentSlashCommand } from '../hooks/useSlashCompletion.js';
import type { Suggestion } from '../utils/suggestions.js';
import { toCodePoints } from '../utils/textUtils.js';
import { cpLen, toCodePoints } from '../utils/textUtils.js';
import { C } from './theme.js';
import { InputHistory } from './input-history.js';
import { loadInteractiveCommands } from './slash-dispatch.js';
@@ -74,9 +74,13 @@ import {
EscapeClearModel,
MAX_SUGGESTIONS_TO_SHOW,
applyCompletion,
codePointIndexToDisplayCol,
codePointIndexToDisplayOffset,
commandCompletionItemsToSuggestions,
decideSubmit,
detectCompletionTarget,
displayColToCodePointIndex,
displayOffsetToCodePointIndex,
expandPendingPastePlaceholders,
fileSearchToSuggestions,
freePastePlaceholderId,
@@ -336,9 +340,9 @@ export function OpenTuiInputPrompt(props: InputPromptProps) {
const target = detectCompletionTarget(
lines,
cursor.row,
cursor.col,
displayColToCodePointIndex(lines[cursor.row] ?? '', cursor.col),
text,
cursor.offset,
displayOffsetToCodePointIndex(text, cursor.offset),
commandsRef.current,
);
@@ -466,9 +470,9 @@ export function OpenTuiInputPrompt(props: InputPromptProps) {
lines[cursor.row] = line;
el.setText(lines.join('\n'));
if (cursorCol !== undefined) {
el.setCursor(cursor.row, cursorCol);
el.setCursor(cursor.row, codePointIndexToDisplayCol(line, cursorCol));
} else {
el.setCursor(cursor.row, line.length);
el.setCursor(cursor.row, codePointIndexToDisplayCol(line, cpLen(line)));
}
setTextVersion((v) => v + 1);
}, []);
@@ -484,9 +488,9 @@ export function OpenTuiInputPrompt(props: InputPromptProps) {
const target = detectCompletionTarget(
lines,
cursor.row,
cursor.col,
displayColToCodePointIndex(lines[cursor.row] ?? '', cursor.col),
text,
cursor.offset,
displayOffsetToCodePointIndex(text, cursor.offset),
commandsRef.current,
);
if (!target) return;
@@ -580,19 +584,27 @@ export function OpenTuiInputPrompt(props: InputPromptProps) {
// character.
if (pendingPastesRef.current.size > 0) {
const cursor = el.logicalCursor;
const codePoints = toCodePoints(el.plainText);
const plainText = el.plainText;
const codePoints = toCodePoints(plainText);
const cursorCpOffset = displayOffsetToCodePointIndex(
plainText,
cursor.offset,
);
for (const placeholder of pendingPastesRef.current.keys()) {
const placeholderStart = cursor.offset - placeholder.length;
const placeholderStart = cursorCpOffset - placeholder.length;
if (
placeholderStart >= 0 &&
codePoints.slice(placeholderStart, cursor.offset).join('') ===
codePoints.slice(placeholderStart, cursorCpOffset).join('') ===
placeholder
) {
const nextText =
codePoints.slice(0, placeholderStart).join('') +
codePoints.slice(cursor.offset).join('');
codePoints.slice(cursorCpOffset).join('');
el.setText(nextText);
el.cursorOffset = placeholderStart;
el.cursorOffset = codePointIndexToDisplayOffset(
nextText,
placeholderStart,
);
pendingPastesRef.current.delete(placeholder);
const parsedPlaceholder = parsePastePlaceholder(placeholder);
if (parsedPlaceholder) {
@@ -692,7 +704,10 @@ export function OpenTuiInputPrompt(props: InputPromptProps) {
// decideSubmit owns the whitespace guard and the `\`+Enter
// continuation: a trailing backslash before the caret is removed and
// becomes a newline instead of submitting (ink InputPrompt parity).
const decision = decideSubmit(el.plainText, el.cursorOffset);
const decision = decideSubmit(
el.plainText,
displayOffsetToCodePointIndex(el.plainText, el.cursorOffset),
);
if (decision.kind === 'noop') {
key.preventDefault();
return;
@@ -773,6 +788,11 @@ export function OpenTuiInputPrompt(props: InputPromptProps) {
completionModeRef.current = CompletionMode.IDLE;
setSuggestions([]);
setLoadingSuggestions(false);
// Invalidate in-flight searches: an async resolution landing after
// the Esc would otherwise re-populate the dismissed dropdown and
// turn the next Enter into an accidental suggestion insert.
atSearchSeqRef.current++;
slashSearchSeqRef.current++;
return;
}
// Pop queued prompts back into the composer before the double-Esc
@@ -855,7 +875,10 @@ export function OpenTuiInputPrompt(props: InputPromptProps) {
el.plainText,
el.lineCount,
cursor.row,
cursor.col,
displayColToCodePointIndex(
el.plainText.split('\n')[cursor.row] ?? '',
cursor.col,
),
);
if (decision.kind === 'passthrough') return; // caret moves inside text
key.preventDefault();
@@ -877,8 +900,11 @@ export function OpenTuiInputPrompt(props: InputPromptProps) {
historyRef.current!,
el.lineCount,
cursor.row,
cursor.col,
lastLine.length,
displayColToCodePointIndex(
el.plainText.split('\n')[cursor.row] ?? '',
cursor.col,
),
cpLen(lastLine),
);
if (decision.kind === 'passthrough') return;
key.preventDefault();
@@ -897,7 +923,10 @@ export function OpenTuiInputPrompt(props: InputPromptProps) {
const el = editorRef.current;
if (!el) return;
const text = el.plainText;
const decision = decideSubmit(text, el.cursorOffset);
const decision = decideSubmit(
text,
displayOffsetToCodePointIndex(text, el.cursorOffset),
);
if (decision.kind === 'noop') return;
if (decision.kind === 'newline-continuation') {
el.deleteCharBackward();
@@ -210,6 +210,27 @@ describe('foldLiveEvent tool-result diff', () => {
fileName: 'a.txt',
});
});
it('carries the vision-bridge egress disclosure onto the tool item (R2-3)', () => {
let items = foldLiveEvent([], {
type: 'tool-start',
id: 'tool1',
tool: 'read_file',
title: 'read_file',
});
items = foldLiveEvent(items, {
type: 'tool-result',
id: 'tool1',
display: '',
visionBridgeNotice:
'Content was sent to the vision model — your data left this machine.',
});
const tool = items[0];
if (tool.kind !== 'tool') throw new Error('expected tool item');
expect(tool.visionBridgeNotice).toBe(
'Content was sent to the vision model — your data left this machine.',
);
});
});
describe('foldLiveEvent tool-result ansi', () => {
@@ -35,12 +35,16 @@ export type LiveToolItem = Extract<HistoryItem, { kind: 'tool' }> & {
* (ink TodoDisplay parity) instead of the flattened output text. */
todos?: TodoItem[];
/** Structured AnsiOutputDisplay result: the card renders the styled token
* grid (ink AnsiOutputText parity) instead of the color-stripped text. */
* grid (ink AnsiOutputText parity) instead of the color-stripped output. */
ansi?: {
grid: AnsiToken[][];
totalLines?: number;
totalBytes?: number;
};
/** Vision-bridge egress disclosure (ink ToolMessage renders the notice
* under the result): tells the user their image/prompt left the machine
* via the vision model. */
visionBridgeNotice?: string;
};
export type LiveThinkingItem = Extract<HistoryItem, { kind: 'thinking' }> & {
@@ -269,6 +273,9 @@ export function foldLiveEvent(
if (ev.type === 'tool-result' && ev.diff) next.diff = ev.diff;
if (ev.type === 'tool-result' && ev.todos) next.todos = ev.todos;
if (ev.type === 'tool-result' && ev.ansi) next.ansi = ev.ansi;
if (ev.type === 'tool-result' && ev.visionBridgeNotice) {
next.visionBridgeNotice = ev.visionBridgeNotice;
}
items[i] = next;
}
return items;
@@ -37,6 +37,8 @@ import {
truncateTokenLine,
userMessageMeta,
} from './messages.js';
import { TOOL_STATUS } from '../constants.js';
import { C } from './theme.js';
import type { AnsiToken } from '@qwen-code/qwen-code-core';
import type { LiveToolItem } from './live-session-model.js';
@@ -219,6 +221,26 @@ describe('message meta (ink glyph/color parity)', () => {
} as unknown as LiveToolItem;
expect(toolStatusMeta(item).strikethrough).toBe(true);
});
it('marks the producers. two-L cancelled spelling for strikethrough too (R2-4)', () => {
// Both real producers (event adapter tool_call_response and the client
// tool-run) emit 'cancelled'; the CANCELED glyph must not fall through
// to the red ERROR glyph for them.
const item = {
kind: 'tool',
id: 't',
tool: 'run_shell_command',
title: 'run_shell_command',
output: '',
done: true,
success: false,
summary: 'cancelled',
} as unknown as LiveToolItem;
const meta = toolStatusMeta(item);
expect(meta.strikethrough).toBe(true);
expect(meta.glyph).toBe(TOOL_STATUS.CANCELED);
expect(meta.color).not.toBe(C.red);
});
});
describe('truncateTokenLine (ink wrap="truncate" parity)', () => {
+6 -1
View File
@@ -298,8 +298,13 @@ export function toolStatusMeta(item: LiveToolItem): ToolStatusMeta {
if (item.success) {
return { glyph: TOOL_STATUS.SUCCESS, color: C.green, strikethrough: false };
}
// Both spellings appear in producers: the event adapter and the client
// tool-run emit 'cancelled' (two Ls); 'canceled' is kept for any other
// source. 'interrupted' is the ESC-abort summary.
const canceled =
item.summary === 'interrupted' || item.summary === 'canceled';
item.summary === 'interrupted' ||
item.summary === 'canceled' ||
item.summary === 'cancelled';
if (canceled) {
return { glyph: TOOL_STATUS.CANCELED, color: C.text, strikethrough: true };
}
@@ -38,9 +38,15 @@ interface FakeConfigCalls {
startNewSession: Array<[string, unknown]>;
rebuildTurnBoundaries: number;
clientInitialize: number;
swapBegin: number;
swapCommit: number;
swapAbort: number;
}
function createFakeConfig(options?: { failClientInitialize?: boolean }): {
function createFakeConfig(options?: {
failClientInitialize?: boolean;
beginTelemetrySwapReturns?: boolean;
}): {
config: Config;
calls: FakeConfigCalls;
} {
@@ -48,6 +54,9 @@ function createFakeConfig(options?: { failClientInitialize?: boolean }): {
startNewSession: [],
rebuildTurnBoundaries: 0,
clientInitialize: 0,
swapBegin: 0,
swapCommit: 0,
swapAbort: 0,
};
// Stable registry objects so tests can flip their behavior live.
const backgroundTaskRegistry = {
@@ -90,6 +99,19 @@ function createFakeConfig(options?: { failClientInitialize?: boolean }): {
}
},
}),
getLlmClient: () => ({
beginTelemetrySwap: () => {
calls.swapBegin += 1;
return options?.beginTelemetrySwapReturns ?? true;
},
commitTelemetrySwap: () => {
calls.swapCommit += 1;
},
abortTelemetrySwap: () => {
calls.swapAbort += 1;
return true;
},
}),
loadPausedBackgroundAgents: async () => [],
getBackgroundAgentResumeService: () => ({
buildRecoveredBackgroundAgentsNotice: () => '',
@@ -209,6 +231,45 @@ describe('handleResumeSession', () => {
expect.any(Number),
);
});
it('commits the telemetry swap transaction at the UI commit point (R2-6)', async () => {
const { config, calls } = createFakeConfig();
const host = createFakeHost(config);
await handleResumeSession(host, 'target-session');
expect(calls.swapBegin).toBe(1);
expect(calls.swapCommit).toBe(1);
expect(calls.swapAbort).toBe(0);
});
it('aborts the telemetry swap exactly once when the swap fails (R2-6)', async () => {
const { config, calls } = createFakeConfig({
failClientInitialize: true,
});
const host = createFakeHost(config);
await handleResumeSession(host, 'target-session');
expect(calls.swapBegin).toBe(1);
expect(calls.swapAbort).toBe(1);
expect(calls.swapCommit).toBe(0);
});
it('rejects a concurrent switch when the swap slot is held (R2-6)', async () => {
const { config, calls } = createFakeConfig({
beginTelemetrySwapReturns: false,
});
const host = createFakeHost(config);
await handleResumeSession(host, 'target-session');
expect(calls.swapBegin).toBe(1);
expect(calls.startNewSession).toHaveLength(0);
expect(calls.swapCommit).toBe(0);
expect(calls.swapAbort).toBe(0);
expect(host.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
text: expect.stringContaining('already in progress'),
}),
expect.any(Number),
);
});
});
describe('handleBranchSession', () => {
+56 -1
View File
@@ -112,6 +112,24 @@ export async function handleResumeSession(
return;
}
// Open the telemetry swap transaction BEFORE touching the outgoing
// session (ink useResumeCommand parity): the slot is the only
// serialization for session switches, and a failed swap must restore
// the usage aggregate. A false return means another /resume or /branch
// already holds the single swap slot — reject instead of entangling.
const telemetrySwapOpened =
config.getLlmClient()?.beginTelemetrySwap?.() ?? true;
if (!telemetrySwapOpened) {
host.addItem(
{
type: MessageType.ERROR,
text: 'A session switch is already in progress. Try again in a moment.',
},
Date.now(),
);
return;
}
const oldSessionId = config.getSessionId();
let coreSwapped = false;
let uiSwapped = false;
@@ -121,6 +139,8 @@ export async function handleResumeSession(
const sessionService = new SessionService(cwd);
const sessionData = await sessionService.loadSession(sessionId);
if (!sessionData) {
// Nothing was replayed — close this attempt's unarmed transaction.
config.getLlmClient()?.commitTelemetrySwap?.();
return;
}
const customTitle = sessionService.getSessionTitle(sessionId);
@@ -164,7 +184,8 @@ export async function handleResumeSession(
.buildRecoveredBackgroundAgentsNotice(recovered.length)
: null;
// 2. UI swap.
// 2. UI swap. The commit point is the UI-side session re-key: from here
// on a failure must not roll core back OR undo the telemetry replay.
host.startNewSession(sessionId);
host.setSessionName(customTitle ?? null);
host.clearPendingState();
@@ -178,6 +199,7 @@ export async function handleResumeSession(
);
}
uiSwapped = true;
config.getLlmClient()?.commitTelemetrySwap?.();
} catch (error) {
if (coreSwapped && !uiSwapped) {
try {
@@ -189,6 +211,15 @@ export async function handleResumeSession(
.getDebugLogger()
.warn(`Rollback after failed /resume init failed: ${rollbackErr}`);
}
// Core is back on the old session: restore the usage aggregate to
// pre-swap state, dropping the abandoned session's replayed history.
config.getLlmClient()?.abortTelemetrySwap?.();
} else {
// Either the core swap never happened (nothing was replayed — the
// transaction is unarmed) or the UI already committed (the replay
// belongs to the session the user is on): close this attempt's
// transaction without restoring.
config.getLlmClient()?.commitTelemetrySwap?.();
}
host.addItem(
{
@@ -247,6 +278,21 @@ export async function handleBranchSession(
return;
}
// Telemetry swap transaction (ink useBranchCommand parity): the slot is
// the only serialization for session switches; see the resume handler.
const telemetrySwapOpened =
config.getLlmClient()?.beginTelemetrySwap?.() ?? true;
if (!telemetrySwapOpened) {
host.addItem(
{
type: MessageType.ERROR,
text: 'A session switch is already in progress. Try again in a moment.',
},
Date.now(),
);
return;
}
const oldSessionId = config.getSessionId();
const newSessionId = randomUUID();
const sessionService = config.getSessionService();
@@ -318,6 +364,9 @@ export async function handleBranchSession(
host.resetTranscript(resumeEventsFromSession(resumed, config));
uiSwapped = true;
resetBackgroundStateForSessionSwitch(config);
// The UI re-key commits the swap: from here on a failure keeps the
// replay — it belongs to the session the user is on.
config.getLlmClient()?.commitTelemetrySwap?.();
// 9. Apply the persisted title to the session-name surface.
host.setSessionName(effectiveTitle);
@@ -348,6 +397,12 @@ export async function handleBranchSession(
.getDebugLogger()
.warn(`Rollback after failed /branch init failed: ${rollbackErr}`);
}
// Core is back on the old session: restore the usage aggregate.
config.getLlmClient()?.abortTelemetrySwap?.();
} else {
// Unarmed (core never swapped) or already committed (UI re-keyed):
// close this attempt's transaction without restoring.
config.getLlmClient()?.commitTelemetrySwap?.();
}
if (forkCreated && !uiSwapped) {
try {