Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions surface/centaur-client/src/reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export interface TextItem {
type: 'text';
id: string;
text: string;
executionId: string | null;
messageId?: string;
uuid?: string;
handle?: string | null;
Expand All @@ -37,6 +38,7 @@ export interface ToolCallItem {
id: string;
name: string;
input: JsonObject;
executionId: string | null;
result?: {
content: string;
is_error: boolean;
Expand All @@ -50,6 +52,7 @@ export interface ReasoningItem {
type: 'reasoning';
id: string;
text: string;
executionId: string | null;
summary?: string;
messageId?: string;
handle?: string | null;
Expand All @@ -61,6 +64,7 @@ export interface QuestionItem {
type: 'question';
id: string;
questionId: string;
executionId: string | null;
turnId?: string;
questions: QuestionPrompt[];
status: 'pending' | 'resolved';
Expand All @@ -74,6 +78,7 @@ export interface UserMessageItem {
type: 'user_message';
id: string;
text: string;
executionId: string | null;
handle?: string | null;
ts?: string;
sourceEventIds: number[];
Expand Down Expand Up @@ -149,6 +154,9 @@ export interface ArtifactPresentation {

export interface SessionState {
status: ExecutionStatus | 'idle';
/** Execution currently producing transcript items. Null for legacy frames
* that predate execution identity. */
executionId: string | null;
/** Server-stamped start of the current turn — `turn/started` when the harness
* emits it, else the execution_state frame that flipped us into running. */
turnStartTs?: string;
Expand Down Expand Up @@ -198,6 +206,7 @@ export interface SessionState {
export function initialSessionState(): SessionState {
return {
status: 'idle',
executionId: null,
frameSeq: 0,
deltaChars: 0,
transport: 'ok',
Expand Down Expand Up @@ -249,6 +258,7 @@ function reduceSessionFrame(state: SessionState, frame: CentaurEventFrame): Sess
if (frame.event === 'execution_state') {
const wasActive = state.status !== 'idle' && !isTerminalExecutionStatus(state.status);
next.status = frame.data.status;
next.executionId = frame.data.execution_id ?? null;
if (frame.data.result_text) {
next.resultText = frame.data.result_text;
}
Expand Down Expand Up @@ -523,6 +533,7 @@ function upsertQuestionItem(
type: 'question',
id: `question:${event.question_id}`,
questionId: event.question_id,
executionId: state.executionId,
...(event.turn_id !== undefined ? { turnId: event.turn_id } : {}),
questions: event.questions,
status: 'pending',
Expand Down Expand Up @@ -553,6 +564,7 @@ function resolveQuestionItem(
type: 'question',
id: `question:${questionId}`,
questionId,
executionId: state.executionId,
questions: [],
status: 'resolved',
reason,
Expand Down Expand Up @@ -640,6 +652,7 @@ function upsertClaudeReasoningItem(
type: 'reasoning',
id,
text,
executionId: state.executionId,
messageId: messageKey,
...(handle ? { handle } : {}),
sourceEventIds: [eventId],
Expand Down Expand Up @@ -668,6 +681,7 @@ function appendStreamingText(state: SessionState, eventId: number, text: string,
type: 'text',
id: `text:${eventId}`,
text,
executionId: state.executionId,
...(handle ? { handle } : {}),
sourceEventIds: [eventId],
});
Expand Down Expand Up @@ -709,6 +723,7 @@ function reconcileCompleteText(
id: messageId ? `text:${messageId}` : `text:${uuid}`,
text,
uuid,
executionId: state.executionId,
...optionalProp('messageId', messageId),
...(handle ? { handle } : {}),
sourceEventIds: [eventId],
Expand Down Expand Up @@ -738,6 +753,7 @@ function upsertToolCall(
id: block.id,
name: block.name,
input: block.input,
executionId: state.executionId,
...(handle ? { handle } : {}),
sourceEventIds: [eventId],
};
Expand Down Expand Up @@ -1019,6 +1035,7 @@ function upsertReasoningItem(
type: 'reasoning',
id,
text,
executionId: state.executionId,
...(summary !== undefined ? { summary } : {}),
...(itemId ? { messageId: itemId } : {}),
...(handle ? { handle } : {}),
Expand Down Expand Up @@ -1117,6 +1134,7 @@ function appendCodexStreamingText(
type: 'text',
id: itemId ? `text:codex:${itemId}` : `text:codex:${eventId}`,
text,
executionId: state.executionId,
...optionalProp('messageId', itemId),
sourceEventIds: [eventId],
});
Expand Down Expand Up @@ -1155,6 +1173,7 @@ function reconcileCodexCompleteText(
type: 'text',
id: itemId ? `text:codex:${itemId}` : `text:codex:${eventId}`,
text,
executionId: state.executionId,
...optionalProp('messageId', itemId),
...(handle ? { handle } : {}),
sourceEventIds: [eventId],
Expand Down Expand Up @@ -1188,6 +1207,7 @@ function upsertCodexCommandExecution(
id,
name: 'command',
input,
executionId: state.executionId,
...(handle ? { handle } : {}),
sourceEventIds: [eventId],
};
Expand Down Expand Up @@ -1234,6 +1254,7 @@ function upsertUserMessage(
type: 'user_message',
id,
text,
executionId: state.executionId,
...(handle ? { handle } : {}),
sourceEventIds: [eventId],
};
Expand Down
1 change: 1 addition & 0 deletions surface/centaur-client/src/toolDisplay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { ToolCallItem } from './reducer.js';
const tool = (name: string, input: ToolCallItem['input'] = {}): ToolCallItem => ({
type: 'tool_call',
id: `tool:${name}`,
executionId: null,
name,
input,
sourceEventIds: [1],
Expand Down
33 changes: 17 additions & 16 deletions surface/centaur-client/src/transcriptRows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import {
toolDefaultOpen,
} from './transcriptRows.js';

const item = (id: string, type: SessionItem['type']): SessionItem => ({ id, type }) as SessionItem;
const item = (id: string, type: SessionItem['type'], executionId: string | null = null): SessionItem =>
({ id, type, executionId }) as SessionItem;

describe('focus transcript rows', () => {
it('groups contiguous reasoning, tools, and inline changes into one count', () => {
Expand Down Expand Up @@ -96,26 +97,26 @@ describe('tool default visibility', () => {
describe('turn work folds', () => {
it('groups work after each human input and before that turn’s final answer', () => {
const items = [
{ ...item('ask-1', 'user_message'), text: 'First', ts: '2026-07-14T12:00:00.000Z' },
{ ...item('thought-1', 'reasoning'), text: 'Think', ts: '2026-07-14T12:00:01.000Z' },
{ ...item('ask-1', 'user_message', 'exe-1'), text: 'First', ts: '2026-07-14T12:00:00.000Z' },
{ ...item('thought-1', 'reasoning', 'exe-1'), text: 'Think', ts: '2026-07-14T12:00:01.000Z' },
{
...item('tool-1', 'tool_call'),
...item('tool-1', 'tool_call', 'exe-1'),
name: 'Bash',
input: {},
result: { content: 'ok', is_error: false },
ts: '2026-07-14T12:00:02.000Z',
},
{ ...item('answer-1', 'text'), text: 'Done', ts: '2026-07-14T12:00:04.000Z' },
{ ...item('ask-2', 'user_message'), text: 'Again', ts: '2026-07-14T12:01:00.000Z' },
{ ...item('tool-2', 'tool_call'), name: 'Read', input: {}, ts: '2026-07-14T12:01:01.000Z' },
{ ...item('answer-1', 'text', 'exe-1'), text: 'Done', ts: '2026-07-14T12:00:04.000Z' },
{ ...item('ask-2', 'user_message', 'exe-2'), text: 'Again', ts: '2026-07-14T12:01:00.000Z' },
{ ...item('tool-2', 'tool_call', 'exe-2'), name: 'Read', input: {}, ts: '2026-07-14T12:01:01.000Z' },
] as SessionItem[];

const folds = foldedTurnRows(items);
expect(folds).toHaveLength(2);
expect(folds[0]).toMatchObject({
items: [items[1], items[2]],
toolNames: ['Bash'],
replyOrdinal: 0,
executionId: 'exe-1',
triggerIndex: 0,
triggerOrdinal: 0,
replyIndex: 3,
Expand All @@ -125,25 +126,25 @@ describe('turn work folds', () => {
expect(folds[1]).toMatchObject({
items: [items[5]],
toolNames: ['Read'],
replyOrdinal: null,
executionId: 'exe-2',
triggerIndex: 4,
triggerOrdinal: 1,
replyIndex: null,
completed: false,
});
});

it('preserves the reply ordinal when an earlier turn has no hidden work', () => {
it('takes identity from the fold’s own work and final reply', () => {
const items = [
{ ...item('ask-1', 'user_message'), text: 'First' },
{ ...item('answer-1', 'text'), text: 'Immediate answer' },
{ ...item('ask-2', 'user_message'), text: 'Second' },
{ ...item('thought-2', 'reasoning'), text: 'Think' },
{ ...item('answer-2', 'text'), text: 'Worked answer' },
{ ...item('ask-1', 'user_message', 'exe-1'), text: 'First' },
{ ...item('answer-1', 'text', 'exe-1'), text: 'Immediate answer' },
{ ...item('ask-2', 'user_message', 'exe-2'), text: 'Second' },
{ ...item('thought-2', 'reasoning', 'exe-2'), text: 'Think' },
{ ...item('answer-2', 'text', 'exe-2'), text: 'Worked answer' },
] as SessionItem[];

expect(foldedTurnRows(items)).toHaveLength(1);
expect(foldedTurnRows(items)[0]).toMatchObject({ replyOrdinal: 1, replyIndex: 4 });
expect(foldedTurnRows(items)[0]).toMatchObject({ executionId: 'exe-2', replyIndex: 4 });
});
});

Expand Down
11 changes: 6 additions & 5 deletions surface/centaur-client/src/transcriptRows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@ export interface FoldedTurnRow {
kind: 'fold';
key: string;
turn: number;
/** Zero-based position among completed assistant replies; null for a live turn. */
replyOrdinal: number | null;
executionId: string | null;
items: TurnWorkItem[];
toolNames: string[];
startIndex: number;
Expand Down Expand Up @@ -61,7 +60,6 @@ export function foldedTurnRows(items: readonly SessionItem[]): FoldedTurnRow[] {
let triggerOrdinal: number | null = null;
let nextTriggerOrdinal = 0;
let turn = 0;
let replyOrdinal = 0;

const flush = (segmentEnd: number) => {
let replyIndex: number | null = null;
Expand All @@ -82,11 +80,15 @@ export function foldedTurnRows(items: readonly SessionItem[]): FoldedTurnRow[] {
const first = indexedWork[0]!;
const lastIndex = replyIndex ?? indexedWork[indexedWork.length - 1]!.index;
const durationMs = elapsedMs(first.item, items[lastIndex]);
const executionId =
(replyIndex === null ? null : items[replyIndex]?.executionId) ??
[...workItems].reverse().find((item) => item.executionId !== null)?.executionId ??
null;
folds.push({
kind: 'fold',
key: `turn-${turn}-${first.item.id}`,
turn,
replyOrdinal: replyIndex === null ? null : replyOrdinal,
executionId,
items: workItems,
toolNames,
startIndex: first.index,
Expand All @@ -98,7 +100,6 @@ export function foldedTurnRows(items: readonly SessionItem[]): FoldedTurnRow[] {
completed: replyIndex !== null,
});
}
if (replyIndex !== null) replyOrdinal += 1;
turn += 1;
};

Expand Down
17 changes: 17 additions & 0 deletions surface/centaur-client/test/reducer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,23 @@ const userMessageFrame = (eventId: number, id: string, text: string): CentaurEve
});

describe('reduceSession', () => {
it('stamps items with the execution that produced them', () => {
const running = reduceSession(initialSessionState(), {
event: 'execution_state',
event_id: 1,
data: {
type: 'execution.state',
status: 'running',
thread_key: 'thread-1',
execution_id: 'exe-1',
},
});
const state = reduceSession(running, userMessageFrame(2, 'item-1', 'do the thing'));

expect(state.executionId).toBe('exe-1');
expect(state.items[0]).toMatchObject({ type: 'user_message', executionId: 'exe-1' });
});

it('reduces A_pong without duplicating observed text projections', () => {
const state = reduceAll(fixture('A_pong'));

Expand Down
8 changes: 7 additions & 1 deletion surface/mobile/test/threadWorkFold.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ describe('thread work fold view mapping', () => {
type: 'user_message',
text: 'Inspect it',
ts: '2026-07-16T12:00:00.000Z',
executionId: null,
sourceEventIds,
},
{
Expand All @@ -20,6 +21,7 @@ describe('thread work fold view mapping', () => {
summary: 'Inspecting',
text: 'I will read the file.',
ts: '2026-07-16T12:00:01.000Z',
executionId: null,
sourceEventIds,
},
{
Expand All @@ -29,6 +31,7 @@ describe('thread work fold view mapping', () => {
questions: [{ id: 'prompt-1', header: 'Choice', question: 'Which file?' }],
status: 'resolved',
ts: '2026-07-16T12:00:02.000Z',
executionId: null,
sourceEventIds,
},
{
Expand All @@ -38,13 +41,15 @@ describe('thread work fold view mapping', () => {
input: { file_path: 'src/file.ts' },
result: { content: 'file contents', is_error: false },
ts: '2026-07-16T12:00:03.000Z',
executionId: null,
sourceEventIds,
},
{
id: 'answer',
type: 'text',
text: 'Done',
ts: '2026-07-16T12:00:05.000Z',
executionId: null,
sourceEventIds,
},
];
Expand Down Expand Up @@ -73,12 +78,13 @@ describe('thread work fold view mapping', () => {

it('maps an incomplete turn with no reply as live tool work', () => {
const items: SessionItem[] = [
{ id: 'ask', type: 'user_message', text: 'Run it', sourceEventIds },
{ id: 'ask', type: 'user_message', text: 'Run it', executionId: null, sourceEventIds },
{
id: 'run',
type: 'tool_call',
name: 'Bash',
input: { command: 'pnpm test' },
executionId: null,
sourceEventIds,
},
];
Expand Down
4 changes: 2 additions & 2 deletions surface/mobile/test/turns.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ import { deriveTurns, type Turn } from '../src/components/work/turns';
afterEach(cleanup);

function textItem(id: string, text = 'Agent output'): SessionItem {
return { type: 'text', id, text, sourceEventIds: [1] };
return { type: 'text', id, text, executionId: null, sourceEventIds: [1] };
}

function steerItem(id: string, text: string): SessionItem {
return { type: 'user_message', id, text, sourceEventIds: [2] };
return { type: 'user_message', id, text, executionId: null, sourceEventIds: [2] };
}

describe('deriveTurns', () => {
Expand Down
4 changes: 2 additions & 2 deletions surface/server/src/session-projection-triggers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ describe('session projection triggers', () => {

const replies = await pool.query<{
thread_root_event_id: number;
payload: { session_id: string; text: string; broadcast: boolean };
payload: { session_id: string; text: string; broadcast: boolean; execution_id: string };
}>(
`SELECT thread_root_event_id, payload
FROM events
Expand All @@ -156,7 +156,7 @@ describe('session projection triggers', () => {
expect(replies.rows).toEqual([
{
thread_root_event_id: rootId,
payload: { session_id: sessionId, text: 'done', broadcast: true },
payload: { session_id: sessionId, text: 'done', broadcast: true, execution_id: 'exec-test' },
},
]);
});
Expand Down
Loading
Loading