diff --git a/config/scripts/check-runtime-electron-ratchet.mjs b/config/scripts/check-runtime-electron-ratchet.mjs index 8c20cb8e8c2..d3a86cbb210 100644 --- a/config/scripts/check-runtime-electron-ratchet.mjs +++ b/config/scripts/check-runtime-electron-ratchet.mjs @@ -49,7 +49,9 @@ export const STRUCTURED_CHAT_LANES = [ { directory: ['src', 'shared'] }, { directory: ['src', 'main', 'runtime'], basename: /^(?:structured-|agent-session-)/ }, { directory: ['src', 'main', 'provider-process'] }, - { directory: ['src', 'main', 'acp'] } + { directory: ['src', 'main', 'acp'] }, + { directory: ['src', 'main', 'jsonl-rpc'] }, + { directory: ['src', 'main', 'pi'], basename: /^rpc-/ } ] export function collectStructuredChatEntryPoints(root = ROOT) { diff --git a/config/scripts/check-runtime-electron-ratchet.test.mjs b/config/scripts/check-runtime-electron-ratchet.test.mjs index 9f9e81f0f40..82941bab290 100644 --- a/config/scripts/check-runtime-electron-ratchet.test.mjs +++ b/config/scripts/check-runtime-electron-ratchet.test.mjs @@ -39,6 +39,8 @@ describe('structured chat coverage', () => { 'src/main/runtime/structured-agent-session-host.ts': 'export {}', 'src/main/provider-process/provider-process-teardown.ts': 'export {}', 'src/main/acp/acp-structured-session-adapter.ts': 'export {}', + 'src/main/jsonl-rpc/peer.ts': 'export {}', + 'src/main/pi/rpc-launch.ts': 'export {}', 'src/shared/agent-session-record.ts': 'export {}' } @@ -55,7 +57,8 @@ describe('structured chat coverage', () => { 'src/main/runtime/structured-agent-runtime-registrations.ts', 'src/main/runtime/rpc/methods/structured-agent-session-agents.ts', 'src/main/acp/adapter.ts', - 'src/main/provider-process/worker.ts' + 'src/main/provider-process/worker.ts', + 'src/main/jsonl-rpc/nested/dialect.ts' ] const excluded = [ 'src/main/native-chat/reader.test.ts', @@ -73,7 +76,10 @@ describe('structured chat coverage', () => { 'src/main/runtime/orca-runtime-tests/structured-agent-session-host.ts', 'src/shared/types.d.ts', 'src/main/runtime/other.ts', - 'src/main/runtime/rpc/methods/browser.ts' + 'src/main/runtime/rpc/methods/browser.ts', + 'src/main/jsonl-rpc/peer.test.ts', + 'src/main/pi/rpc-launch.test.ts', + 'src/main/pi/titlebar-extension-service.ts' ] const root = fixture( Object.fromEntries([...sources, ...excluded].map((file) => [file, 'export {}'])) @@ -90,7 +96,9 @@ describe('structured chat coverage', () => { Object.entries(requiredLanes).filter(([file]) => !file.startsWith(`${lane}/`)) ) expect(() => collectStructuredChatEntryPoints(fixture(without))).toThrow(`${lane} is missing`) - expect(collectStructuredChatEntryPoints(fixture(requiredLanes))).toHaveLength(7) + expect(collectStructuredChatEntryPoints(fixture(requiredLanes))).toHaveLength( + Object.keys(requiredLanes).length + ) } ) @@ -101,6 +109,8 @@ describe('structured chat coverage', () => { ...requiredLanes, 'src/main/acp/adapter.ts': "import 'acp-desktop-package'", 'src/main/provider-process/worker.ts': "import 'provider-desktop-package'", + 'src/main/jsonl-rpc/peer.ts': "import 'rpc-desktop-package'", + 'src/main/pi/rpc-launch.ts': "import 'pi-desktop-package'", 'node_modules/acp-desktop-package/package.json': JSON.stringify({ main: 'index.js', sideEffects @@ -110,12 +120,24 @@ describe('structured chat coverage', () => { main: 'index.js', sideEffects }), - 'node_modules/provider-desktop-package/index.js': "require('electron')" + 'node_modules/provider-desktop-package/index.js': "require('electron')", + 'node_modules/rpc-desktop-package/package.json': JSON.stringify({ + main: 'index.js', + sideEffects + }), + 'node_modules/rpc-desktop-package/index.js': "require('electron')", + 'node_modules/pi-desktop-package/package.json': JSON.stringify({ + main: 'index.js', + sideEffects + }), + 'node_modules/pi-desktop-package/index.js': "require('electron')" }) const current = await collectElectronImporters(collectStructuredChatEntryPoints(root)) expect(current.map((file) => file.split('/node_modules/').pop())).toEqual([ 'acp-desktop-package/index.js', - 'provider-desktop-package/index.js' + 'pi-desktop-package/index.js', + 'provider-desktop-package/index.js', + 'rpc-desktop-package/index.js' ]) } ) diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 1edfaacebc7..f1820fd8906 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -150,6 +150,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts', 'src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts', 'src/main/orcad/orcad-automations.test.ts', + 'src/main/pi/rpc-launch-resolution.test.ts', 'src/main/runtime/agent-session-conversation-clear-commit.test.ts', 'src/main/runtime/agent-session-conversation-name-store.test.ts', 'src/main/runtime/agent-session-death-evidence-persistence.test.ts', diff --git a/mobile/src/session/MobileNativeChatQuestion.test.tsx b/mobile/src/session/MobileNativeChatQuestion.test.tsx index 5c877dabb3f..dc4ff203493 100644 --- a/mobile/src/session/MobileNativeChatQuestion.test.tsx +++ b/mobile/src/session/MobileNativeChatQuestion.test.tsx @@ -129,4 +129,71 @@ describe('MobileNativeChatQuestion', () => { await act(async () => cancel.props.onPress()) expect(onCancel).toHaveBeenCalledWith({ itemId: 'question-1', expectedRevision: 7 }) }) + + it('prefills an editor and sends its whitespace unchanged', async () => { + const onAnswer = vi.fn(async () => true) + await act(async () => { + renderer = create( + createElement(MobileNativeChatQuestion, { + question: { + question: 'Edit the draft', + options: [], + multiSelect: false, + optionTokens: [], + freeTextToken: 'editor-token', + freeTextInput: { + allowEmpty: true, + multiline: true, + initialValue: ' draft\n', + placeholder: 'Write here' + } + }, + onAnswer + }) + ) + }) + + const input = renderer.root.findByType('TextInput') + expect(input.props).toMatchObject({ + value: ' draft\n', + placeholder: 'Write here', + multiline: true + }) + await act(async () => input.props.onChangeText(' \n ')) + const send = renderer.root.findByProps({ accessibilityLabel: 'Send reply' }) + expect(send.props.disabled).toBe(false) + await act(async () => send.props.onPress()) + expect(onAnswer).toHaveBeenCalledWith(`editor-token:${encodeURIComponent(' \n ')}`) + }) + + it('sends an empty allowed answer and still disables an empty legacy answer', async () => { + const onAnswer = vi.fn(async () => true) + const question = { + question: 'Input', + options: [], + multiSelect: false, + optionTokens: [], + freeTextToken: 'input-token' + } + await act(async () => { + renderer = create( + createElement(MobileNativeChatQuestion, { + question: { ...question, freeTextInput: { allowEmpty: true, multiline: false } }, + onAnswer + }) + ) + }) + const send = renderer.root.findByProps({ accessibilityLabel: 'Send reply' }) + expect(renderer.root.findByType('TextInput').props.multiline).toBe(false) + expect(send.props.disabled).toBe(false) + await act(async () => send.props.onPress()) + expect(onAnswer).toHaveBeenCalledWith('input-token:') + + await act(async () => + renderer.update(createElement(MobileNativeChatQuestion, { question, onAnswer })) + ) + expect(renderer.root.findByProps({ accessibilityLabel: 'Send reply' }).props.disabled).toBe( + true + ) + }) }) diff --git a/mobile/src/session/MobileNativeChatQuestion.tsx b/mobile/src/session/MobileNativeChatQuestion.tsx index 314e3efd4a1..35dcd568bf2 100644 --- a/mobile/src/session/MobileNativeChatQuestion.tsx +++ b/mobile/src/session/MobileNativeChatQuestion.tsx @@ -30,13 +30,13 @@ export function MobileNativeChatQuestion({ onCollapse }: Props): React.JSX.Element { const [selectedOptionIndexes, setSelectedOptionIndexes] = useState([]) - const [freeText, setFreeText] = useState('') + const [freeText, setFreeText] = useState(() => question.freeTextInput?.initialValue ?? '') const [sending, setSending] = useState(false) const sendingRef = useRef(false) const allowOther = question.allowOther !== false const hasOptions = question.options.length > 0 - const trimmedFreeText = freeText.trim() + const answerText = question.freeTextInput?.allowEmpty ? freeText : freeText.trim() const toggle = (optionIndex: number): void => { setSelectedOptionIndexes((prev) => @@ -72,8 +72,8 @@ export function MobileNativeChatQuestion({ return } const answer = - question.freeTextToken && trimmedFreeText.length > 0 - ? formatQuestionAnswerWithOtherByIndexes(question, selectedOptionIndexes, trimmedFreeText) + question.freeTextToken && answerText.length > 0 + ? formatQuestionAnswerWithOtherByIndexes(question, selectedOptionIndexes, answerText) : formatQuestionAnswerByIndexes(question, selectedOptionIndexes) if (await sendAnswer(answer)) { setFreeText('') @@ -81,20 +81,21 @@ export function MobileNativeChatQuestion({ } const submitFreeText = async (): Promise => { - if (trimmedFreeText.length === 0) { + if (!allowOther || (answerText.length === 0 && !question.freeTextInput?.allowEmpty)) { return } const answer = question.multiSelect && question.freeTextToken && selectedOptionIndexes.length > 0 - ? formatQuestionAnswerWithOtherByIndexes(question, selectedOptionIndexes, trimmedFreeText) - : formatQuestionFreeTextAnswer(question, trimmedFreeText) + ? formatQuestionAnswerWithOtherByIndexes(question, selectedOptionIndexes, answerText) + : formatQuestionFreeTextAnswer(question, answerText) if (await sendAnswer(answer)) { setFreeText('') } } const canSubmitMulti = selectedOptionIndexes.length > 0 && !sending - const canSendFreeText = allowOther && trimmedFreeText.length > 0 && !sending + const canSendFreeText = + allowOther && (answerText.length > 0 || question.freeTextInput?.allowEmpty === true) && !sending // Stable keys for option rows even if an agent repeats a label. const optionRows = useMemo( @@ -178,12 +179,15 @@ export function MobileNativeChatQuestion({ style={mobileNativeChatInputStyles.freeInput} value={freeText} onChangeText={setFreeText} - placeholder={hasOptions ? 'Or type a reply…' : 'Type your reply…'} + placeholder={ + question.freeTextInput?.placeholder ?? + (hasOptions ? 'Or type a reply…' : 'Type your reply…') + } placeholderTextColor={colors.textMuted} selectionColor={colors.accentBlue} onSubmitEditing={submitFreeText} returnKeyType="send" - multiline + multiline={question.freeTextInput?.multiline ?? true} /> { formatQuestionFreeTextAnswer({ ...numbered, freeTextToken: 'target' }, ' hi there ') ).toBe(`target:${encodeURIComponent('hi there')}`) }) + + it('encodes empty and whitespace answers only when allowed', () => { + const withInput: MobileChatQuestion = { + ...numbered, + freeTextToken: 'target', + freeTextInput: { allowEmpty: true } + } + expect(formatQuestionFreeTextAnswer(withInput, '')).toBe('target:') + expect(formatQuestionFreeTextAnswer(withInput, ' \n ')).toBe( + `target:${encodeURIComponent(' \n ')}` + ) + expect(formatQuestionFreeTextAnswer({ ...withInput, freeTextInput: undefined }, ' \n ')).toBe( + '' + ) + }) }) describe('mobileChatQuestionKey', () => { diff --git a/mobile/src/session/mobile-native-chat-question.ts b/mobile/src/session/mobile-native-chat-question.ts index 66ebb75919f..d32b335138f 100644 --- a/mobile/src/session/mobile-native-chat-question.ts +++ b/mobile/src/session/mobile-native-chat-question.ts @@ -3,6 +3,8 @@ // have no structured signal, so we parse the text conservatively and only treat // it as a question when a clear option list is present. +import type { AgentJournalFreeTextInput } from '../../../src/shared/agent-session-journal-types' + export type MobileChatQuestion = { question: string /** Structured prompt identity, present only for durable host prompts. */ @@ -19,6 +21,7 @@ export type MobileChatQuestion = { optionDescriptions?: (string | undefined)[] /** Opaque prefix used when free-text answers must target a specific prompt. */ freeTextToken?: string + freeTextInput?: AgentJournalFreeTextInput } export function mobileChatQuestionKey(question: MobileChatQuestion): string { @@ -203,11 +206,13 @@ export function formatQuestionAnswer(question: MobileChatQuestion, selected: str } export function formatQuestionFreeTextAnswer(question: MobileChatQuestion, text: string): string { - const trimmed = text.trim() - if (trimmed.length === 0) { + const answer = question.freeTextInput?.allowEmpty ? text : text.trim() + if (answer.length === 0 && !question.freeTextInput?.allowEmpty) { return '' } return question.freeTextToken - ? `${question.freeTextToken}:${encodeURIComponent(trimmed)}` - : formatQuestionAnswer(question, [trimmed]) + ? `${question.freeTextToken}:${encodeURIComponent(answer)}` + : question.freeTextInput?.allowEmpty + ? answer + : formatQuestionAnswer(question, [answer]) } diff --git a/mobile/src/session/mobile-structured-agent-prompts-free-text.test.ts b/mobile/src/session/mobile-structured-agent-prompts-free-text.test.ts new file mode 100644 index 00000000000..5cf44a60c47 --- /dev/null +++ b/mobile/src/session/mobile-structured-agent-prompts-free-text.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import type { AgentJournalFreeTextInput } from '../../../src/shared/agent-session-journal-types' +import { formatQuestionFreeTextAnswer } from './mobile-native-chat-question' +import { + projectStructuredQuestion, + structuredQuestionResponseTarget, + type StructuredQuestionItem +} from './mobile-structured-agent-prompts' + +function prompt(freeTextInput?: AgentJournalFreeTextInput): StructuredQuestionItem { + return { + itemId: 'item-1', + revision: 3, + sequence: 1, + observedAt: 1, + body: { + kind: 'question', + question: 'Edit the draft', + options: [], + freeTextQuestionId: 'q1', + ...(freeTextInput ? { freeTextInput } : {}), + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + } + } +} + +describe('mobile structured free-text questions', () => { + it('projects editor input metadata onto the existing question card', () => { + const freeTextInput = { + allowEmpty: true, + multiline: true, + initialValue: 'draft', + placeholder: 'Edit here' + } + expect(projectStructuredQuestion(prompt(freeTextInput))?.freeTextInput).toEqual(freeTextInput) + expect(projectStructuredQuestion(prompt())?.freeTextInput).toBeUndefined() + }) + + it('accepts empty and whitespace text only for the matching allowed prompt', () => { + const current = prompt({ allowEmpty: true }) + const projected = projectStructuredQuestion(current)! + for (const answer of ['', ' \n ']) { + const response = formatQuestionFreeTextAnswer(projected, answer) + expect(structuredQuestionResponseTarget(response, current)).toEqual({ + itemId: 'item-1', + expectedRevision: 3, + answer: { questionId: 'q1', optionIds: [], other: answer } + }) + expect(structuredQuestionResponseTarget(response, prompt())).toBeNull() + } + expect( + structuredQuestionResponseTarget(formatQuestionFreeTextAnswer(projected, ''), { + ...current, + revision: 4 + }) + ).toBeNull() + }) + + it('keeps legacy trimming for ordinary free-text answers', () => { + const current = prompt() + const projected = projectStructuredQuestion(current)! + expect( + structuredQuestionResponseTarget( + formatQuestionFreeTextAnswer(projected, ' draft '), + current + ) + ).toMatchObject({ answer: { other: 'draft' } }) + expect(structuredQuestionResponseTarget('', current)).toBeNull() + }) +}) diff --git a/mobile/src/session/mobile-structured-agent-prompts.ts b/mobile/src/session/mobile-structured-agent-prompts.ts index b36ca43e892..cd1b35d0102 100644 --- a/mobile/src/session/mobile-structured-agent-prompts.ts +++ b/mobile/src/session/mobile-structured-agent-prompts.ts @@ -192,6 +192,7 @@ export function projectStructuredQuestion( ...(optionDescriptions.some(Boolean) ? { optionDescriptions } : {}), multiSelect: false, allowOther: Boolean(prompt.body.freeTextQuestionId), + ...(prompt.body.freeTextInput ? { freeTextInput: prompt.body.freeTextInput } : {}), optionTokens: prompt.body.options.map((option) => encodePromptToken({ kind: 'question-option', @@ -258,8 +259,13 @@ export function structuredQuestionResponseTarget( } const freeText = decodeQuestionFreeTextAnswer(response) if (freeText) { - const answer = freeText.answer.trim() - return answer.length > 0 + const allowEmpty = + currentPrompt?.itemId === freeText.payload.itemId && + currentPrompt.revision === freeText.payload.revision && + currentPrompt.body.freeTextQuestionId === freeText.payload.questionId && + currentPrompt.body.freeTextInput?.allowEmpty === true + const answer = allowEmpty ? freeText.answer : freeText.answer.trim() + return answer.length > 0 || allowEmpty ? { itemId: freeText.payload.itemId, expectedRevision: freeText.payload.revision, @@ -272,6 +278,7 @@ export function structuredQuestionResponseTarget( } const [question] = agentSessionPromptQuestions(currentPrompt.body) const trimmed = response.trim() + const allowEmpty = currentPrompt.body.freeTextInput?.allowEmpty === true const option = currentPrompt.body.options.find( (candidate) => candidate.id === response || candidate.label === trimmed ) @@ -282,11 +289,11 @@ export function structuredQuestionResponseTarget( answer: { questionId: question.id, optionIds: [option.id] } } } - return question && currentPrompt.body.freeTextQuestionId && trimmed + return question && currentPrompt.body.freeTextQuestionId && (trimmed || allowEmpty) ? { itemId: currentPrompt.itemId, expectedRevision: currentPrompt.revision, - answer: { questionId: question.id, optionIds: [], other: trimmed } + answer: { questionId: question.id, optionIds: [], other: allowEmpty ? response : trimmed } } : null } diff --git a/mobile/src/session/mobile-structured-grouped-question.test.ts b/mobile/src/session/mobile-structured-grouped-question.test.ts index 37a4dc125c2..83b1e50e9c1 100644 --- a/mobile/src/session/mobile-structured-grouped-question.test.ts +++ b/mobile/src/session/mobile-structured-grouped-question.test.ts @@ -123,6 +123,43 @@ describe('mobile structured grouped questions', () => { ]) }) + it('projects editor metadata and preserves allowed empty and whitespace answers', () => { + const editor = question({ + options: [], + freeTextInput: { + allowEmpty: true, + multiline: true, + initialValue: 'draft', + placeholder: 'Edit response' + } + }) + const projected = projectGroupedQuestion([editor], null, PROMPT_KEY)! + expect(projected.freeTextInput).toEqual(editor.freeTextInput) + + for (const answer of ['', ' \n ']) { + const result = advanceGroupedQuestion({ + response: formatQuestionFreeTextAnswer(projected, answer), + questions: [editor], + draft: null, + promptKey: PROMPT_KEY + }) + expect(result).toEqual({ + kind: 'submit', + answers: [{ questionId: 'q1', optionIds: [], other: answer }] + }) + } + + const legacy = question({ options: [] }) + expect( + advanceGroupedQuestion({ + response: formatQuestionFreeTextAnswer(projected, ''), + questions: [legacy], + draft: null, + promptKey: PROMPT_KEY + }) + ).toBeNull() + }) + it('keeps selected options and other text for grouped multi-select answers', () => { const questions = [SECOND] const only = projectGroupedQuestion(questions, null, PROMPT_KEY)! diff --git a/mobile/src/session/mobile-structured-grouped-question.ts b/mobile/src/session/mobile-structured-grouped-question.ts index d111b4de95a..7fa5c41b95d 100644 --- a/mobile/src/session/mobile-structured-grouped-question.ts +++ b/mobile/src/session/mobile-structured-grouped-question.ts @@ -122,6 +122,7 @@ export function projectGroupedQuestion( ...(optionDescriptions.some(Boolean) ? { optionDescriptions } : {}), multiSelect: question.multiSelect, allowOther: Boolean(question.freeTextQuestionId), + ...(question.freeTextInput ? { freeTextInput: question.freeTextInput } : {}), optionTokens: question.options.map((option) => encodeGroupedToken({ kind: 'option', @@ -155,11 +156,12 @@ function answerFromResponse( const trimmed = part.trim() const freeText = decodeGroupedFreeTextAnswer(trimmed) if (freeText) { - const answer = freeText.answer.trim() + const allowEmpty = question.freeTextInput?.allowEmpty === true + const answer = allowEmpty ? freeText.answer : freeText.answer.trim() if ( freeText.promptKey !== promptKey || freeText.questionId !== question.id || - answer.length === 0 || + (answer.length === 0 && !allowEmpty) || other !== undefined ) { return null @@ -182,14 +184,14 @@ function answerFromResponse( if (optionIds.some((optionId) => !offered.has(optionId))) { return null } - if (other && !question.freeTextQuestionId) { + if (other !== undefined && !question.freeTextQuestionId) { return null } - const answerCount = optionIds.length + (other ? 1 : 0) + const answerCount = optionIds.length + (other !== undefined ? 1 : 0) if (answerCount === 0 || (!question.multiSelect && answerCount !== 1)) { return null } - return { questionId: question.id, optionIds, ...(other ? { other } : {}) } + return { questionId: question.id, optionIds, ...(other !== undefined ? { other } : {}) } } /** diff --git a/src/main/acp/acp-json-rpc-peer.test.ts b/src/main/acp/acp-json-rpc-peer.test.ts index 3d74a9c1c7c..858bac2d670 100644 --- a/src/main/acp/acp-json-rpc-peer.test.ts +++ b/src/main/acp/acp-json-rpc-peer.test.ts @@ -293,6 +293,22 @@ describe('ACP JSON-RPC peer', () => { output.destroy() }) + it('closes when a request times out mid-write, so a cancel never waits behind it', async () => { + vi.useFakeTimers() + const input = new PassThrough() + const output = new Writable({ write() {} }) + const peer = new AcpJsonRpcPeer(input, output, {}, { requestTimeoutMs: 20 }) + peers.push(peer) + const timedOut = expect(peer.request('stuck', {})).rejects.toBeInstanceOf( + AcpRequestTimeoutError + ) + await vi.advanceTimersByTimeAsync(20) + await timedOut + expect(peer.closed).toBe(true) + await expect(peer.notify('session/cancel', {})).rejects.toBeInstanceOf(AcpRequestTimeoutError) + input.destroy() + }) + it('fails pending requests on an output stream error', async () => { const { peer, agent } = fixture() const rejected = expect(peer.request('wait', {})).rejects.toThrow('Broken pipe') diff --git a/src/main/acp/acp-stdio-error-boundary.ts b/src/main/acp/acp-stdio-error-boundary.ts index 67854b6f95a..6dd0fe81b55 100644 --- a/src/main/acp/acp-stdio-error-boundary.ts +++ b/src/main/acp/acp-stdio-error-boundary.ts @@ -1,16 +1 @@ -import type { Readable, Writable } from 'node:stream' - -function ignoreLateError(): void {} - -export function detachAcpStreamErrorHandler( - stream: Readable | Writable, - handler: (error: Error) => void -): void { - stream.removeListener('error', handler) - if (stream.closed) { - return - } - // Node may emit the write error after its callback has already closed the peer. - stream.on('error', ignoreLateError) - stream.once('close', () => stream.removeListener('error', ignoreLateError)) -} +export { detachProviderStreamErrorHandler as detachAcpStreamErrorHandler } from '../provider-process/provider-stdio-error-boundary' diff --git a/src/main/acp/acp-structured-session-adapter.ts b/src/main/acp/acp-structured-session-adapter.ts index e1fd1f31715..66458084870 100644 --- a/src/main/acp/acp-structured-session-adapter.ts +++ b/src/main/acp/acp-structured-session-adapter.ts @@ -27,7 +27,11 @@ import { endAcpStructuredSession, type AcpStructuredSession } from './acp-structured-session' -import { AcpStructuredStarts, type AcpStartAttempt } from './acp-structured-starts' +import { + ProviderAcquisitionStarts, + type ProviderStartAttempt +} from '../provider-process/provider-acquisition-starts' +import type { AcpStructuredConnection } from './acp-structured-connection' import { waitForAcpExit } from './acp-structured-connection' import { AcpConnectionClosedError } from './acp-errors' import { awaitAcpTurnEnd, interruptAcpTurn, windDownAcpTurn } from './acp-structured-stop' @@ -43,7 +47,7 @@ import { readAcpRecoveryHistory } from './acp-recovery-history' export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapter { /** Live children, and ones whose exit is not yet proven; a proven exit removes its entry. */ private readonly sessions = new Map() - private readonly starts = new AcpStructuredStarts() + private readonly starts = new ProviderAcquisitionStarts() constructor(private readonly deps: AcpStructuredSessionAdapterDeps) {} @@ -75,7 +79,7 @@ export class AcpStructuredSessionAdapter implements StructuredAgentSessionAdapte private async start( input: StructuredAgentSessionAcquireInput, - attempt: AcpStartAttempt + attempt: ProviderStartAttempt ): Promise { const sessionId = input.identity.sessionId const generation = this.deps.mintGeneration?.() ?? randomUUID() diff --git a/src/main/acp/acp-write-queue.ts b/src/main/acp/acp-write-queue.ts index 7538fc04aa6..9a72f21c235 100644 --- a/src/main/acp/acp-write-queue.ts +++ b/src/main/acp/acp-write-queue.ts @@ -1,119 +1,12 @@ import type { Writable } from 'node:stream' +import { ProviderStdioWriteQueue } from '../provider-process/provider-stdio-write-queue' import { AcpConnectionClosedError } from './acp-errors' -type Write = { - line: string - resolve: () => void - reject: (error: Error) => void - detachAbort?: () => void -} - -export class AcpWriteQueue { - private readonly queue: Write[] = [] - private bytes = 0 - private active?: Write - private terminalError?: Error - private detachDrain?: () => void - - constructor( - private readonly output: Writable, - private readonly maxBytes: number, - private readonly onFailure: (error: Error) => void - ) {} - - write(line: string, signal?: AbortSignal): Promise { - if (this.terminalError) { - return Promise.reject(this.terminalError) - } - if (signal?.aborted) { - return Promise.reject(signal.reason) - } - const bytes = Buffer.byteLength(line) - if (this.bytes + bytes > this.maxBytes) { - return Promise.reject(new Error('ACP write queue capacity exceeded')) - } - this.bytes += bytes - return new Promise((resolve, reject) => { - const write: Write = { line, resolve, reject } - const abort = (): void => { - const index = this.queue.indexOf(write) - if (index === -1) { - return - } - this.queue.splice(index, 1) - this.bytes -= bytes - write.detachAbort?.() - reject(signal?.reason) - } - signal?.addEventListener('abort', abort, { once: true }) - write.detachAbort = () => signal?.removeEventListener('abort', abort) - this.queue.push(write) - this.flush() +export class AcpWriteQueue extends ProviderStdioWriteQueue { + constructor(output: Writable, maxBytes: number, onFailure: (error: Error) => void) { + super(output, maxBytes, onFailure, { + capacity: () => new Error('ACP write queue capacity exceeded'), + closed: () => new AcpConnectionClosedError('ACP output is not writable') }) } - - close(error: Error): void { - if (this.terminalError) { - return - } - this.terminalError = error - this.detachDrain?.() - this.active?.reject(error) - this.active = undefined - for (const write of this.queue.splice(0)) { - write.detachAbort?.() - write.reject(error) - } - this.bytes = 0 - } - - private flush(): void { - if (this.active || this.terminalError) { - return - } - const write = this.queue.shift() - if (!write) { - return - } - write.detachAbort?.() - this.active = write - if (this.output.destroyed || !this.output.writable) { - this.onFailure(new AcpConnectionClosedError('ACP output is not writable')) - return - } - let completed = false - let drained = false - let returned = false - const finish = (): void => { - if (!returned || !completed || !drained || this.terminalError) { - return - } - this.detachDrain?.() - this.active = undefined - this.bytes -= Buffer.byteLength(write.line) - write.resolve() - this.flush() - } - const onDrain = (): void => { - drained = true - finish() - } - this.output.once('drain', onDrain) - this.detachDrain = () => this.output.removeListener('drain', onDrain) - try { - const accepted = this.output.write(write.line, (error) => { - if (error) { - this.onFailure(error) - return - } - completed = true - finish() - }) - drained ||= accepted - returned = true - finish() - } catch (error) { - this.onFailure(error instanceof Error ? error : new Error(String(error))) - } - } } diff --git a/src/main/agent-launch/agent-launch-executor.test.ts b/src/main/agent-launch/agent-launch-executor.test.ts index 30e652a6cb4..c27d84cf037 100644 --- a/src/main/agent-launch/agent-launch-executor.test.ts +++ b/src/main/agent-launch/agent-launch-executor.test.ts @@ -159,6 +159,31 @@ describe('a structured launch that creates its own worktree', () => { expect(result.receipt).toMatchObject({ reason: 'structured_support_unknown' }) }) + it('keeps Pi on the terminal path when its RPC version is unsupported', async () => { + const h = harness({ createSupport: { supported: false, reason: 'agent' } }) + const result = await h.run({ + ...CREATE_INTENT, + agent: 'pi', + prompt: { text: 'continue my task', delivery: 'submit' } + }) + expect(h.calls).toEqual([ + 'createWorktree(startupAgent=undefined)', + 'createSupport', + 'createTerminalAgent' + ]) + expect(h.createStructuredSession).not.toHaveBeenCalled() + expect(h.createTerminalAgent).toHaveBeenCalledWith( + expect.objectContaining({ + startupPrompt: 'continue my task' + }) + ) + expect(result.outcome).toEqual({ kind: 'terminal', handle: 'term_1' }) + expect(result.receipt).toMatchObject({ + mode: 'terminal', + reason: 'structured_unsupported_on_host' + }) + }) + it('falls back only for a definitive structured refusal after the worktree exists', async () => { const h = harness({ structuredCreateError: new AgentLaunchStructuredSessionRefusedError( diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.ts index ab205b55975..61566cd084b 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.ts @@ -5,6 +5,7 @@ import { AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY, REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, @@ -42,6 +43,7 @@ export const DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES: readonly RuntimeCapab CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, // The renderer reads `agentSession.agents` and renders a chat tab of any agent its host lists. STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, + PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY, // Without this `supportsAgentLaunch` refuses the renderer outright, while the same renderer diff --git a/src/main/jsonl-rpc/agent-connection.test.ts b/src/main/jsonl-rpc/agent-connection.test.ts new file mode 100644 index 00000000000..d8cd3e009ea --- /dev/null +++ b/src/main/jsonl-rpc/agent-connection.test.ts @@ -0,0 +1,224 @@ +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { spawnProcess } from '../../shared/child-process/run-process' +import { ROOT_ONLY_GRACEFUL_EXIT_MS } from '../provider-process/provider-process-close' +import { PROVIDER_SUPERVISOR_MAX_STOP_MS } from '../provider-process/provider-process-supervisor' +import type { terminateProviderProcessTree } from '../provider-process/provider-process-teardown' +import { JsonlRpcAgentConnection, type JsonlRpcAgentConnectionOptions } from './agent-connection' + +const teardown = vi.hoisted(() => + vi.fn(async () => 'unverifiable') +) +vi.mock('../provider-process/provider-process-teardown', () => ({ + terminateProviderProcessTree: teardown +})) +const opened: { connection: JsonlRpcAgentConnection; exit: () => void }[] = [] +const grace = + process.platform === 'win32' ? ROOT_ONLY_GRACEFUL_EXIT_MS : PROVIDER_SUPERVISOR_MAX_STOP_MS + +function fixture( + options: JsonlRpcAgentConnectionOptions = {}, + behavior: { exitOnEnd?: boolean; processless?: boolean } = {} +) { + const child = Object.assign(new EventEmitter(), { + pid: behavior.processless ? undefined : 9_999_999, + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr: new PassThrough(), + kill: vi.fn(() => true) + }) + const spawn = vi.fn(() => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Fixture supplies piped stdio, pid, kill and process lifecycle events read by the shared supervisor. + return child as unknown as ReturnType + }) + if (behavior.exitOnEnd !== false) { + child.stdin.once('finish', () => child.emit('exit', 0, null)) + } + const connection = new JsonlRpcAgentConnection( + { + command: 'fixture-rpc-agent', + args: ['--mode', 'rpc'], + cwd: '/execution-host/folder', + env: { PI_CODING_AGENT_DIR: '/host/account', STRIPPED: 'overlay' }, + envToDelete: ['STRIPPED'] + }, + options, + spawn + ) + opened.push({ connection, exit: () => child.emit('exit', 0, null) }) + return { child, spawn, connection } +} +afterEach(async () => { + for (const { connection, exit } of opened.splice(0)) { + exit() + await connection.close() + } + teardown.mockReset().mockResolvedValue('unverifiable') + vi.useRealTimers() +}) + +describe('JSON-lines RPC process ownership', () => { + it('uses host-resolved launch data and exposes ordinary RPC replies', async () => { + const { connection, child, spawn } = fixture() + expect(spawn).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + cwd: '/execution-host/folder', + env: expect.objectContaining({ PI_CODING_AGENT_DIR: '/host/account' }), + stdio: ['pipe', 'pipe', 'pipe'] + }) + ) + expect(spawn.mock.calls[0][0].env).not.toHaveProperty('STRIPPED') + const pending = connection.request('get_state') + child.stdout.write( + '{"type":"response","id":"orca-1","command":"get_state","success":true,"data":{"sessionFile":"/host/session.jsonl"}}\n' + ) + expect(await pending).toEqual({ sessionFile: '/host/session.jsonl' }) + expect(await connection.close()).toEqual({ root: 'exited', tree: null }) + }) + + it('settles requests on proven exit with stdout open, and excludes stderr from public errors', async () => { + vi.useFakeTimers() + const onExit = vi.fn() + const { connection, child } = fixture({ onExit }) + const pending = expect(connection.request('prompt')).rejects.toThrow('code 7') + child.stderr.write('sensitive stderr text') + child.emit('exit', 7, null) + child.emit('close', 7, null) + await vi.advanceTimersByTimeAsync(1_000) + await pending + expect(child.stdout.readableEnded).toBe(false) + expect(connection.rootVerdict).toBe('exited') + expect(onExit).toHaveBeenCalledExactlyOnceWith(expect.any(Error), { + expected: false, + exit: { code: 7, signal: null, processless: false } + }) + expect(onExit.mock.calls[0][0].message).not.toContain('sensitive') + }) + + it('drains final response and event bytes written before the root exit', async () => { + const onRecord = vi.fn() + const onExit = vi.fn() + const { connection, child } = fixture({ onRecord, onExit }) + const request = connection.request('get_state') + child.emit('exit', 0, null) + expect(connection.rootVerdict).toBe('exited') + expect(onExit).not.toHaveBeenCalled() + child.stdout.end( + '{"type":"response","command":"get_state","id":"orca-1","success":true,"data":{}}\n{"type":"agent_settled"}\n' + ) + await expect(request).resolves.toEqual({}) + await new Promise((resolve) => setImmediate(resolve)) + expect(onRecord).toHaveBeenCalledExactlyOnceWith({ type: 'agent_settled' }) + expect(onExit).toHaveBeenCalledOnce() + }) + + it('closes a broken stdout transport and cleans up, while keeping host exit evidence separate', async () => { + vi.useFakeTimers() + const onExit = vi.fn() + const onClose = vi.fn() + const { connection, child } = fixture({ onExit, onClose }, { exitOnEnd: false }) + const pending = expect(connection.request('prompt')).rejects.toThrow('stream closed') + child.stdout.emit('end') + await pending + expect(connection.closed).toBe(true) + expect(connection.rootVerdict).toBe('live') + expect(onClose).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(250) + expect(onClose).toHaveBeenCalledOnce() + expect(onExit).not.toHaveBeenCalled() + child.emit('exit', 0, null) + expect(connection.rootVerdict).toBe('exited') + await connection.close() + }) + + it('preserves the numeric exit when stdout closes first', async () => { + const onClose = vi.fn(), + onExit = vi.fn() + const { connection, child } = fixture({ onClose, onExit }, { exitOnEnd: false }) + child.stdout.emit('end') + child.emit('exit', 7, null) + expect(onClose).not.toHaveBeenCalled() + expect(onExit).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('code 7') }), + expect.anything() + ) + await connection.close() + }) + + it('joins close attempts and retries unproven exit without starting another process', async () => { + vi.useFakeTimers() + const { connection, child, spawn } = fixture({}, { exitOnEnd: false }) + const first = connection.close() + const joined = connection.close() + await vi.advanceTimersByTimeAsync(grace + 1_000) + expect(await first).toEqual({ root: 'live', tree: 'unverifiable' }) + expect(await joined).toEqual({ root: 'live', tree: 'unverifiable' }) + expect(teardown).toHaveBeenCalledOnce() + teardown.mockImplementationOnce(async () => { + child.emit('exit', null, 'SIGKILL') + return 'unverifiable' + }) + const retry = connection.close() + await vi.advanceTimersByTimeAsync(grace + 1_000) + expect(await retry).toEqual({ root: 'exited', tree: 'unverifiable' }) + expect(spawn).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + }) + + it('keeps retained descendant uncertainty when the root exits between attempts', async () => { + vi.useFakeTimers() + const { connection, child } = fixture({}, { exitOnEnd: false }) + const close = connection.close() + await vi.advanceTimersByTimeAsync(grace + 1_000) + await close + child.emit('exit', null, 'SIGKILL') + await connection.close() + expect(connection.lastCloseResult).toEqual({ root: 'live', tree: 'unverifiable' }) + expect(connection.rootVerdict).toBe('exited') + }) + + it('requires processless close evidence after a failed spawn', async () => { + const onExit = vi.fn() + const { connection, child } = fixture({ onExit }, { processless: true, exitOnEnd: false }) + const pending = expect(connection.request('prompt')).rejects.toThrow('ENOENT') + child.emit('error', new Error('ENOENT')) + await pending + expect(connection.rootVerdict).toBe('unverifiable') + expect(onExit).not.toHaveBeenCalled() + child.emit('close', null, null) + expect(connection.rootVerdict).toBe('exited') + expect(onExit.mock.calls[0][1].exit.processless).toBe(true) + }) + + it('handles stderr failure, and isolates failing close and exit observers', async () => { + const { connection, child } = fixture({ + onClose: () => { + throw new Error('close observer') + }, + onExit: () => { + throw new Error('exit observer') + }, + onDiagnostic: () => { + throw new Error('diagnostic observer') + } + }) + const pending = expect(connection.request('prompt')).rejects.toThrow('stderr failed') + child.stderr.emit('error', new Error('stderr failed')) + await pending + expect(() => child.emit('exit', 1, null)).not.toThrow() + }) + + it('validates peer limits before spawning', () => { + const spawn = vi.fn() + expect( + () => + new JsonlRpcAgentConnection( + { command: 'fixture', args: [] }, + { peer: { requestTimeoutMs: 0 } }, + spawn + ) + ).toThrow('timer duration') + expect(spawn).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/jsonl-rpc/agent-connection.ts b/src/main/jsonl-rpc/agent-connection.ts new file mode 100644 index 00000000000..65bd6cfc9ad --- /dev/null +++ b/src/main/jsonl-rpc/agent-connection.ts @@ -0,0 +1,182 @@ +import { spawnProcess } from '../../shared/child-process/run-process' +import { + spawnManagedProviderProcess, + type ManagedProviderProcess, + type ProviderProcessExit +} from '../provider-process/managed-provider-process' +import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch' +import type { ProviderProcessCloseResult } from '../provider-process/provider-process-close' +import { + JsonlRpcPeer, + JsonlRpcStreamClosedError, + type JsonlRpcPeerHandlers, + type JsonlRpcRecord +} from './peer' +import { resolveJsonlRpcPeerOptions, type JsonlRpcPeerOptions } from './peer-limits' + +export type JsonlRpcAgentConnectionOptions = JsonlRpcPeerHandlers & { + peer?: JsonlRpcPeerOptions + onExit?: (error: Error, context: { expected: boolean; exit: ProviderProcessExit }) => void +} + +/** The owning runtime keeps process exit evidence separate from transport closure. */ +export class JsonlRpcAgentConnection { + private readonly managed: ManagedProviderProcess + private readonly peer: JsonlRpcPeer + private closing = false + private finishObservedExit?: () => void + private streamExitTimer?: ReturnType + + constructor( + launch: ProviderProcessLaunch, + private readonly options: JsonlRpcAgentConnectionOptions = {}, + spawnImpl: typeof spawnProcess = spawnProcess + ) { + const peerOptions = resolveJsonlRpcPeerOptions(options.peer) + this.managed = spawnManagedProviderProcess(launch, { + spawnImpl, + site: 'jsonl-rpc-agent-teardown' + }) + const managed = this.managed + this.peer = new JsonlRpcPeer( + managed.child.stdout, + managed.child.stdin, + { + ...options, + onClose: (error) => { + const fail = (): void => { + if (this.closing || managed.rootVerdict === 'exited') { + return + } + void managed.close().then( + (result) => { + if (result.root !== 'exited') { + this.diagnose('Agent exit was not proven after RPC failure') + } + }, + () => this.diagnose('Agent cleanup failed after RPC failure') + ) + options.onClose?.(error) + } + if ( + error instanceof JsonlRpcStreamClosedError && + !this.closing && + managed.rootVerdict !== 'exited' + ) { + // EOF often precedes the exit carrying an import/startup failure's code. + this.streamExitTimer = setTimeout(fail, 250) + this.streamExitTimer.unref() + } else { + fail() + } + } + }, + peerOptions + ) + managed.child.on('error', this.onError) + managed.child.stderr.on('error', this.onError) + managed.onExit((exit) => { + clearTimeout(this.streamExitTimer) + const error = new Error( + exit.processless + ? 'Agent process could not start' + : `Agent process exited (code ${exit.code ?? 'none'}, signal ${exit.signal ?? 'none'})` + ) + this.peer.finishOnInputEnd(error) + const stdout = managed.child.stdout + let finished = false + let timer: ReturnType | undefined + const finish = (): void => { + if (finished) { + return + } + finished = true + clearTimeout(timer) + stdout.removeListener('end', finish) + stdout.removeListener('close', finish) + this.finishObservedExit = undefined + this.peer.close(error) + managed.child.removeListener('error', this.onError) + try { + options.onExit?.(error, { expected: this.closing, exit }) + } catch { + this.diagnose('Agent exit observer failed') + } + } + this.finishObservedExit = finish + if ( + this.closing || + exit.processless || + this.peer.closed || + stdout.destroyed || + stdout.readableEnded + ) { + finish() + } else { + stdout.once('end', finish) + stdout.once('close', finish) + // A descendant can inherit stdout after the provider root exits. + timer = setTimeout(finish, 1_000) + timer.unref() + } + }) + } + + get pid(): number | undefined { + return this.managed.child.pid + } + get closed(): boolean { + return this.peer.closed + } + get rootVerdict(): ManagedProviderProcess['rootVerdict'] { + return this.managed.rootVerdict + } + get processless(): boolean { + return this.managed.processless + } + get lastCloseResult(): ManagedProviderProcess['lastCloseResult'] { + return this.managed.lastCloseResult + } + + onExit(listener: () => void): void { + this.managed.onExit(listener) + } + + request( + command: string, + params: Record = {}, + options: { timeoutMs?: number | null } = {} + ): Promise { + return this.peer.request(command, params, options) + } + + send(record: JsonlRpcRecord): Promise { + return this.peer.send(record) + } + + pauseReading(): void { + this.peer.pauseReading() + } + + resumeReading(): void { + this.peer.resumeReading() + } + + /** Stop sends the dialect's abort first, then closes this connection through the supervisor. */ + close(error?: Error): Promise { + this.closing = true + clearTimeout(this.streamExitTimer) + this.finishObservedExit?.() + this.peer.close(error) + return this.managed.close() + } + + private readonly onError = (error: Error): void => this.peer.close(error) + private diagnose(message: string): void { + try { + this.options.onDiagnostic?.(message) + } catch { + /* Diagnostics cannot break cleanup. */ + } + } +} diff --git a/src/main/jsonl-rpc/peer-limits.ts b/src/main/jsonl-rpc/peer-limits.ts new file mode 100644 index 00000000000..851adec01f4 --- /dev/null +++ b/src/main/jsonl-rpc/peer-limits.ts @@ -0,0 +1,36 @@ +import { isSafeTimerDelayMs } from '../../shared/timer-delay' + +export type JsonlRpcPeerOptions = { + maxLineBytes?: number + maxQueuedWriteBytes?: number + maxPendingRequests?: number + requestTimeoutMs?: number +} + +export function resolveJsonlRpcPeerOptions( + options: JsonlRpcPeerOptions = {} +): Required { + return { + maxLineBytes: positiveInteger(options.maxLineBytes, 8 * 1024 * 1024), + maxQueuedWriteBytes: positiveInteger(options.maxQueuedWriteBytes, 32 * 1024 * 1024), + maxPendingRequests: positiveInteger(options.maxPendingRequests, 128), + requestTimeoutMs: jsonlRpcRequestTimeout(options.requestTimeoutMs ?? 30_000) + } +} + +function positiveInteger(value: number | undefined, fallback: number): number { + if (value === undefined) { + return fallback + } + if (!Number.isSafeInteger(value) || value <= 0) { + throw new RangeError('JSON-lines RPC limits must be positive finite integers') + } + return value +} + +export function jsonlRpcRequestTimeout(value: number): number { + if (!isSafeTimerDelayMs(value) || value <= 0) { + throw new RangeError('JSON-lines RPC timeout must be a positive finite timer duration') + } + return value +} diff --git a/src/main/jsonl-rpc/peer.test.ts b/src/main/jsonl-rpc/peer.test.ts new file mode 100644 index 00000000000..dc4b8e816b5 --- /dev/null +++ b/src/main/jsonl-rpc/peer.test.ts @@ -0,0 +1,224 @@ +import { PassThrough, Writable } from 'node:stream' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { JsonlRpcPeer, type JsonlRpcPeerHandlers } from './peer' +import type { JsonlRpcPeerOptions } from './peer-limits' + +const peers: JsonlRpcPeer[] = [] +function fixture( + handlers: JsonlRpcPeerHandlers = {}, + options: JsonlRpcPeerOptions = {}, + output = new PassThrough() +) { + const input = new PassThrough() + const peer = new JsonlRpcPeer(input, output, handlers, options) + peers.push(peer) + const reply = (record: unknown): void => { + input.write(`${JSON.stringify(record)}\n`) + } + return { input, output, peer, reply } +} +afterEach(() => { + for (const peer of peers.splice(0)) { + peer.close() + } + vi.useRealTimers() +}) + +describe('JSON-lines RPC peer', () => { + it('correlates control replies and preserves unknown event fields', async () => { + const onRecord = vi.fn() + const { peer, output, reply } = fixture({ onRecord }) + const pending = peer.request('get_state') + expect(JSON.parse(output.read().toString())).toEqual({ type: 'get_state', id: 'orca-1' }) + reply({ type: 'agent_start', future: { enabled: true } }) + reply({ + type: 'response', + id: 'orca-1', + command: 'get_state', + success: true, + data: { isStreaming: false } + }) + expect(await pending).toEqual({ isStreaming: false }) + expect(onRecord).toHaveBeenCalledExactlyOnceWith({ + type: 'agent_start', + future: { enabled: true } + }) + }) + + it('frames only on LF, preserving Unicode separators and split UTF-8 code points', () => { + const onRecord = vi.fn() + const { input } = fixture({ onRecord }) + const bytes = Buffer.from('{"type":"message_update","text":"a\u2028b\u2029c😀"}\r\n') + const split = bytes.indexOf(Buffer.from('😀')) + 2 + input.write(bytes.subarray(0, split)) + expect(onRecord).not.toHaveBeenCalled() + input.write(bytes.subarray(split)) + expect(onRecord).toHaveBeenCalledExactlyOnceWith({ + type: 'message_update', + text: 'a\u2028b\u2029c😀' + }) + }) + + it('pauses within a burst and preserves queued records and a partial suffix across repeated pauses', async () => { + const records: unknown[] = [] + const { peer, input } = fixture({ + onRecord: (record) => { + records.push(record) + peer.pauseReading() + } + }) + input.write('{"type":"first"}\n{"type":"second"}\n{"type":"thi') + expect(records).toEqual([{ type: 'first' }]) + input.write('rd"}\n{"type":"fourth"}\n') + peer.resumeReading() + expect(records).toEqual([{ type: 'first' }, { type: 'second' }]) + peer.resumeReading() + await new Promise((resolve) => setImmediate(resolve)) + expect(records).toEqual([{ type: 'first' }, { type: 'second' }, { type: 'third' }]) + peer.resumeReading() + expect(records).toEqual([ + { type: 'first' }, + { type: 'second' }, + { type: 'third' }, + { type: 'fourth' } + ]) + }) + + it('ignores non-JSON output and routes id-less acknowledgements and dialogs', async () => { + const onRecord = vi.fn() + const onDiagnostic = vi.fn() + const { peer, input, output, reply } = fixture({ onRecord, onDiagnostic }) + const writes: string[] = [] + output.on('data', (chunk: Buffer) => writes.push(chunk.toString())) + input.write('extension banner\n\n') + await peer.send({ type: 'prompt', message: '/ask' }) + reply({ type: 'extension_ui_request', id: 'dialog-1', method: 'input', title: 'Answer' }) + await peer.send({ type: 'extension_ui_response', id: 'dialog-1', value: '' }) + reply({ type: 'response', command: 'prompt', success: true }) + expect(onRecord.mock.calls.map(([record]) => record.type)).toEqual([ + 'extension_ui_request', + 'response' + ]) + expect(writes.join('')).toContain('"value":""') + expect(onDiagnostic).toHaveBeenCalledExactlyOnceWith('Ignored non-JSON agent output') + }) + + it('does not time out prompts waiting for slash expansion, but bounds control requests', async () => { + vi.useFakeTimers() + const { peer, reply } = fixture({}, { requestTimeoutMs: 100 }) + const prompt = peer.request('prompt', { message: '/ask' }, { timeoutMs: null }) + const control = expect(peer.request('get_state')).rejects.toThrow('get_state in time') + await vi.advanceTimersByTimeAsync(1_000) + await control + reply({ type: 'response', id: 'orca-1', command: 'prompt', success: true }) + await expect(prompt).resolves.toBeUndefined() + const bounded = expect(peer.request('prompt', {}, { timeoutMs: 10 })).rejects.toThrow('in time') + await vi.advanceTimersByTimeAsync(10) + await bounded + expect(vi.getTimerCount()).toBe(0) + }) + + it.each([ + { type: 'response', command: 'wrong', success: true }, + { type: 'response', command: 'prompt', success: 'yes' }, + { type: 'reponse', command: 'prompt', success: true }, + { type: 4 } + ])('rejects malformed identified replies without stranding a prompt: %j', async (record) => { + const { peer, reply } = fixture() + const pending = expect(peer.request('prompt')).rejects.toThrow('Invalid agent RPC response') + reply({ ...record, id: 'orca-1' }) + await pending + }) + + it('ignores unsolicited replies and preserves structured command errors', async () => { + const onRecord = vi.fn() + const { peer, reply } = fixture({ onRecord }) + const pending = expect(peer.request('get_state')).rejects.toThrow( + 'No API key found for provider' + ) + reply({ type: 'response', id: 'other', command: 'get_state', success: true }) + reply({ + type: 'response', + id: 'orca-1', + command: 'get_state', + success: false, + error: 'No API key found for provider' + }) + await pending + expect(onRecord).not.toHaveBeenCalled() + }) + + it('closes on oversized records, with bounded diagnostics and no later dispatch', async () => { + const onRecord = vi.fn() + const { peer, input, reply } = fixture({ onRecord }, { maxLineBytes: 64 }) + const pending = expect(peer.request('get_state')).rejects.toThrow('size limit') + input.write(`{"type":"secret","text":"${'x'.repeat(100)}`) + reply({ type: 'agent_settled' }) + await pending + expect(peer.closed).toBe(true) + expect(onRecord).not.toHaveBeenCalled() + }) + + it('enforces pending capacity, and releases it after timeout and invalid response', async () => { + vi.useFakeTimers() + const { peer, reply } = fixture({}, { maxPendingRequests: 1, requestTimeoutMs: 10 }) + const first = expect(peer.request('get_state')).rejects.toThrow('in time') + await expect(peer.request('get_state')).rejects.toThrow('capacity') + await vi.advanceTimersByTimeAsync(10) + await first + const second = peer.request('get_state') + reply({ type: 'response', id: 'orca-2', command: 'get_state', success: true }) + await second + expect(vi.getTimerCount()).toBe(0) + }) + + it('settles all requests and detaches listeners on EOF, and isolates close observers', async () => { + const { peer, input, output } = fixture({ + onClose: () => { + throw new Error('observer') + } + }) + const pending = expect(peer.request('prompt')).rejects.toThrow('stream closed') + input.emit('end') + input.emit('close') + await pending + expect(input.listenerCount('data')).toBe(0) + expect(output.listenerCount('drain')).toBe(0) + await expect(peer.send({ type: 'abort' })).rejects.toThrow('stream closed') + }) + + it('rejects future writes after a broken pipe and consumes delayed stream errors', async () => { + const output = new Writable({ + autoDestroy: false, + write(_data, _encoding, callback) { + callback(new Error('broken pipe')) + } + }) + const input = new PassThrough() + const peer = new JsonlRpcPeer(input, output) + peers.push(peer) + await expect(peer.send({ type: 'abort' })).rejects.toThrow('broken pipe') + await expect(peer.request('prompt')).rejects.toThrow('broken pipe') + expect(() => output.emit('error', new Error('late error'))).not.toThrow() + }) + + it('closes a stalled active control write on timeout so later aborts cannot wait on it', async () => { + vi.useFakeTimers() + const output = new Writable({ write() {} }) + const peer = new JsonlRpcPeer(new PassThrough(), output, {}, { requestTimeoutMs: 10 }) + peers.push(peer) + const pending = expect(peer.request('get_state')).rejects.toThrow('in time') + await vi.advanceTimersByTimeAsync(10) + await pending + expect(peer.closed).toBe(true) + await expect(peer.send({ type: 'abort' })).rejects.toThrow('in time') + }) + + it('rejects invalid limits before installing stream listeners', () => { + const input = new PassThrough() + expect(() => new JsonlRpcPeer(input, new PassThrough(), {}, { maxLineBytes: 0 })).toThrow( + 'positive finite integers' + ) + expect(input.listenerCount('data')).toBe(0) + }) +}) diff --git a/src/main/jsonl-rpc/peer.ts b/src/main/jsonl-rpc/peer.ts new file mode 100644 index 00000000000..992d7f987e2 --- /dev/null +++ b/src/main/jsonl-rpc/peer.ts @@ -0,0 +1,295 @@ +import type { Readable, Writable } from 'node:stream' +import { z } from 'zod' +import { + createIncrementalNdjsonFramer, + encodeNdjson +} from '../../shared/main-process-ndjson-framer' +import { detachProviderStreamErrorHandler } from '../provider-process/provider-stdio-error-boundary' +import { ProviderStdioWriteQueue } from '../provider-process/provider-stdio-write-queue' +import { + jsonlRpcRequestTimeout, + resolveJsonlRpcPeerOptions, + type JsonlRpcPeerOptions +} from './peer-limits' + +const recordSchema = z.looseObject({ type: z.string() }) +const responseSchema = z.looseObject({ + type: z.literal('response'), + id: z.string().optional(), + command: z.string(), + success: z.boolean(), + data: z.unknown().optional(), + error: z.string().optional() +}) + +export type JsonlRpcRecord = z.infer +export class JsonlRpcStreamClosedError extends Error { + constructor() { + super('Agent JSON-lines RPC stream closed') + this.name = 'JsonlRpcStreamClosedError' + } +} +export class JsonlRpcResponseError extends Error { + constructor( + readonly command: string, + message: string + ) { + super(message) + this.name = 'JsonlRpcResponseError' + } +} +export type JsonlRpcPeerHandlers = { + /** Includes deferred prompt acknowledgements without an id. */ + /** A dialect failure is fatal: continuing would discard session or tool lifecycle evidence. */ + onRecord?: (record: JsonlRpcRecord) => void + onClose?: (error: Error) => void + onDiagnostic?: (message: string) => void +} +type PendingRequest = { + command: string + resolve: (value: unknown) => void + reject: (error: Error) => void + timer?: ReturnType +} + +/** Transport only: a dialect owns turns, dialogs, and session handles. */ +export class JsonlRpcPeer { + private readonly limits: Required + private readonly pending = new Map() + private readonly writer: ProviderStdioWriteQueue + private readonly framer: ReturnType + private nextId = 0 + private terminalError?: Error + private inputEndError?: Error + private readingPaused = false + + constructor( + private readonly input: Readable, + private readonly output: Writable, + private readonly handlers: JsonlRpcPeerHandlers = {}, + options: JsonlRpcPeerOptions = {} + ) { + this.limits = resolveJsonlRpcPeerOptions(options) + this.writer = new ProviderStdioWriteQueue( + output, + this.limits.maxQueuedWriteBytes, + (error) => this.close(error), + { + capacity: () => new Error('JSON-lines RPC write queue capacity exceeded'), + closed: () => new Error('JSON-lines RPC output is not writable') + } + ) + this.framer = createIncrementalNdjsonFramer( + (record) => this.dispatch(record), + (rejected) => { + if (rejected.kind === 'line-too-long') { + this.close(new Error('JSON-lines RPC record exceeded the size limit')) + } else { + this.diagnose('Ignored non-JSON agent output') + } + }, + { + maxLineBytes: this.limits.maxLineBytes, + shouldPause: () => this.readingPaused || this.closed + } + ) + input.setEncoding('utf8') + input.on('data', this.onData) + input.on('end', this.onEnd) + input.on('close', this.onEnd) + input.on('error', this.onError) + output.on('finish', this.onEnd) + output.on('close', this.onEnd) + output.on('error', this.onError) + if (input.destroyed || input.readableEnded || output.destroyed || !output.writable) { + this.onEnd() + } + } + + get closed(): boolean { + return this.terminalError !== undefined + } + + /** A process exit can precede the last bytes already written to stdout. */ + finishOnInputEnd(error: Error): void { + this.inputEndError = error + } + + pauseReading(): void { + this.readingPaused = true + this.input.pause() + } + + resumeReading(): void { + if (this.closed) { + return + } + this.readingPaused = false + try { + this.framer.resume() + if (!this.readingPaused && !this.closed) { + this.input.resume() + } + } catch (error) { + this.close(error instanceof Error ? error : new Error(String(error))) + } + } + + request( + command: string, + params: Record = {}, + options: { timeoutMs?: number | null } = {} + ): Promise { + if (this.terminalError) { + return Promise.reject(this.terminalError) + } + if (this.pending.size >= this.limits.maxPendingRequests) { + return Promise.reject(new Error('JSON-lines RPC pending request capacity exceeded')) + } + let timeoutMs: number | null + try { + timeoutMs = + options.timeoutMs === null + ? null + : jsonlRpcRequestTimeout(options.timeoutMs ?? this.limits.requestTimeoutMs) + } catch (error) { + return Promise.reject(error) + } + const id = `orca-${++this.nextId}` + const controller = new AbortController() + return new Promise((resolve, reject) => { + const timer = + timeoutMs === null + ? undefined + : setTimeout(() => { + this.pending.delete(id) + const error = new Error(`Agent did not answer ${command} in time`) + controller.abort(error) + reject(error) + }, timeoutMs) + timer?.unref() + this.pending.set(id, { command, resolve, reject, timer }) + void this.send({ ...params, type: command, id }, controller.signal).catch( + (error: unknown) => { + this.rejectPending(id, error instanceof Error ? error : new Error(String(error))) + } + ) + }) + } + + /** The dialect may track acknowledgements independently of transport correlation. */ + send(record: JsonlRpcRecord, signal?: AbortSignal): Promise { + if (this.terminalError) { + return Promise.reject(this.terminalError) + } + try { + return this.writer.write(encodeNdjson(record, this.limits.maxLineBytes), signal) + } catch (error) { + return Promise.reject(error) + } + } + + close(error: Error = new Error('Agent JSON-lines RPC stream closed')): void { + if (this.terminalError) { + return + } + this.terminalError = error + this.input.removeListener('data', this.onData) + this.input.removeListener('end', this.onEnd) + this.input.removeListener('close', this.onEnd) + detachProviderStreamErrorHandler(this.input, this.onError) + this.output.removeListener('finish', this.onEnd) + this.output.removeListener('close', this.onEnd) + detachProviderStreamErrorHandler(this.output, this.onError) + this.framer.reset() + this.writer.close(error) + for (const id of this.pending.keys()) { + this.rejectPending(id, error) + } + try { + this.handlers.onClose?.(error) + } catch { + this.diagnose('Agent RPC close observer failed') + } + } + + private readonly onData = (chunk: string): void => { + try { + this.framer.feed(chunk) + } catch (error) { + this.close(error instanceof Error ? error : new Error(String(error))) + } + } + private readonly onEnd = (): void => + this.close(this.inputEndError ?? new JsonlRpcStreamClosedError()) + private readonly onError = (error: Error): void => this.close(error) + + private diagnose(message: string): void { + try { + this.handlers.onDiagnostic?.(message) + } catch { + /* Diagnostics cannot break cleanup. */ + } + } + + private rejectPending(id: string, error: Error): void { + const pending = this.pending.get(id) + if (!pending) { + return + } + this.pending.delete(id) + clearTimeout(pending.timer) + pending.reject(error) + } + + private dispatch(value: unknown): void { + if (this.closed) { + return + } + const parsed = recordSchema.safeParse(value) + if (!parsed.success) { + const identified = z.object({ id: z.string() }).safeParse(value) + if (identified.success) { + this.rejectPending(identified.data.id, new Error('Invalid agent RPC response')) + } + this.diagnose('Ignored invalid agent RPC record') + return + } + const record = parsed.data + if ( + record.type !== 'response' && + record.type !== 'extension_ui_request' && + typeof record.id === 'string' && + this.pending.has(record.id) && + (Object.hasOwn(record, 'command') || Object.hasOwn(record, 'success')) + ) { + this.rejectPending(record.id, new Error('Invalid agent RPC response')) + return + } + if (record.type === 'response' && typeof record.id === 'string') { + const pending = this.pending.get(record.id) + if (!pending) { + return + } + const response = responseSchema.safeParse(record) + if (!response.success || response.data.command !== pending.command) { + this.rejectPending(record.id, new Error('Invalid agent RPC response')) + return + } + this.pending.delete(record.id) + clearTimeout(pending.timer) + if (response.data.success) { + pending.resolve(response.data.data) + } else { + pending.reject( + new JsonlRpcResponseError( + pending.command, + response.data.error ?? `Agent rejected ${pending.command}` + ) + ) + } + return + } + this.handlers.onRecord?.(record) + } +} diff --git a/src/main/jsonl-rpc/timeline-final-tail.test.ts b/src/main/jsonl-rpc/timeline-final-tail.test.ts new file mode 100644 index 00000000000..f39f9e78991 --- /dev/null +++ b/src/main/jsonl-rpc/timeline-final-tail.test.ts @@ -0,0 +1,161 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types' +import { + closeProviderTimelineRigs, + openProviderTimelineRig, + pendingApproval, + SESSION, + GENERATION, + NAMESPACE +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { providerTimelineSink } from '../native-chat/agent-session-timeline/provider-timeline-plan' +import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { evictStructuredAgentSession } from '../native-chat/agent-session-wire/structured-agent-session-eviction' +import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' +import { JsonlRpcTimelineLane } from './timeline-lane' + +const cleanups: (() => void)[] = [] +afterEach(async () => { + cleanups.splice(0).forEach((cleanup) => cleanup()) + vi.useRealTimers() + await closeProviderTimelineRigs() +}) + +describe('Pi final tail before host eviction', () => { + it('admits text and settlements before the exit barrier while ordinary and lifecycle queues are full', async () => { + const { journal } = await openProviderTimelineRig({ agent: 'pi' }) + const logging = testEventSinkLogging(SESSION) + const deferred = createDeferredStructuredAgentSessionEventSink({ + ...logging, + watermarks: { maxQueuedOperations: 1, maxLifecycleQueuedOperations: 1 } + }) + deferred.bind({ journal, fence: 1, publish: () => {} }) + const sink = providerTimelineSink(deferred.sink) + if (!sink) { + throw new Error('the sink must provide transitions') + } + const onFailed = vi.fn() + const lane = new JsonlRpcTimelineLane({ + sink, + sessionId: SESSION, + agent: 'pi', + generation: GENERATION, + namespace: NAMESPACE, + pauseReading: vi.fn(), + resumeReading: vi.fn(), + onInputAccepted: vi.fn(), + onFailed + }) + cleanups.push( + () => lane.dispose(), + () => deferred.close() + ) + for (const event of [ + { type: 'turn.open', turn: 'run:1', at: 1 }, + { type: 'request.open', request: 'confirm:1', body: pendingApproval }, + { + type: 'item.open', + item: 'tool:1', + body: { kind: 'tool-call', name: 'bash', input: {}, state: 'running' } + } + ] as const) { + lane.apply([event]) + await deferred.drained() + lane.retry() + } + const gate = Promise.withResolvers() + cleanups.push(() => gate.resolve()) + const append = (recordId: string, lifecycle = false) => + deferred.sink.tryAppendItem?.( + { provider: 'legacy', agent: 'pi', sessionId: SESSION, recordId }, + { kind: 'status', tone: 'info', text: recordId }, + { lifecycle, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + // The nested write joins behind this held read; later writes stay in the real journal queue. + const blocked = journal.readInOrder(() => { + expect(append('ordinary-full')).toEqual({ accepted: true }) + return gate.promise + }) + expect(append('lifecycle-full', true)).toEqual({ accepted: true }) + expect(append('ordinary-refused')).toEqual({ accepted: false, reason: 'backpressure' }) + expect(append('lifecycle-refused', true)).toEqual({ accepted: false, reason: 'backpressure' }) + const before = journal.snapshot().items.length + vi.useFakeTimers() + lane.apply([ + { type: 'text.delta', item: { id: 'reply' }, channel: 'assistant', text: 'Final reply' }, + { + type: 'item.update', + item: 'tail', + body: { kind: 'status', tone: 'info', text: 'Final status' } + } + ]) + lane.finalize() + lane.apply([{ type: 'session.ended', verdict: { state: 'interrupted', completedAt: 2 } }]) + let landed = false + const barrier = deferred.drained().then((result) => { + landed = true + return result + }) + await vi.advanceTimersByTimeAsync(2_501) + expect(journal.snapshot().items).toHaveLength(before) + expect(landed).toBe(false) + expect(onFailed).not.toHaveBeenCalled() + gate.resolve() + await blocked + await expect(barrier).resolves.toEqual({ ok: true }) + const acknowledgeRelease = vi.fn(() => lane.dispose()) + await evictStructuredAgentSession({ + sessionId: SESSION, + eventSink: deferred, + logger: logging.logger, + acknowledgeRelease, + discardSink: vi.fn() + }) + expect(acknowledgeRelease).toHaveBeenCalledOnce() + expect(journal.snapshot().items.map((item) => item.body)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: 'Final reply' }] + }), + expect.objectContaining({ kind: 'status', text: 'Final status' }), + expect.objectContaining({ + kind: 'approval', + resolution: expect.objectContaining({ state: 'cancelled' }) + }), + expect.objectContaining({ kind: 'tool-call', state: 'failed' }) + ]) + ) + }) + + it('fails excessive final output explicitly instead of leaving a refused tail waiting for eviction', async () => { + const failures: unknown[] = [] + const deferred = createDeferredStructuredAgentSessionEventSink({ + ...testEventSinkLogging(SESSION), + onFailed: (error) => failures.push(error) + }) + cleanups.push(() => deferred.close()) + expect( + deferred.sink.tryAppendTransition?.({ + finalTail: true, + lifecycle: true, + publish: false, + steps: [ + { + kind: 'settlement', + settlementId: 'oversize', + reservedBytes: 16 * 1024 * 1024 + 1, + resolve: () => [] + } + ] + }) + ).toEqual({ accepted: false, reason: 'failed' }) + expect(failures).toEqual([ + expect.objectContaining({ + message: 'structured agent-session final tail exceeded its reserved capacity' + }) + ]) + await expect(deferred.drained()).resolves.toMatchObject({ ok: false }) + }) +}) diff --git a/src/main/jsonl-rpc/timeline-lane.test.ts b/src/main/jsonl-rpc/timeline-lane.test.ts new file mode 100644 index 00000000000..8ef584d1ac3 --- /dev/null +++ b/src/main/jsonl-rpc/timeline-lane.test.ts @@ -0,0 +1,146 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + closeProviderTimelineRigs, + openProviderTimelineRig, + refusingSink, + SESSION, + GENERATION, + NAMESPACE +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { JsonlRpcTimelineLane } from './timeline-lane' + +const lanes: JsonlRpcTimelineLane[] = [] +afterEach(async () => { + for (const lane of lanes.splice(0)) { + lane.dispose() + } + vi.useRealTimers() + await closeProviderTimelineRigs() +}) + +async function rig() { + const journal = await openProviderTimelineRig({ agent: 'pi' }) + let blocked = true + const pauseReading = vi.fn(), + resumeReading = vi.fn(), + onInputAccepted = vi.fn(), + onFailed = vi.fn() + const lane = new JsonlRpcTimelineLane({ + sink: refusingSink(journal.sink, () => blocked), + sessionId: SESSION, + agent: 'pi', + generation: GENERATION, + namespace: NAMESPACE, + pauseReading, + resumeReading, + onInputAccepted, + onFailed + }) + lanes.push(lane) + return { + journal, + lane, + pauseReading, + resumeReading, + onInputAccepted, + onFailed, + unblock: () => { + blocked = false + } + } +} + +describe('JSON-lines timeline admission', () => { + it('admits the final tail with the host settlement budget when ordinary writes are full', async () => { + const journal = await openProviderTimelineRig({ agent: 'pi' }) + const lane = new JsonlRpcTimelineLane({ + sink: { + ...journal.sink, + tryAppendTransition: (transition) => + transition.lifecycle + ? journal.sink.tryAppendTransition(transition) + : { accepted: false, reason: 'backpressure' } + }, + sessionId: SESSION, + agent: 'pi', + generation: GENERATION, + namespace: NAMESPACE, + pauseReading: vi.fn(), + resumeReading: vi.fn(), + onInputAccepted: vi.fn(), + onFailed: vi.fn() + }) + lanes.push(lane) + lane.apply([ + { + type: 'item.update', + item: 'final', + body: { kind: 'status', tone: 'info', text: 'Final tail' } + } + ]) + expect(await journal.rows()).toEqual([]) + lane.finalize() + await lane.drained() + expect(await journal.rows()).toEqual([ + expect.objectContaining({ body: { kind: 'status', tone: 'info', text: 'Final tail' } }) + ]) + }) + + it('holds an entire parsed batch and accepts input only after journal admission', async () => { + const test = await rig() + test.lane.apply([ + { type: 'turn.open', turn: 'run:1', at: 1 }, + { type: 'input.accepted', clientMessageId: 'message:1', requestedAt: 1 }, + { type: 'turn.end', at: 2, state: 'completed', outcome: 'success' } + ]) + expect(test.pauseReading).toHaveBeenCalled() + expect(test.onInputAccepted).not.toHaveBeenCalled() + expect(await test.journal.turns()).toEqual([]) + test.unblock() + test.lane.retry() + await test.lane.drained() + expect(test.onInputAccepted).toHaveBeenCalledExactlyOnceWith('message:1') + expect(test.resumeReading).toHaveBeenCalled() + expect(await test.journal.turns()).toHaveLength(1) + expect((await test.journal.turns())[0]).toMatchObject({ + state: 'completed', + outcome: 'success' + }) + }) + + it('retries paused input and clears its timer after draining', async () => { + const test = await rig() + vi.useFakeTimers() + test.lane.apply([{ type: 'turn.open', turn: 'run:1', at: 1 }]) + test.unblock() + await vi.advanceTimersByTimeAsync(250) + await test.lane.drained() + expect(test.lane.openTurnId).not.toBeNull() + expect(vi.getTimerCount()).toBe(0) + }) + + it('bounds data already parsed in a batch after the reader pauses', async () => { + const test = await rig() + test.lane.apply( + Array.from({ length: 257 }, (_, index) => ({ + type: 'item.close', + item: `item:${index}`, + body: { kind: 'status', tone: 'info', text: 'held' } + })) + ) + expect(test.onFailed).toHaveBeenCalledExactlyOnceWith('Agent timeline queue capacity exceeded') + await expect(test.lane.drained()).resolves.toBeUndefined() + expect(test.onInputAccepted).not.toHaveBeenCalled() + }) + + it('releases drain and turn waiters when a failed lane is disposed', async () => { + const test = await rig() + test.unblock() + test.lane.apply([{ type: 'turn.open', turn: 'run:1', at: 1 }]) + const turn = test.lane.openTurnId + expect(turn).not.toBeNull() + const waiting = test.lane.whenTurnLeaves(turn!) + test.lane.dispose() + await expect(waiting).resolves.toBeUndefined() + }) +}) diff --git a/src/main/jsonl-rpc/timeline-lane.ts b/src/main/jsonl-rpc/timeline-lane.ts new file mode 100644 index 00000000000..36c759e42de --- /dev/null +++ b/src/main/jsonl-rpc/timeline-lane.ts @@ -0,0 +1,190 @@ +import { parseAgentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { + createProviderTimelineAssembler, + type ProviderTimelineAssembler +} from '../native-chat/agent-session-timeline/provider-timeline-assembler' +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import { createLegacyProviderTimelineIdentityScheme } from '../native-chat/agent-session-timeline/provider-timeline-identity' +import type { ProviderTimelineSink } from '../native-chat/agent-session-timeline/provider-timeline-plan' +import type { StructuredAgentSessionCommandRun } from '../native-chat/agent-session-wire/structured-agent-session-adapter' + +const MAX_HELD_EVENTS = 256 +const MAX_HELD_BYTES = 4 * 1024 * 1024 + +export type JsonlRpcTimelineLaneDeps = { + sink: ProviderTimelineSink + sessionId: string + agent: string + generation: string + namespace: string + pauseReading: () => void + resumeReading: () => void + onInputAccepted: (clientMessageId: string) => void + onFailed: (reason: string) => void +} + +/** A dialect supplies semantic events; the shared assembler owns every journal write. */ +export class JsonlRpcTimelineLane { + private readonly assembler: ProviderTimelineAssembler + private readonly identity: ReturnType + private readonly held: { event: ProviderTimelineEvent; bytes: number }[] = [] + private heldBytes = 0 + private draining = false + private ended = false + private finalizing = false + private retryTimer?: ReturnType + private readonly watchers = new Set<() => void>() + + constructor(private readonly deps: JsonlRpcTimelineLaneDeps) { + this.assembler = createProviderTimelineAssembler({ + ...deps, + sink: { + ...deps.sink, + tryAppendTransition: (transition) => + deps.sink.tryAppendTransition( + this.finalizing ? { ...transition, lifecycle: true, finalTail: true } : transition + ) + } + }) + this.identity = createLegacyProviderTimelineIdentityScheme(deps) + } + + get openTurnId(): string | null { + return this.assembler.openTurnId + } + + beginCommand(command: StructuredAgentSessionCommandRun): void { + this.retry() + if (this.ended || this.held.length > 0) { + throw new Error('Provider timeline has not drained for this command') + } + this.assembler.beginCommand(command) + } + + forgetCommand(turnId: string): void { + this.assembler.forgetCommand(turnId) + } + + apply(events: readonly ProviderTimelineEvent[]): void { + if (this.ended) { + return + } + for (const event of events) { + const bytes = Buffer.byteLength(JSON.stringify(event)) + if (this.held.length >= MAX_HELD_EVENTS || this.heldBytes + bytes > MAX_HELD_BYTES) { + this.fail('Agent timeline queue capacity exceeded') + return + } + this.held.push({ event, bytes }) + this.heldBytes += bytes + this.retry() + } + } + + retry(): void { + if (this.draining || this.ended) { + return + } + clearTimeout(this.retryTimer) + this.retryTimer = undefined + this.draining = true + try { + while (this.held.length > 0 && !this.ended) { + const entry = this.held[0] + const result = this.assembler.apply(entry.event) + if (!result.admission.accepted) { + if (result.admission.reason !== 'backpressure') { + this.fail(result.admission.reason) + } else { + this.deps.pauseReading() + this.retryTimer = setTimeout(() => this.retry(), 250) + this.retryTimer.unref() + } + return + } + this.held.shift() + this.heldBytes -= entry.bytes + if (entry.event.type === 'input.accepted' && !result.dropped) { + this.deps.onInputAccepted(entry.event.clientMessageId) + } + } + } finally { + this.draining = false + this.notify() + } + this.deps.resumeReading() + } + + whenTurnLeaves(turnId: string): Promise { + return new Promise((resolve) => { + const check = (): void => { + if (this.ended || this.openTurnId !== turnId) { + this.watchers.delete(check) + resolve() + } + } + this.watchers.add(check) + check() + }) + } + + drained(): Promise { + return new Promise((resolve) => { + const check = (): void => { + if (this.ended || this.held.length === 0) { + this.watchers.delete(check) + resolve() + } + } + this.watchers.add(check) + check() + }) + } + + isRequestRow(itemId: string, key: string): boolean { + const row = parseAgentJournalItemKey(itemId) + const expected = this.identity.request({ + generation: this.deps.generation, + key, + incarnation: 1 + }) + return ( + row?.provider === 'legacy' && + expected.provider === 'legacy' && + row.agent === this.deps.agent && + row.sessionId === this.deps.sessionId && + (row.recordId === expected.recordId || row.recordId.startsWith(`${expected.recordId}#`)) + ) + } + + flush(): void { + this.assembler.flush() + } + + finalize(): void { + // Admission must precede the host's exit barrier even when lifecycle writes are full. + this.finalizing = true + this.retry() + this.flush() + } + + dispose(): void { + this.ended = true + clearTimeout(this.retryTimer) + this.held.length = 0 + this.heldBytes = 0 + this.assembler.dispose() + this.notify() + } + + private fail(reason: string): void { + this.dispose() + this.deps.onFailed(reason) + } + + private notify(): void { + for (const check of this.watchers) { + check() + } + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts b/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts index d8c5c1737b7..6e226a4c6cf 100644 --- a/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts +++ b/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts @@ -3,7 +3,8 @@ import type { AgentJournalItemBody, AgentJournalPromptOption, AgentJournalQuestion, - AgentJournalQuestionItem + AgentJournalQuestionItem, + AgentJournalFreeTextInput } from '../../../shared/agent-session-journal-types' import { isPlanApprovalSubject } from '../../../shared/agent-session-approval-subject' import { @@ -97,7 +98,8 @@ export function boundJournalPromptBody( : {}), ...(body.freeTextQuestionId ? { freeTextQuestionId: boundPromptIdentifier(body.freeTextQuestionId) } - : {}) + : {}), + ...(body.freeTextInput ? { freeTextInput: boundFreeTextInput(body.freeTextInput) } : {}) } } @@ -110,7 +112,19 @@ function boundPromptQuestion(question: AgentJournalQuestion): AgentJournalQuesti options: boundPromptOptions(question.options), ...(question.freeTextQuestionId ? { freeTextQuestionId: boundPromptIdentifier(question.freeTextQuestionId) } - : {}) + : {}), + ...(question.freeTextInput ? { freeTextInput: boundFreeTextInput(question.freeTextInput) } : {}) + } +} + +function boundFreeTextInput(input: AgentJournalFreeTextInput): AgentJournalFreeTextInput { + return { + ...(input.allowEmpty === undefined ? {} : { allowEmpty: input.allowEmpty }), + ...(input.multiline === undefined ? {} : { multiline: input.multiline }), + ...(input.initialValue === undefined + ? {} + : { initialValue: boundPromptText(input.initialValue) }), + ...(input.placeholder === undefined ? {} : { placeholder: boundPromptText(input.placeholder) }) } } diff --git a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts index fdd10ab6104..cee9087f240 100644 --- a/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts +++ b/src/main/native-chat/agent-session-timeline/provider-timeline-assembler.ts @@ -14,6 +14,9 @@ // recovered: a new child is a new assembler, in a new acquisition generation. import type { AgentType } from '../../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import type { StructuredAgentSessionCommandRun } from '../agent-session-wire/structured-agent-session-adapter' import type { AgentSessionDeltaCoalescerDeps } from '../agent-session-wire/agent-session-delta-coalescer' import type { StructuredAgentSessionSinkAdmission } from '../agent-session-wire/structured-agent-session-event-sink' import { @@ -47,6 +50,9 @@ export type { ProviderTimelineDropRule } from './provider-timeline-decision' export type { ProviderTimelineApplyResult } from './provider-timeline-text-events' export type ProviderTimelineAssembler = { + /** The host already wrote this command's running turn; the provider ends that same row. */ + beginCommand(command: StructuredAgentSessionCommandRun): void + forgetCommand(turnId: string): void apply(event: ProviderTimelineEvent): ProviderTimelineApplyResult /** The turn id of the open turn, as its row and a client's Stop name it. */ readonly openTurnId: string | null @@ -191,6 +197,27 @@ export function createProviderTimelineAssembler( } return { + forgetCommand: (turnId) => { + if (state.open?.turnId === turnId) { + state.endTurn(state.open) + } + }, + beginCommand: (command) => { + if (state.ended || state.open || state.stopped) { + throw new Error('Provider timeline cannot adopt a command while work is open') + } + const running = readAgentJournalTurn(command.running) + if (!running || running.state !== 'running' || running.turnId !== command.turnId) { + throw new Error('Provider timeline command is not a running host turn') + } + state.open = { + identity: command.identity, + itemId: agentJournalItemKey(command.identity), + turnId: command.turnId, + key: { source: 'provider', value: command.turnId }, + running + } + }, apply, get openTurnId() { return state.open?.turnId ?? null diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts index 9a0637c99bc..3d5d9847400 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts @@ -9,6 +9,7 @@ import type { StructuredAgentSessionSinkWatermarks } from './structured-agent-session-event-sink' import type { StructuredAgentSessionTransitionJournal } from './structured-agent-session-transition' +import { StructuredAgentSessionFinalTailReservation } from './structured-agent-session-final-tail-reservation' export type StructuredAgentSessionSinkOperation = { sequence: number @@ -16,6 +17,7 @@ export type StructuredAgentSessionSinkOperation = { /** Lifecycle rows use their own bounded reservation budget. */ lifecycleBytes?: number lifecycle?: boolean + finalTail?: true /** Marks a publication, which writes no row: it runs at handover, or at its place in the * journal's queue while writes wait there, and one still waiting with the same key is replaced * by the next. Journal writes never coalesce: @@ -26,7 +28,10 @@ export type StructuredAgentSessionSinkOperation = { run: (target: StructuredAgentSessionEventTarget) => Promise | void } -type Admitted = StructuredAgentSessionSinkOperation & { superseded?: boolean } +type Admitted = StructuredAgentSessionSinkOperation & { + superseded?: boolean + releaseFinalTail?: () => void +} export type StructuredAgentSessionDrainWaiter = { through: number @@ -59,6 +64,7 @@ export class StructuredAgentSessionSinkQueue { private readonly buffered: Admitted[] = [] private readonly waitingPublications = new Map() private readonly waiters: StructuredAgentSessionDrainWaiter[] = [] + private readonly finalTail = new StructuredAgentSessionFinalTailReservation() constructor( private readonly deps: { @@ -121,6 +127,7 @@ export class StructuredAgentSessionSinkQueue { close(): void { this.closed = true this.dropBuffered() + this.finalTail.close() this.updateBackpressure() } @@ -160,7 +167,8 @@ export class StructuredAgentSessionSinkQueue { : key === undefined ? undefined : this.waitingPublications.get(key) - const lifecycle = operation.lifecycle ?? options.lifecycle === true + const finalTail = operation.finalTail === true + const lifecycle = !finalTail && (operation.lifecycle ?? options.lifecycle === true) const lifecycleBytes = lifecycle ? (operation.lifecycleBytes ?? operation.bytes) : 0 const nextBytes = this.queuedBytes - (replaced?.bytes ?? 0) + operation.bytes const nextOperations = this.queuedOperations + (replaced ? 0 : 1) @@ -169,6 +177,7 @@ export class StructuredAgentSessionSinkQueue { const nextLifecycleOperations = this.lifecycleQueuedOperations - (replaced?.lifecycle ? 1 : 0) + (lifecycle ? 1 : 0) const exceedsOrdinary = + !finalTail && !lifecycle && (nextBytes > this.deps.watermarks.maxQueuedBytes || nextOperations > this.deps.watermarks.maxQueuedOperations) @@ -180,11 +189,18 @@ export class StructuredAgentSessionSinkQueue { this.setBackpressure(true) return { accepted: false, reason: 'backpressure' } } + const releaseFinalTail = + finalTail && key === undefined ? this.finalTail.reserve(operation.bytes) : undefined + if (finalTail && !releaseFinalTail) { + this.fail(new Error('structured agent-session final tail exceeded its reserved capacity')) + return { accepted: false, reason: 'failed' } + } const accepted: Admitted = { ...operation, sequence: ++this.acceptedSequence, lifecycle, - lifecycleBytes + lifecycleBytes, + releaseFinalTail: releaseFinalTail ?? undefined } if (replaceAt >= 0) { this.buffered.splice(replaceAt, 1) @@ -243,6 +259,7 @@ export class StructuredAgentSessionSinkQueue { } private release(operation: Admitted): void { + operation.releaseFinalTail?.() this.queuedBytes = Math.max(0, this.queuedBytes - operation.bytes) this.queuedOperations = Math.max(0, this.queuedOperations - 1) if (operation.lifecycle) { @@ -313,6 +330,7 @@ export class StructuredAgentSessionSinkQueue { this.deps.onFailed?.(error) } this.dropBuffered() + this.finalTail.close() this.updateBackpressure() } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-final-tail-reservation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-final-tail-reservation.test.ts new file mode 100644 index 00000000000..1c33de1ce12 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-final-tail-reservation.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { StructuredAgentSessionFinalTailReservation } from './structured-agent-session-final-tail-reservation' + +describe('finalized provider tail reservations', () => { + it('bounds outstanding bytes and releases each admission once', () => { + const reservation = new StructuredAgentSessionFinalTailReservation() + const release = reservation.reserve(16 * 1024 * 1024) + expect(release).not.toBeNull() + expect(reservation.reserve(1)).toBeNull() + release?.() + release?.() + expect(reservation.reserve(16 * 1024 * 1024)).not.toBeNull() + expect(reservation.reserve(1)).toBeNull() + }) + + it('bounds operations even when they write no bytes', () => { + const reservation = new StructuredAgentSessionFinalTailReservation() + const releases = Array.from({ length: 512 }, () => reservation.reserve(0)) + expect(releases.every(Boolean)).toBe(true) + expect(reservation.reserve(0)).toBeNull() + releases[0]?.() + expect(reservation.reserve(0)).not.toBeNull() + expect(reservation.reserve(0)).toBeNull() + }) + + it('abandons outstanding reservations on close and refuses later admission', () => { + const reservation = new StructuredAgentSessionFinalTailReservation() + const release = reservation.reserve(100) + reservation.close() + release?.() + reservation.close() + expect(reservation.reserve(0)).toBeNull() + }) + + it('refuses invalid sizes', () => { + const reservation = new StructuredAgentSessionFinalTailReservation() + for (const bytes of [-1, 0.5, Number.NaN, Number.POSITIVE_INFINITY]) { + expect(reservation.reserve(bytes)).toBeNull() + } + expect(reservation.reserve(0)).not.toBeNull() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-final-tail-reservation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-final-tail-reservation.ts new file mode 100644 index 00000000000..92b8e0f897f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-final-tail-reservation.ts @@ -0,0 +1,38 @@ +const MAX_FINAL_TAIL_BYTES = 16 * 1024 * 1024 +const MAX_FINAL_TAIL_OPERATIONS = 512 + +/** Finalized providers can admit their bounded tail before the host takes its exit barrier. */ +export class StructuredAgentSessionFinalTailReservation { + private bytes = 0 + private operations = 0 + private closed = false + + reserve(bytes: number): (() => void) | null { + if ( + this.closed || + !Number.isSafeInteger(bytes) || + bytes < 0 || + this.bytes + bytes > MAX_FINAL_TAIL_BYTES || + this.operations >= MAX_FINAL_TAIL_OPERATIONS + ) { + return null + } + this.bytes += bytes + this.operations += 1 + let released = false + return () => { + if (released || this.closed) { + return + } + released = true + this.bytes -= bytes + this.operations -= 1 + } + } + + close(): void { + this.closed = true + this.bytes = 0 + this.operations = 0 + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts index f13b897f12b..4c73941ee5d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.ts @@ -57,6 +57,8 @@ export type StructuredAgentSessionTransition = { steps: readonly StructuredAgentSessionTransitionStep[] /** Rides the sink's lifecycle budget: it ends or settles something. */ lifecycle: boolean + /** Internal admission only, for a provider's bounded tail after finalization. */ + finalTail?: true /** Announce the writes once they land, when any step wrote. */ publish: boolean } @@ -88,6 +90,7 @@ function transitionAppend( transition.steps.reduce((total, step) => total + step.reservedBytes, 0) + (transition.publish ? 1 : 0), lifecycle: transition.lifecycle, + finalTail: transition.finalTail, run: async (bound) => { const { journal, fence } = bound const wrote = await journal.appendSteps( diff --git a/src/main/pi/__fixtures__/command-only.jsonl b/src/main/pi/__fixtures__/command-only.jsonl new file mode 100644 index 00000000000..e3cefacfc16 --- /dev/null +++ b/src/main/pi/__fixtures__/command-only.jsonl @@ -0,0 +1,20 @@ +{"dir":"in","raw":"{\"id\": \"q1\", \"type\": \"get_state\"}"} +{"dir":"out","raw":"{\"id\":\"q1\",\"type\":\"response\",\"command\":\"get_state\",\"success\":true,\"data\":{\"model\":{\"id\":\"gpt-6.1-sol\",\"name\":\"GPT-6.1 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":10,\"cacheRead\":0.1,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":15,\"cacheRead\":0.2,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":null,\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},\"thinkingLevel\":\"medium\",\"isStreaming\":false,\"isCompacting\":false,\"steeringMode\":\"one-at-a-time\",\"followUpMode\":\"one-at-a-time\",\"sessionFile\":\"/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/command-only/sessions/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.jsonl\",\"sessionId\":\"00000000-0000-4000-8000-000000000009\",\"autoCompactionEnabled\":true,\"messageCount\":0,\"pendingMessageCount\":0}}"} +{"dir":"in","raw":"{\"id\": \"q2\", \"type\": \"set_auto_retry\", \"enabled\": false}"} +{"dir":"out","raw":"{\"id\":\"q2\",\"type\":\"response\",\"command\":\"set_auto_retry\",\"success\":true}"} +{"dir":"in","raw":"{\"id\": \"q3\", \"type\": \"set_auto_compaction\", \"enabled\": false}"} +{"dir":"out","raw":"{\"id\":\"q3\",\"type\":\"response\",\"command\":\"set_auto_compaction\",\"success\":true}"} +{"dir":"in","raw":"{\"id\": \"q4\", \"type\": \"get_available_models\"}"} +{"dir":"out","raw":"{\"id\":\"q4\",\"type\":\"response\",\"command\":\"get_available_models\",\"success\":true,\"data\":{\"models\":[{\"id\":\"gpt-5.3-codex-spark\",\"name\":\"GPT-5.3 Codex Spark\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\"],\"cost\":{\"input\":1.75,\"output\":14,\"cacheRead\":0.175,\"cacheWrite\":0},\"contextWindow\":128000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true},\"type\":\"chat\"},{\"id\":\"gpt-5.5\",\"name\":\"GPT-5.5\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":5,\"output\":30,\"cacheRead\":0.5,\"cacheWrite\":0,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":10,\"output\":45,\"cacheRead\":1,\"cacheWrite\":0}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-5.6-luna\",\"name\":\"GPT-5.6 Luna\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":0.2,\"output\":1.2,\"cacheRead\":0.02,\"cacheWrite\":0.25,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":0.4,\"output\":1.8,\"cacheRead\":0.04,\"cacheWrite\":0.5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"max\":\"max\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-5.6-sol\",\"name\":\"GPT-5.6 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":4,\"output\":20,\"cacheRead\":0.4,\"cacheWrite\":5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":8,\"output\":30,\"cacheRead\":0.8,\"cacheWrite\":10}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"max\":\"max\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-5.6-terra\",\"name\":\"GPT-5.6 Terra\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":12,\"cacheRead\":0.2,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":18,\"cacheRead\":0.4,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"max\":\"max\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6-astra\",\"name\":\"GPT-6 Astra\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":10,\"output\":50,\"cacheRead\":1,\"cacheWrite\":12.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":20,\"output\":75,\"cacheRead\":2,\"cacheWrite\":25}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":null,\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6-luna\",\"name\":\"GPT-6 Luna\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":0.1,\"output\":0.5,\"cacheRead\":0.01,\"cacheWrite\":0.125,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":0.2,\"output\":0.75,\"cacheRead\":0.02,\"cacheWrite\":0.25}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":\"none\",\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6-sol\",\"name\":\"GPT-6 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":10,\"cacheRead\":0.2,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":15,\"cacheRead\":0.4,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":\"none\",\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6.1-sol\",\"name\":\"GPT-6.1 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":10,\"cacheRead\":0.1,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":15,\"cacheRead\":0.2,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":null,\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"}]}}"} +{"dir":"in","raw":"{\"id\": \"q5\", \"type\": \"set_model\", \"provider\": \"openai-codex\", \"modelId\": \"gpt-5.5\"}"} +{"dir":"out","raw":"{\"id\":\"q5\",\"type\":\"response\",\"command\":\"set_model\",\"success\":true,\"data\":{\"id\":\"gpt-5.5\",\"name\":\"GPT-5.5\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":5,\"output\":30,\"cacheRead\":0.5,\"cacheWrite\":0,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":10,\"output\":45,\"cacheRead\":1,\"cacheWrite\":0}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"}}"} +{"dir":"in","raw":"{\"id\": \"q6\", \"type\": \"set_thinking_level\", \"level\": \"off\"}"} +{"dir":"out","raw":"{\"type\":\"thinking_level_changed\",\"level\":\"off\"}"} +{"dir":"out","raw":"{\"id\":\"q6\",\"type\":\"response\",\"command\":\"set_thinking_level\",\"success\":true}"} +{"dir":"in","raw":"{\"id\": \"q7\", \"type\": \"prompt\", \"message\": \"/qa-local\"}"} +{"dir":"out","raw":"{\"type\":\"extension_ui_request\",\"id\":\"00000000-0000-4000-8000-000000000010\",\"method\":\"setStatus\",\"statusKey\":\"qa-capture\",\"statusText\":\"local command\"}"} +{"dir":"out","raw":"{\"type\":\"extension_ui_request\",\"id\":\"00000000-0000-4000-8000-000000000011\",\"method\":\"setTitle\",\"title\":\"RPC capture\"}"} +{"dir":"out","raw":"{\"type\":\"extension_ui_request\",\"id\":\"00000000-0000-4000-8000-000000000012\",\"method\":\"notify\",\"message\":\"command completed without a model call\",\"notifyType\":\"info\"}"} +{"dir":"out","raw":"{\"id\":\"q7\",\"type\":\"response\",\"command\":\"prompt\",\"success\":true,\"data\":{\"disposition\":\"handled\"}}"} +{"dir":"in","raw":"{\"id\": \"q8\", \"type\": \"get_state\"}"} +{"dir":"out","raw":"{\"id\":\"q8\",\"type\":\"response\",\"command\":\"get_state\",\"success\":true,\"data\":{\"model\":{\"id\":\"gpt-5.5\",\"name\":\"GPT-5.5\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":5,\"output\":30,\"cacheRead\":0.5,\"cacheWrite\":0,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":10,\"output\":45,\"cacheRead\":1,\"cacheWrite\":0}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},\"thinkingLevel\":\"off\",\"isStreaming\":false,\"isCompacting\":false,\"steeringMode\":\"one-at-a-time\",\"followUpMode\":\"one-at-a-time\",\"sessionFile\":\"/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/command-only/sessions/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.jsonl\",\"sessionId\":\"00000000-0000-4000-8000-000000000009\",\"autoCompactionEnabled\":false,\"messageCount\":0,\"pendingMessageCount\":0}}"} diff --git a/src/main/pi/__fixtures__/compaction.jsonl b/src/main/pi/__fixtures__/compaction.jsonl new file mode 100644 index 00000000000..c5cde2edb14 --- /dev/null +++ b/src/main/pi/__fixtures__/compaction.jsonl @@ -0,0 +1,99 @@ +{"dir":"in","raw":"{\"id\": \"q1\", \"type\": \"get_state\"}"} +{"dir":"out","raw":"{\"id\":\"q1\",\"type\":\"response\",\"command\":\"get_state\",\"success\":true,\"data\":{\"model\":{\"id\":\"gpt-6.1-sol\",\"name\":\"GPT-6.1 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":10,\"cacheRead\":0.1,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":15,\"cacheRead\":0.2,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":null,\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},\"thinkingLevel\":\"medium\",\"isStreaming\":false,\"isCompacting\":false,\"steeringMode\":\"one-at-a-time\",\"followUpMode\":\"one-at-a-time\",\"sessionFile\":\"/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/steer-then-compact/sessions/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.jsonl\",\"sessionId\":\"00000000-0000-4000-8000-000000000049\",\"autoCompactionEnabled\":false,\"messageCount\":0,\"pendingMessageCount\":0}}"} +{"dir":"in","raw":"{\"id\": \"q2\", \"type\": \"set_auto_retry\", \"enabled\": false}"} +{"dir":"out","raw":"{\"id\":\"q2\",\"type\":\"response\",\"command\":\"set_auto_retry\",\"success\":true}"} +{"dir":"in","raw":"{\"id\": \"q3\", \"type\": \"set_auto_compaction\", \"enabled\": false}"} +{"dir":"out","raw":"{\"id\":\"q3\",\"type\":\"response\",\"command\":\"set_auto_compaction\",\"success\":true}"} +{"dir":"in","raw":"{\"id\": \"q4\", \"type\": \"get_available_models\"}"} +{"dir":"out","raw":"{\"id\":\"q4\",\"type\":\"response\",\"command\":\"get_available_models\",\"success\":true,\"data\":{\"models\":[{\"id\":\"gpt-5.3-codex-spark\",\"name\":\"GPT-5.3 Codex Spark\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\"],\"cost\":{\"input\":1.75,\"output\":14,\"cacheRead\":0.175,\"cacheWrite\":0},\"contextWindow\":128000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true},\"type\":\"chat\"},{\"id\":\"gpt-5.5\",\"name\":\"GPT-5.5\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":5,\"output\":30,\"cacheRead\":0.5,\"cacheWrite\":0,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":10,\"output\":45,\"cacheRead\":1,\"cacheWrite\":0}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-5.6-luna\",\"name\":\"GPT-5.6 Luna\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":0.2,\"output\":1.2,\"cacheRead\":0.02,\"cacheWrite\":0.25,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":0.4,\"output\":1.8,\"cacheRead\":0.04,\"cacheWrite\":0.5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"max\":\"max\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-5.6-sol\",\"name\":\"GPT-5.6 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":4,\"output\":20,\"cacheRead\":0.4,\"cacheWrite\":5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":8,\"output\":30,\"cacheRead\":0.8,\"cacheWrite\":10}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"max\":\"max\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-5.6-terra\",\"name\":\"GPT-5.6 Terra\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":12,\"cacheRead\":0.2,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":18,\"cacheRead\":0.4,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"max\":\"max\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6-astra\",\"name\":\"GPT-6 Astra\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":10,\"output\":50,\"cacheRead\":1,\"cacheWrite\":12.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":20,\"output\":75,\"cacheRead\":2,\"cacheWrite\":25}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":null,\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6-luna\",\"name\":\"GPT-6 Luna\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":0.1,\"output\":0.5,\"cacheRead\":0.01,\"cacheWrite\":0.125,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":0.2,\"output\":0.75,\"cacheRead\":0.02,\"cacheWrite\":0.25}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":\"none\",\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6-sol\",\"name\":\"GPT-6 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":10,\"cacheRead\":0.2,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":15,\"cacheRead\":0.4,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":\"none\",\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6.1-sol\",\"name\":\"GPT-6.1 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":10,\"cacheRead\":0.1,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":15,\"cacheRead\":0.2,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":null,\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"}]}}"} +{"dir":"in","raw":"{\"id\": \"q5\", \"type\": \"set_model\", \"provider\": \"openai-codex\", \"modelId\": \"gpt-5.5\"}"} +{"dir":"out","raw":"{\"id\":\"q5\",\"type\":\"response\",\"command\":\"set_model\",\"success\":true,\"data\":{\"id\":\"gpt-5.5\",\"name\":\"GPT-5.5\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":5,\"output\":30,\"cacheRead\":0.5,\"cacheWrite\":0,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":10,\"output\":45,\"cacheRead\":1,\"cacheWrite\":0}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"}}"} +{"dir":"in","raw":"{\"id\": \"q6\", \"type\": \"set_thinking_level\", \"level\": \"off\"}"} +{"dir":"out","raw":"{\"type\":\"thinking_level_changed\",\"level\":\"off\"}"} +{"dir":"out","raw":"{\"id\":\"q6\",\"type\":\"response\",\"command\":\"set_thinking_level\",\"success\":true}"} +{"dir":"in","raw":"{\"id\": \"q7\", \"type\": \"prompt\", \"message\": \"Use bash to run exactly: printf capture-start; sleep 20; printf capture-finish. Do not background it. Then reply ORIGINAL_FINISHED.\"}"} +{"dir":"out","raw":"{\"id\":\"q7\",\"type\":\"response\",\"command\":\"prompt\",\"success\":true,\"data\":{\"disposition\":\"started\"}}"} +{"dir":"out","raw":"{\"type\":\"agent_start\"}"} +{"dir":"out","raw":"{\"type\":\"turn_start\"}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"system\",\"content\":\"\",\"sections\":{\"preamble\":\"You are a protocol capture assistant. Follow exact requests and use only the requested tools. Read and write only within this project. Never read credentials, home directories, or account details.\",\"cwd\":\"\\n/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/steer-then-compact\\n\"},\"timestamp\":1791281787030,\"toolsAdded\":[{\"name\":\"bash\",\"description\":\"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.\",\"parameters\":{\"type\":\"object\",\"required\":[\"command\"],\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout\":{\"type\":\"number\",\"description\":\"Timeout in seconds (optional, no default timeout)\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}}]}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"system\",\"content\":\"\",\"sections\":{\"preamble\":\"You are a protocol capture assistant. Follow exact requests and use only the requested tools. Read and write only within this project. Never read credentials, home directories, or account details.\",\"cwd\":\"\\n/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/steer-then-compact\\n\"},\"timestamp\":1791281787030,\"toolsAdded\":[{\"name\":\"bash\",\"description\":\"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.\",\"parameters\":{\"type\":\"object\",\"required\":[\"command\"],\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout\":{\"type\":\"number\",\"description\":\"Timeout in seconds (optional, no default timeout)\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}}]}}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use bash to run exactly: printf capture-start; sleep 20; printf capture-finish. Do not background it. Then reply ORIGINAL_FINISHED.\"}],\"timestamp\":1791281787029}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use bash to run exactly: printf capture-start; sleep 20; printf capture-finish. Do not background it. Then reply ORIGINAL_FINISHED.\"}],\"timestamp\":1791281787029}}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"assistant\",\"content\":[],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"pending\",\"timestamp\":1791281787039,\"responseId\":\"captured.id.000055.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_start\",\"contentIndex\":0,\"id\":\"captured.id.000056.xxxxxxxxxx|captured.id.000057.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"{\\\"\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"command\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"\\\":\\\"\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"printf\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\" capture\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"-start\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\";\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\" sleep\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\" \"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"20\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\";\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\" printf\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\" capture\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"-f\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"inish\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"\\\",\\\"\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"timeout\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"\\\":\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"25\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"}\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_end\",\"contentIndex\":0,\"toolCall\":{\"type\":\"toolCall\",\"id\":\"captured.id.000056.xxxxxxxxxx|captured.id.000057.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"name\":\"bash\",\"arguments\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":25}}}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"toolCall\",\"id\":\"captured.id.000056.xxxxxxxxxx|captured.id.000057.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"name\":\"bash\",\"arguments\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":25}}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":222,\"output\":33,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":255,\"cost\":{\"input\":0.00111,\"output\":0.00099,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.0021000000000000003}},\"stopReason\":\"toolUse\",\"timestamp\":1791281787039,\"responseId\":\"captured.id.000055.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"}}"} +{"dir":"out","raw":"{\"type\":\"tool_execution_start\",\"toolCallId\":\"captured.id.000056.xxxxxxxxxx|captured.id.000057.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"args\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":25}}"} +{"dir":"in","raw":"{\"id\": \"q8\", \"type\": \"prompt\", \"message\": \"After the running tool completes, reply STEER_CONSUMED instead of ORIGINAL_FINISHED.\", \"streamingBehavior\": \"steer\"}"} +{"dir":"in","raw":"{\"id\": \"q9\", \"type\": \"prompt\", \"message\": \"Then reply FOLLOWUP_CONSUMED on the next turn.\", \"streamingBehavior\": \"followUp\"}"} +{"dir":"out","raw":"{\"type\":\"tool_execution_update\",\"toolCallId\":\"captured.id.000056.xxxxxxxxxx|captured.id.000057.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"args\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":25},\"partialResult\":{\"content\":[]}}"} +{"dir":"out","raw":"{\"type\":\"queue_update\",\"steering\":[\"After the running tool completes, reply STEER_CONSUMED instead of ORIGINAL_FINISHED.\"],\"followUp\":[]}"} +{"dir":"out","raw":"{\"type\":\"queue_update\",\"steering\":[\"After the running tool completes, reply STEER_CONSUMED instead of ORIGINAL_FINISHED.\"],\"followUp\":[\"Then reply FOLLOWUP_CONSUMED on the next turn.\"]}"} +{"dir":"out","raw":"{\"id\":\"q8\",\"type\":\"response\",\"command\":\"prompt\",\"success\":true,\"data\":{\"disposition\":\"queued\"}}"} +{"dir":"out","raw":"{\"id\":\"q9\",\"type\":\"response\",\"command\":\"prompt\",\"success\":true,\"data\":{\"disposition\":\"queued\"}}"} +{"dir":"out","raw":"{\"type\":\"tool_execution_update\",\"toolCallId\":\"captured.id.000056.xxxxxxxxxx|captured.id.000057.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"args\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":25},\"partialResult\":{\"content\":[{\"type\":\"text\",\"text\":\"capture-start\"}],\"details\":{}}}"} +{"dir":"out","raw":"{\"type\":\"tool_execution_update\",\"toolCallId\":\"captured.id.000056.xxxxxxxxxx|captured.id.000057.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"args\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":25},\"partialResult\":{\"content\":[{\"type\":\"text\",\"text\":\"capture-startcapture-finish\"}],\"details\":{}}}"} +{"dir":"out","raw":"{\"type\":\"tool_execution_end\",\"toolCallId\":\"captured.id.000056.xxxxxxxxxx|captured.id.000057.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"capture-startcapture-finish\"}],\"structuredContent\":{\"output\":\"capture-startcapture-finish\",\"truncated\":false,\"exit_code\":0,\"wall_time_seconds\":20}},\"isError\":false}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"toolResult\",\"toolCallId\":\"captured.id.000056.xxxxxxxxxx|captured.id.000057.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"content\":[{\"type\":\"text\",\"text\":\"capture-startcapture-finish\"}],\"isError\":false,\"timestamp\":1791281821986}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"toolResult\",\"toolCallId\":\"captured.id.000056.xxxxxxxxxx|captured.id.000057.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"content\":[{\"type\":\"text\",\"text\":\"capture-startcapture-finish\"}],\"isError\":false,\"timestamp\":1791281821986}}"} +{"dir":"out","raw":"{\"type\":\"turn_end\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"toolCall\",\"id\":\"captured.id.000056.xxxxxxxxxx|captured.id.000057.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"name\":\"bash\",\"arguments\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":25}}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":222,\"output\":33,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":255,\"cost\":{\"input\":0.00111,\"output\":0.00099,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.0021000000000000003}},\"stopReason\":\"toolUse\",\"timestamp\":1791281787039,\"responseId\":\"captured.id.000055.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"},\"toolResults\":[{\"role\":\"toolResult\",\"toolCallId\":\"captured.id.000056.xxxxxxxxxx|captured.id.000057.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"content\":[{\"type\":\"text\",\"text\":\"capture-startcapture-finish\"}],\"isError\":false,\"timestamp\":1791281821986}]}"} +{"dir":"out","raw":"{\"type\":\"turn_start\"}"} +{"dir":"out","raw":"{\"type\":\"queue_update\",\"steering\":[],\"followUp\":[\"Then reply FOLLOWUP_CONSUMED on the next turn.\"]}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"After the running tool completes, reply STEER_CONSUMED instead of ORIGINAL_FINISHED.\"}],\"timestamp\":1791281801961}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"After the running tool completes, reply STEER_CONSUMED instead of ORIGINAL_FINISHED.\"}],\"timestamp\":1791281801961}}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"assistant\",\"content\":[],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"pending\",\"timestamp\":1791281821988}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_start\",\"contentIndex\":0}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"STE\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"ER\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"_CON\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"SUM\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"ED\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_end\",\"contentIndex\":0,\"content\":\"STEER_CONSUMED\"}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"STEER_CONSUMED\",\"textSignature\":\"{\\\"v\\\":1,\\\"id\\\":\\\"captured.id.000058.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxx\\\",\\\"phase\\\":\\\"final_answer\\\"}\"}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":293,\"output\":9,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":302,\"cost\":{\"input\":0.0014650000000000002,\"output\":0.00027,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.0017350000000000002}},\"stopReason\":\"stop\",\"timestamp\":1791281821988,\"responseId\":\"captured.id.000059.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"}}"} +{"dir":"out","raw":"{\"type\":\"turn_end\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"STEER_CONSUMED\",\"textSignature\":\"{\\\"v\\\":1,\\\"id\\\":\\\"captured.id.000058.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxx\\\",\\\"phase\\\":\\\"final_answer\\\"}\"}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":293,\"output\":9,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":302,\"cost\":{\"input\":0.0014650000000000002,\"output\":0.00027,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.0017350000000000002}},\"stopReason\":\"stop\",\"timestamp\":1791281821988,\"responseId\":\"captured.id.000059.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"},\"toolResults\":[]}"} +{"dir":"out","raw":"{\"type\":\"turn_start\"}"} +{"dir":"out","raw":"{\"type\":\"queue_update\",\"steering\":[],\"followUp\":[]}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Then reply FOLLOWUP_CONSUMED on the next turn.\"}],\"timestamp\":1791281801961}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Then reply FOLLOWUP_CONSUMED on the next turn.\"}],\"timestamp\":1791281801961}}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"assistant\",\"content\":[],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"pending\",\"timestamp\":1791281825232}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_start\",\"contentIndex\":0}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"FOLLOW\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"UP\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"_CON\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"SUM\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":320,\"output\":9,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":329,\"cost\":{\"input\":0.0016,\"output\":0.00027,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.0018700000000000001}},\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"ED\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":320,\"output\":9,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":329,\"cost\":{\"input\":0.0016,\"output\":0.00027,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.0018700000000000001}},\"assistantMessageEvent\":{\"type\":\"text_end\",\"contentIndex\":0,\"content\":\"FOLLOWUP_CONSUMED\"}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"FOLLOWUP_CONSUMED\",\"textSignature\":\"{\\\"v\\\":1,\\\"id\\\":\\\"captured.id.000060.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxx\\\",\\\"phase\\\":\\\"final_answer\\\"}\"}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":320,\"output\":9,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":329,\"cost\":{\"input\":0.0016,\"output\":0.00027,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.0018700000000000001}},\"stopReason\":\"stop\",\"timestamp\":1791281825232,\"responseId\":\"captured.id.000061.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"}}"} +{"dir":"out","raw":"{\"type\":\"turn_end\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"FOLLOWUP_CONSUMED\",\"textSignature\":\"{\\\"v\\\":1,\\\"id\\\":\\\"captured.id.000060.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxx\\\",\\\"phase\\\":\\\"final_answer\\\"}\"}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":320,\"output\":9,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":329,\"cost\":{\"input\":0.0016,\"output\":0.00027,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.0018700000000000001}},\"stopReason\":\"stop\",\"timestamp\":1791281825232,\"responseId\":\"captured.id.000061.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"},\"toolResults\":[]}"} +{"dir":"out","raw":"{\"type\":\"agent_end\",\"messages\":[{\"role\":\"system\",\"content\":\"\",\"sections\":{\"preamble\":\"You are a protocol capture assistant. Follow exact requests and use only the requested tools. Read and write only within this project. Never read credentials, home directories, or account details.\",\"cwd\":\"\\n/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/steer-then-compact\\n\"},\"timestamp\":1791281787030,\"toolsAdded\":[{\"name\":\"bash\",\"description\":\"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.\",\"parameters\":{\"type\":\"object\",\"required\":[\"command\"],\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout\":{\"type\":\"number\",\"description\":\"Timeout in seconds (optional, no default timeout)\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use bash to run exactly: printf capture-start; sleep 20; printf capture-finish. Do not background it. Then reply ORIGINAL_FINISHED.\"}],\"timestamp\":1791281787029},{\"role\":\"assistant\",\"content\":[{\"type\":\"toolCall\",\"id\":\"captured.id.000056.xxxxxxxxxx|captured.id.000057.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"name\":\"bash\",\"arguments\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":25}}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":222,\"output\":33,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":255,\"cost\":{\"input\":0.00111,\"output\":0.00099,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.0021000000000000003}},\"stopReason\":\"toolUse\",\"timestamp\":1791281787039,\"responseId\":\"captured.id.000055.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"},{\"role\":\"toolResult\",\"toolCallId\":\"captured.id.000056.xxxxxxxxxx|captured.id.000057.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"content\":[{\"type\":\"text\",\"text\":\"capture-startcapture-finish\"}],\"isError\":false,\"timestamp\":1791281821986},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"After the running tool completes, reply STEER_CONSUMED instead of ORIGINAL_FINISHED.\"}],\"timestamp\":1791281801961},{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"STEER_CONSUMED\",\"textSignature\":\"{\\\"v\\\":1,\\\"id\\\":\\\"captured.id.000058.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxx\\\",\\\"phase\\\":\\\"final_answer\\\"}\"}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":293,\"output\":9,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":302,\"cost\":{\"input\":0.0014650000000000002,\"output\":0.00027,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.0017350000000000002}},\"stopReason\":\"stop\",\"timestamp\":1791281821988,\"responseId\":\"captured.id.000059.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Then reply FOLLOWUP_CONSUMED on the next turn.\"}],\"timestamp\":1791281801961},{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"FOLLOWUP_CONSUMED\",\"textSignature\":\"{\\\"v\\\":1,\\\"id\\\":\\\"captured.id.000060.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxx\\\",\\\"phase\\\":\\\"final_answer\\\"}\"}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":320,\"output\":9,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":329,\"cost\":{\"input\":0.0016,\"output\":0.00027,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.0018700000000000001}},\"stopReason\":\"stop\",\"timestamp\":1791281825232,\"responseId\":\"captured.id.000061.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"}],\"willRetry\":false}"} +{"dir":"in","raw":"{\"id\": \"q10\", \"type\": \"get_state\"}"} +{"dir":"out","raw":"{\"type\":\"agent_settled\"}"} +{"dir":"out","raw":"{\"id\":\"q10\",\"type\":\"response\",\"command\":\"get_state\",\"success\":true,\"data\":{\"model\":{\"id\":\"gpt-5.5\",\"name\":\"GPT-5.5\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":5,\"output\":30,\"cacheRead\":0.5,\"cacheWrite\":0,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":10,\"output\":45,\"cacheRead\":1,\"cacheWrite\":0}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},\"thinkingLevel\":\"off\",\"isStreaming\":false,\"isCompacting\":false,\"steeringMode\":\"one-at-a-time\",\"followUpMode\":\"one-at-a-time\",\"sessionFile\":\"/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/steer-then-compact/sessions/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.jsonl\",\"sessionId\":\"00000000-0000-4000-8000-000000000049\",\"autoCompactionEnabled\":false,\"messageCount\":8,\"pendingMessageCount\":0}}"} +{"dir":"in","raw":"{\"id\": \"q11\", \"type\": \"get_messages\"}"} +{"dir":"out","raw":"{\"id\":\"q11\",\"type\":\"response\",\"command\":\"get_messages\",\"success\":true,\"data\":{\"messages\":[{\"role\":\"system\",\"content\":\"\",\"sections\":{\"preamble\":\"You are a protocol capture assistant. Follow exact requests and use only the requested tools. Read and write only within this project. Never read credentials, home directories, or account details.\",\"cwd\":\"\\n/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/steer-then-compact\\n\"},\"timestamp\":1791281787030,\"toolsAdded\":[{\"name\":\"bash\",\"description\":\"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.\",\"parameters\":{\"type\":\"object\",\"required\":[\"command\"],\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout\":{\"type\":\"number\",\"description\":\"Timeout in seconds (optional, no default timeout)\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use bash to run exactly: printf capture-start; sleep 20; printf capture-finish. Do not background it. Then reply ORIGINAL_FINISHED.\"}],\"timestamp\":1791281787029},{\"role\":\"assistant\",\"content\":[{\"type\":\"toolCall\",\"id\":\"captured.id.000056.xxxxxxxxxx|captured.id.000057.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"name\":\"bash\",\"arguments\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":25}}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":222,\"output\":33,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":255,\"cost\":{\"input\":0.00111,\"output\":0.00099,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.0021000000000000003}},\"stopReason\":\"toolUse\",\"timestamp\":1791281787039,\"responseId\":\"captured.id.000055.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"},{\"role\":\"toolResult\",\"toolCallId\":\"captured.id.000056.xxxxxxxxxx|captured.id.000057.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"content\":[{\"type\":\"text\",\"text\":\"capture-startcapture-finish\"}],\"isError\":false,\"timestamp\":1791281821986},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"After the running tool completes, reply STEER_CONSUMED instead of ORIGINAL_FINISHED.\"}],\"timestamp\":1791281801961},{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"STEER_CONSUMED\",\"textSignature\":\"{\\\"v\\\":1,\\\"id\\\":\\\"captured.id.000058.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxx\\\",\\\"phase\\\":\\\"final_answer\\\"}\"}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":293,\"output\":9,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":302,\"cost\":{\"input\":0.0014650000000000002,\"output\":0.00027,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.0017350000000000002}},\"stopReason\":\"stop\",\"timestamp\":1791281821988,\"responseId\":\"captured.id.000059.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Then reply FOLLOWUP_CONSUMED on the next turn.\"}],\"timestamp\":1791281801961},{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"FOLLOWUP_CONSUMED\",\"textSignature\":\"{\\\"v\\\":1,\\\"id\\\":\\\"captured.id.000060.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxx\\\",\\\"phase\\\":\\\"final_answer\\\"}\"}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":320,\"output\":9,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":329,\"cost\":{\"input\":0.0016,\"output\":0.00027,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.0018700000000000001}},\"stopReason\":\"stop\",\"timestamp\":1791281825232,\"responseId\":\"captured.id.000061.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"}]}}"} +{"dir":"in","raw":"{\"id\": \"q12\", \"type\": \"compact\"}"} +{"dir":"out","raw":"{\"type\":\"compaction_start\",\"reason\":\"manual\"}"} +{"dir":"out","raw":"{\"type\":\"compaction_end\",\"reason\":\"manual\",\"result\":{\"summary\":\"## Goal\\nSummarize the provided conversation for continuity.\\n\\n## Constraints & Preferences\\n- User required the assistant to run exactly: `printf capture-start; sleep 20; printf capture-finish`\\n- User specified not to background the command.\\n- After tool completion, user instructed to reply `STEER_CONSUMED` instead of `ORIGINAL_FINISHED`.\\n\\n## Progress\\n### Done\\n- [x] Ran `printf capture-start; sleep 20; printf capture-finish` via bash without backgrounding.\\n- [x] Tool output was `capture-startcapture-finish`.\\n- [x] Replied `STEER_CONSUMED`.\\n\\n### In Progress\\n- [ ] None.\\n\\n### Blocked\\n- None.\\n\\n## Key Decisions\\n- **Follow latest user instruction**: Replied `STEER_CONSUMED` after the tool completed, replacing the earlier requested `ORIGINAL_FINISHED`.\\n\\n## Next Steps\\n1. Continue from the completed state if further user instructions are provided.\\n\\n## Critical Context\\n- Exact command run: `printf capture-start; sleep 20; printf capture-finish`\\n- Tool output: `capture-startcapture-finish`\\n- Final assistant reply: `STEER_CONSUMED`\\n\\n---\\n\\n**Turn Context (split turn):**\\n\\n## Original Request\\nThe user instructed: “Then reply FOLLOWUP_CONSUMED on the next turn.”\\n\\n## Progress So Far\\n- No work has been completed beyond recording the instruction.\\n\\n## Context Needed to Continue\\n- On the next turn, the assistant should reply exactly: `FOLLOWUP_CONSUMED`\",\"firstKeptEntryId\":\"77988921\",\"tokensBefore\":329,\"estimatedTokensAfter\":559,\"usage\":{\"input\":574,\"output\":317,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":891,\"cost\":{\"input\":0.00287,\"output\":0.009510000000000001,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.01238}},\"details\":{\"readFiles\":[],\"modifiedFiles\":[]}},\"aborted\":false,\"willRetry\":false}"} +{"dir":"out","raw":"{\"id\":\"q12\",\"type\":\"response\",\"command\":\"compact\",\"success\":true,\"data\":{\"summary\":\"## Goal\\nSummarize the provided conversation for continuity.\\n\\n## Constraints & Preferences\\n- User required the assistant to run exactly: `printf capture-start; sleep 20; printf capture-finish`\\n- User specified not to background the command.\\n- After tool completion, user instructed to reply `STEER_CONSUMED` instead of `ORIGINAL_FINISHED`.\\n\\n## Progress\\n### Done\\n- [x] Ran `printf capture-start; sleep 20; printf capture-finish` via bash without backgrounding.\\n- [x] Tool output was `capture-startcapture-finish`.\\n- [x] Replied `STEER_CONSUMED`.\\n\\n### In Progress\\n- [ ] None.\\n\\n### Blocked\\n- None.\\n\\n## Key Decisions\\n- **Follow latest user instruction**: Replied `STEER_CONSUMED` after the tool completed, replacing the earlier requested `ORIGINAL_FINISHED`.\\n\\n## Next Steps\\n1. Continue from the completed state if further user instructions are provided.\\n\\n## Critical Context\\n- Exact command run: `printf capture-start; sleep 20; printf capture-finish`\\n- Tool output: `capture-startcapture-finish`\\n- Final assistant reply: `STEER_CONSUMED`\\n\\n---\\n\\n**Turn Context (split turn):**\\n\\n## Original Request\\nThe user instructed: “Then reply FOLLOWUP_CONSUMED on the next turn.”\\n\\n## Progress So Far\\n- No work has been completed beyond recording the instruction.\\n\\n## Context Needed to Continue\\n- On the next turn, the assistant should reply exactly: `FOLLOWUP_CONSUMED`\",\"firstKeptEntryId\":\"77988921\",\"tokensBefore\":329,\"estimatedTokensAfter\":559,\"usage\":{\"input\":574,\"output\":317,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":891,\"cost\":{\"input\":0.00287,\"output\":0.009510000000000001,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.01238}},\"details\":{\"readFiles\":[],\"modifiedFiles\":[]}}}"} +{"dir":"in","raw":"{\"id\": \"q13\", \"type\": \"get_session_stats\"}"} +{"dir":"out","raw":"{\"id\":\"q13\",\"type\":\"response\",\"command\":\"get_session_stats\",\"success\":true,\"data\":{\"sessionFile\":\"/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/steer-then-compact/sessions/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.jsonl\",\"sessionId\":\"00000000-0000-4000-8000-000000000049\",\"userMessages\":3,\"assistantMessages\":3,\"toolCalls\":1,\"toolResults\":1,\"totalMessages\":8,\"tokens\":{\"input\":1409,\"output\":368,\"cacheRead\":0,\"cacheWrite\":0,\"total\":1777},\"cost\":0.018085,\"contextUsage\":{\"tokens\":null,\"contextWindow\":272000,\"percent\":null}}}"} diff --git a/src/main/pi/__fixtures__/dialogs.jsonl b/src/main/pi/__fixtures__/dialogs.jsonl new file mode 100644 index 00000000000..11ffe8aa068 --- /dev/null +++ b/src/main/pi/__fixtures__/dialogs.jsonl @@ -0,0 +1,26 @@ +{"dir":"in","raw":"{\"id\": \"q1\", \"type\": \"get_state\"}"} +{"dir":"out","raw":"{\"id\":\"q1\",\"type\":\"response\",\"command\":\"get_state\",\"success\":true,\"data\":{\"model\":{\"id\":\"gpt-6.1-sol\",\"name\":\"GPT-6.1 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":10,\"cacheRead\":0.1,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":15,\"cacheRead\":0.2,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":null,\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},\"thinkingLevel\":\"medium\",\"isStreaming\":false,\"isCompacting\":false,\"steeringMode\":\"one-at-a-time\",\"followUpMode\":\"one-at-a-time\",\"sessionFile\":\"/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/dialogs/sessions/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.jsonl\",\"sessionId\":\"00000000-0000-4000-8000-000000000003\",\"autoCompactionEnabled\":true,\"messageCount\":0,\"pendingMessageCount\":0}}"} +{"dir":"in","raw":"{\"id\": \"q2\", \"type\": \"set_auto_retry\", \"enabled\": false}"} +{"dir":"out","raw":"{\"id\":\"q2\",\"type\":\"response\",\"command\":\"set_auto_retry\",\"success\":true}"} +{"dir":"in","raw":"{\"id\": \"q3\", \"type\": \"set_auto_compaction\", \"enabled\": false}"} +{"dir":"out","raw":"{\"id\":\"q3\",\"type\":\"response\",\"command\":\"set_auto_compaction\",\"success\":true}"} +{"dir":"in","raw":"{\"id\": \"q4\", \"type\": \"get_available_models\"}"} +{"dir":"out","raw":"{\"id\":\"q4\",\"type\":\"response\",\"command\":\"get_available_models\",\"success\":true,\"data\":{\"models\":[{\"id\":\"gpt-5.3-codex-spark\",\"name\":\"GPT-5.3 Codex Spark\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\"],\"cost\":{\"input\":1.75,\"output\":14,\"cacheRead\":0.175,\"cacheWrite\":0},\"contextWindow\":128000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true},\"type\":\"chat\"},{\"id\":\"gpt-5.5\",\"name\":\"GPT-5.5\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":5,\"output\":30,\"cacheRead\":0.5,\"cacheWrite\":0,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":10,\"output\":45,\"cacheRead\":1,\"cacheWrite\":0}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-5.6-luna\",\"name\":\"GPT-5.6 Luna\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":0.2,\"output\":1.2,\"cacheRead\":0.02,\"cacheWrite\":0.25,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":0.4,\"output\":1.8,\"cacheRead\":0.04,\"cacheWrite\":0.5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"max\":\"max\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-5.6-sol\",\"name\":\"GPT-5.6 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":4,\"output\":20,\"cacheRead\":0.4,\"cacheWrite\":5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":8,\"output\":30,\"cacheRead\":0.8,\"cacheWrite\":10}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"max\":\"max\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-5.6-terra\",\"name\":\"GPT-5.6 Terra\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":12,\"cacheRead\":0.2,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":18,\"cacheRead\":0.4,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"max\":\"max\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6-astra\",\"name\":\"GPT-6 Astra\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":10,\"output\":50,\"cacheRead\":1,\"cacheWrite\":12.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":20,\"output\":75,\"cacheRead\":2,\"cacheWrite\":25}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":null,\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6-luna\",\"name\":\"GPT-6 Luna\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":0.1,\"output\":0.5,\"cacheRead\":0.01,\"cacheWrite\":0.125,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":0.2,\"output\":0.75,\"cacheRead\":0.02,\"cacheWrite\":0.25}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":\"none\",\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6-sol\",\"name\":\"GPT-6 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":10,\"cacheRead\":0.2,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":15,\"cacheRead\":0.4,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":\"none\",\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6.1-sol\",\"name\":\"GPT-6.1 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":10,\"cacheRead\":0.1,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":15,\"cacheRead\":0.2,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":null,\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"}]}}"} +{"dir":"in","raw":"{\"id\": \"q5\", \"type\": \"set_model\", \"provider\": \"openai-codex\", \"modelId\": \"gpt-5.5\"}"} +{"dir":"out","raw":"{\"id\":\"q5\",\"type\":\"response\",\"command\":\"set_model\",\"success\":true,\"data\":{\"id\":\"gpt-5.5\",\"name\":\"GPT-5.5\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":5,\"output\":30,\"cacheRead\":0.5,\"cacheWrite\":0,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":10,\"output\":45,\"cacheRead\":1,\"cacheWrite\":0}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"}}"} +{"dir":"in","raw":"{\"id\": \"q6\", \"type\": \"set_thinking_level\", \"level\": \"off\"}"} +{"dir":"out","raw":"{\"type\":\"thinking_level_changed\",\"level\":\"off\"}"} +{"dir":"out","raw":"{\"id\":\"q6\",\"type\":\"response\",\"command\":\"set_thinking_level\",\"success\":true}"} +{"dir":"in","raw":"{\"id\": \"q7\", \"type\": \"prompt\", \"message\": \"/qa-dialogs\"}"} +{"dir":"out","raw":"{\"type\":\"extension_ui_request\",\"id\":\"00000000-0000-4000-8000-000000000004\",\"method\":\"confirm\",\"title\":\"RPC capture confirm\",\"message\":\"Continue the capture?\"}"} +{"dir":"in","raw":"{\"id\": \"00000000-0000-4000-8000-000000000004\", \"type\": \"extension_ui_response\", \"confirmed\": true}"} +{"dir":"out","raw":"{\"type\":\"extension_ui_request\",\"id\":\"00000000-0000-4000-8000-000000000005\",\"method\":\"select\",\"title\":\"RPC capture select\",\"options\":[\"Alpha\",\"Beta\"]}"} +{"dir":"in","raw":"{\"id\": \"00000000-0000-4000-8000-000000000005\", \"type\": \"extension_ui_response\", \"value\": \"Beta\"}"} +{"dir":"out","raw":"{\"type\":\"extension_ui_request\",\"id\":\"00000000-0000-4000-8000-000000000006\",\"method\":\"input\",\"title\":\"RPC capture input\",\"placeholder\":\"Type a value\"}"} +{"dir":"in","raw":"{\"id\": \"00000000-0000-4000-8000-000000000006\", \"type\": \"extension_ui_response\", \"value\": \"\"}"} +{"dir":"out","raw":"{\"type\":\"extension_ui_request\",\"id\":\"00000000-0000-4000-8000-000000000007\",\"method\":\"editor\",\"title\":\"RPC capture editor\",\"prefill\":\"one\\ntwo\"}"} +{"dir":"in","raw":"{\"id\": \"00000000-0000-4000-8000-000000000007\", \"type\": \"extension_ui_response\", \"value\": \"one\\nchanged
line
end\"}"} +{"dir":"out","raw":"{\"type\":\"extension_ui_request\",\"id\":\"00000000-0000-4000-8000-000000000008\",\"method\":\"notify\",\"message\":\"{\\\"yes\\\":true,\\\"choice\\\":\\\"Beta\\\",\\\"input\\\":\\\"\\\",\\\"edited\\\":\\\"one\\\\nchanged
line
end\\\"}\",\"notifyType\":\"info\"}"} +{"dir":"out","raw":"{\"id\":\"q7\",\"type\":\"response\",\"command\":\"prompt\",\"success\":true,\"data\":{\"disposition\":\"handled\"}}"} +{"dir":"in","raw":"{\"id\": \"q12\", \"type\": \"get_state\"}"} +{"dir":"out","raw":"{\"id\":\"q12\",\"type\":\"response\",\"command\":\"get_state\",\"success\":true,\"data\":{\"model\":{\"id\":\"gpt-5.5\",\"name\":\"GPT-5.5\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":5,\"output\":30,\"cacheRead\":0.5,\"cacheWrite\":0,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":10,\"output\":45,\"cacheRead\":1,\"cacheWrite\":0}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},\"thinkingLevel\":\"off\",\"isStreaming\":false,\"isCompacting\":false,\"steeringMode\":\"one-at-a-time\",\"followUpMode\":\"one-at-a-time\",\"sessionFile\":\"/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/dialogs/sessions/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.jsonl\",\"sessionId\":\"00000000-0000-4000-8000-000000000003\",\"autoCompactionEnabled\":false,\"messageCount\":0,\"pendingMessageCount\":0}}"} diff --git a/src/main/pi/__fixtures__/provider-fetch-error.jsonl b/src/main/pi/__fixtures__/provider-fetch-error.jsonl new file mode 100644 index 00000000000..22f2b663a78 --- /dev/null +++ b/src/main/pi/__fixtures__/provider-fetch-error.jsonl @@ -0,0 +1,32 @@ +{"dir":"in","raw":"{\"id\": \"q1\", \"type\": \"get_state\"}"} +{"dir":"out","raw":"{\"id\":\"q1\",\"type\":\"response\",\"command\":\"get_state\",\"success\":true,\"data\":{\"model\":{\"id\":\"gpt-6.1-sol\",\"name\":\"GPT-6.1 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":10,\"cacheRead\":0.1,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":15,\"cacheRead\":0.2,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":null,\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},\"thinkingLevel\":\"medium\",\"isStreaming\":false,\"isCompacting\":false,\"steeringMode\":\"one-at-a-time\",\"followUpMode\":\"one-at-a-time\",\"sessionFile\":\"/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/basic-r2/sessions/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.jsonl\",\"sessionId\":\"00000000-0000-4000-8000-000000000017\",\"autoCompactionEnabled\":true,\"messageCount\":0,\"pendingMessageCount\":0}}"} +{"dir":"in","raw":"{\"id\": \"q2\", \"type\": \"set_auto_retry\", \"enabled\": false}"} +{"dir":"out","raw":"{\"id\":\"q2\",\"type\":\"response\",\"command\":\"set_auto_retry\",\"success\":true}"} +{"dir":"in","raw":"{\"id\": \"q3\", \"type\": \"set_auto_compaction\", \"enabled\": false}"} +{"dir":"out","raw":"{\"id\":\"q3\",\"type\":\"response\",\"command\":\"set_auto_compaction\",\"success\":true}"} +{"dir":"in","raw":"{\"id\": \"q4\", \"type\": \"get_available_models\"}"} +{"dir":"out","raw":"{\"id\":\"q4\",\"type\":\"response\",\"command\":\"get_available_models\",\"success\":true,\"data\":{\"models\":[{\"id\":\"gpt-5.3-codex-spark\",\"name\":\"GPT-5.3 Codex Spark\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\"],\"cost\":{\"input\":1.75,\"output\":14,\"cacheRead\":0.175,\"cacheWrite\":0},\"contextWindow\":128000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true},\"type\":\"chat\"},{\"id\":\"gpt-5.5\",\"name\":\"GPT-5.5\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":5,\"output\":30,\"cacheRead\":0.5,\"cacheWrite\":0,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":10,\"output\":45,\"cacheRead\":1,\"cacheWrite\":0}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-5.6-luna\",\"name\":\"GPT-5.6 Luna\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":0.2,\"output\":1.2,\"cacheRead\":0.02,\"cacheWrite\":0.25,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":0.4,\"output\":1.8,\"cacheRead\":0.04,\"cacheWrite\":0.5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"max\":\"max\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-5.6-sol\",\"name\":\"GPT-5.6 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":4,\"output\":20,\"cacheRead\":0.4,\"cacheWrite\":5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":8,\"output\":30,\"cacheRead\":0.8,\"cacheWrite\":10}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"max\":\"max\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-5.6-terra\",\"name\":\"GPT-5.6 Terra\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":12,\"cacheRead\":0.2,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":18,\"cacheRead\":0.4,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"max\":\"max\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6-astra\",\"name\":\"GPT-6 Astra\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":10,\"output\":50,\"cacheRead\":1,\"cacheWrite\":12.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":20,\"output\":75,\"cacheRead\":2,\"cacheWrite\":25}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":null,\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6-luna\",\"name\":\"GPT-6 Luna\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":0.1,\"output\":0.5,\"cacheRead\":0.01,\"cacheWrite\":0.125,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":0.2,\"output\":0.75,\"cacheRead\":0.02,\"cacheWrite\":0.25}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":\"none\",\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6-sol\",\"name\":\"GPT-6 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":10,\"cacheRead\":0.2,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":15,\"cacheRead\":0.4,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":\"none\",\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6.1-sol\",\"name\":\"GPT-6.1 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":10,\"cacheRead\":0.1,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":15,\"cacheRead\":0.2,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":null,\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"}]}}"} +{"dir":"in","raw":"{\"id\": \"q5\", \"type\": \"set_model\", \"provider\": \"openai-codex\", \"modelId\": \"gpt-5.5\"}"} +{"dir":"out","raw":"{\"id\":\"q5\",\"type\":\"response\",\"command\":\"set_model\",\"success\":true,\"data\":{\"id\":\"gpt-5.5\",\"name\":\"GPT-5.5\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":5,\"output\":30,\"cacheRead\":0.5,\"cacheWrite\":0,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":10,\"output\":45,\"cacheRead\":1,\"cacheWrite\":0}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"}}"} +{"dir":"in","raw":"{\"id\": \"q6\", \"type\": \"set_thinking_level\", \"level\": \"off\"}"} +{"dir":"out","raw":"{\"type\":\"thinking_level_changed\",\"level\":\"off\"}"} +{"dir":"out","raw":"{\"id\":\"q6\",\"type\":\"response\",\"command\":\"set_thinking_level\",\"success\":true}"} +{"dir":"in","raw":"{\"id\": \"q7\", \"type\": \"prompt\", \"message\": \"Reply exactly RPC_CAPTURE_OK.\"}"} +{"dir":"out","raw":"{\"id\":\"q7\",\"type\":\"response\",\"command\":\"prompt\",\"success\":true,\"data\":{\"disposition\":\"started\"}}"} +{"dir":"out","raw":"{\"type\":\"agent_start\"}"} +{"dir":"out","raw":"{\"type\":\"turn_start\"}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"system\",\"content\":\"\",\"sections\":{\"preamble\":\"You are a protocol capture assistant. Follow exact requests and use only the requested tools. Read and write only within this project. Never read credentials, home directories, or account details.\",\"cwd\":\"\\n/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/basic-r2\\n\"},\"timestamp\":1791279374657,\"toolsAdded\":[{\"name\":\"read\",\"description\":\"Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to read (relative or absolute)\"},\"offset\":{\"type\":\"number\",\"description\":\"Line number to start reading from (1-indexed)\"},\"limit\":{\"type\":\"number\",\"description\":\"Maximum number of lines to read\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"bash\",\"description\":\"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.\",\"parameters\":{\"type\":\"object\",\"required\":[\"command\"],\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout\":{\"type\":\"number\",\"description\":\"Timeout in seconds (optional, no default timeout)\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"edit\",\"description\":\"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"edits\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to edit (relative or absolute)\"},\"edits\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"oldText\",\"newText\"],\"properties\":{\"oldText\":{\"type\":\"string\",\"description\":\"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.\"},\"newText\":{\"type\":\"string\",\"description\":\"Replacement text for this targeted edit.\"}}},\"description\":\"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"write\",\"description\":\"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"content\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to write (relative or absolute)\"},\"content\":{\"type\":\"string\",\"description\":\"Content to write to the file\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}}]}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"system\",\"content\":\"\",\"sections\":{\"preamble\":\"You are a protocol capture assistant. Follow exact requests and use only the requested tools. Read and write only within this project. Never read credentials, home directories, or account details.\",\"cwd\":\"\\n/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/basic-r2\\n\"},\"timestamp\":1791279374657,\"toolsAdded\":[{\"name\":\"read\",\"description\":\"Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to read (relative or absolute)\"},\"offset\":{\"type\":\"number\",\"description\":\"Line number to start reading from (1-indexed)\"},\"limit\":{\"type\":\"number\",\"description\":\"Maximum number of lines to read\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"bash\",\"description\":\"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.\",\"parameters\":{\"type\":\"object\",\"required\":[\"command\"],\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout\":{\"type\":\"number\",\"description\":\"Timeout in seconds (optional, no default timeout)\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"edit\",\"description\":\"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"edits\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to edit (relative or absolute)\"},\"edits\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"oldText\",\"newText\"],\"properties\":{\"oldText\":{\"type\":\"string\",\"description\":\"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.\"},\"newText\":{\"type\":\"string\",\"description\":\"Replacement text for this targeted edit.\"}}},\"description\":\"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"write\",\"description\":\"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"content\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to write (relative or absolute)\"},\"content\":{\"type\":\"string\",\"description\":\"Content to write to the file\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}}]}}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Reply exactly RPC_CAPTURE_OK.\"}],\"timestamp\":1791279374657}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Reply exactly RPC_CAPTURE_OK.\"}],\"timestamp\":1791279374657}}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"assistant\",\"content\":[],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"error\",\"timestamp\":1791279374699,\"diagnostics\":[{\"type\":\"provider_transport_failure\",\"timestamp\":1791279385277,\"error\":{\"name\":\"Error\",\"message\":\"WebSocket error\",\"stack\":\"Error: WebSocket error\\n at extractWebSocketError (file:///Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/@earendil-works/pi-ai/dist/api/openai-codex-responses.js:973:12)\\n at WebSocket.onError (file:///Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/@earendil-works/pi-ai/dist/api/openai-codex-responses.js:845:18)\\n at [nodejs.internal.kHybridDispatch] (node:internal/event_target:856:20)\\n at WebSocket.dispatchEvent (node:internal/event_target:789:26)\\n at fireEvent (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/util.js:69:10)\\n at #onSocketClose (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/websocket.js:630:7)\\n at Object.onSocketClose (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/websocket.js:99:45)\\n at failWebsocketConnection (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/connection.js:319:13)\\n at Object.processResponse (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/connection.js:104:11)\\n at /Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/fetch/index.js:1120:19\"},\"details\":{\"configuredTransport\":\"auto\",\"fallbackTransport\":\"sse\",\"eventsEmitted\":false,\"phase\":\"before_message_stream_start\",\"requestBytes\":3761}}],\"errorMessage\":\"fetch failed\",\"thinkingLevel\":\"off\"}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"assistant\",\"content\":[],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"error\",\"timestamp\":1791279374699,\"diagnostics\":[{\"type\":\"provider_transport_failure\",\"timestamp\":1791279385277,\"error\":{\"name\":\"Error\",\"message\":\"WebSocket error\",\"stack\":\"Error: WebSocket error\\n at extractWebSocketError (file:///Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/@earendil-works/pi-ai/dist/api/openai-codex-responses.js:973:12)\\n at WebSocket.onError (file:///Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/@earendil-works/pi-ai/dist/api/openai-codex-responses.js:845:18)\\n at [nodejs.internal.kHybridDispatch] (node:internal/event_target:856:20)\\n at WebSocket.dispatchEvent (node:internal/event_target:789:26)\\n at fireEvent (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/util.js:69:10)\\n at #onSocketClose (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/websocket.js:630:7)\\n at Object.onSocketClose (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/websocket.js:99:45)\\n at failWebsocketConnection (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/connection.js:319:13)\\n at Object.processResponse (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/connection.js:104:11)\\n at /Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/fetch/index.js:1120:19\"},\"details\":{\"configuredTransport\":\"auto\",\"fallbackTransport\":\"sse\",\"eventsEmitted\":false,\"phase\":\"before_message_stream_start\",\"requestBytes\":3761}}],\"errorMessage\":\"fetch failed\",\"thinkingLevel\":\"off\"}}"} +{"dir":"out","raw":"{\"type\":\"turn_end\",\"message\":{\"role\":\"assistant\",\"content\":[],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"error\",\"timestamp\":1791279374699,\"diagnostics\":[{\"type\":\"provider_transport_failure\",\"timestamp\":1791279385277,\"error\":{\"name\":\"Error\",\"message\":\"WebSocket error\",\"stack\":\"Error: WebSocket error\\n at extractWebSocketError (file:///Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/@earendil-works/pi-ai/dist/api/openai-codex-responses.js:973:12)\\n at WebSocket.onError (file:///Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/@earendil-works/pi-ai/dist/api/openai-codex-responses.js:845:18)\\n at [nodejs.internal.kHybridDispatch] (node:internal/event_target:856:20)\\n at WebSocket.dispatchEvent (node:internal/event_target:789:26)\\n at fireEvent (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/util.js:69:10)\\n at #onSocketClose (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/websocket.js:630:7)\\n at Object.onSocketClose (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/websocket.js:99:45)\\n at failWebsocketConnection (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/connection.js:319:13)\\n at Object.processResponse (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/connection.js:104:11)\\n at /Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/fetch/index.js:1120:19\"},\"details\":{\"configuredTransport\":\"auto\",\"fallbackTransport\":\"sse\",\"eventsEmitted\":false,\"phase\":\"before_message_stream_start\",\"requestBytes\":3761}}],\"errorMessage\":\"fetch failed\",\"thinkingLevel\":\"off\"},\"toolResults\":[]}"} +{"dir":"out","raw":"{\"type\":\"agent_end\",\"messages\":[{\"role\":\"system\",\"content\":\"\",\"sections\":{\"preamble\":\"You are a protocol capture assistant. Follow exact requests and use only the requested tools. Read and write only within this project. Never read credentials, home directories, or account details.\",\"cwd\":\"\\n/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/basic-r2\\n\"},\"timestamp\":1791279374657,\"toolsAdded\":[{\"name\":\"read\",\"description\":\"Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to read (relative or absolute)\"},\"offset\":{\"type\":\"number\",\"description\":\"Line number to start reading from (1-indexed)\"},\"limit\":{\"type\":\"number\",\"description\":\"Maximum number of lines to read\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"bash\",\"description\":\"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.\",\"parameters\":{\"type\":\"object\",\"required\":[\"command\"],\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout\":{\"type\":\"number\",\"description\":\"Timeout in seconds (optional, no default timeout)\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"edit\",\"description\":\"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"edits\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to edit (relative or absolute)\"},\"edits\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"oldText\",\"newText\"],\"properties\":{\"oldText\":{\"type\":\"string\",\"description\":\"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.\"},\"newText\":{\"type\":\"string\",\"description\":\"Replacement text for this targeted edit.\"}}},\"description\":\"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"write\",\"description\":\"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"content\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to write (relative or absolute)\"},\"content\":{\"type\":\"string\",\"description\":\"Content to write to the file\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Reply exactly RPC_CAPTURE_OK.\"}],\"timestamp\":1791279374657},{\"role\":\"assistant\",\"content\":[],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"error\",\"timestamp\":1791279374699,\"diagnostics\":[{\"type\":\"provider_transport_failure\",\"timestamp\":1791279385277,\"error\":{\"name\":\"Error\",\"message\":\"WebSocket error\",\"stack\":\"Error: WebSocket error\\n at extractWebSocketError (file:///Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/@earendil-works/pi-ai/dist/api/openai-codex-responses.js:973:12)\\n at WebSocket.onError (file:///Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/@earendil-works/pi-ai/dist/api/openai-codex-responses.js:845:18)\\n at [nodejs.internal.kHybridDispatch] (node:internal/event_target:856:20)\\n at WebSocket.dispatchEvent (node:internal/event_target:789:26)\\n at fireEvent (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/util.js:69:10)\\n at #onSocketClose (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/websocket.js:630:7)\\n at Object.onSocketClose (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/websocket.js:99:45)\\n at failWebsocketConnection (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/connection.js:319:13)\\n at Object.processResponse (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/connection.js:104:11)\\n at /Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/fetch/index.js:1120:19\"},\"details\":{\"configuredTransport\":\"auto\",\"fallbackTransport\":\"sse\",\"eventsEmitted\":false,\"phase\":\"before_message_stream_start\",\"requestBytes\":3761}}],\"errorMessage\":\"fetch failed\",\"thinkingLevel\":\"off\"}],\"willRetry\":false}"} +{"dir":"in","raw":"{\"id\": \"q8\", \"type\": \"get_state\"}"} +{"dir":"out","raw":"{\"type\":\"agent_settled\"}"} +{"dir":"out","raw":"{\"id\":\"q8\",\"type\":\"response\",\"command\":\"get_state\",\"success\":true,\"data\":{\"model\":{\"id\":\"gpt-5.5\",\"name\":\"GPT-5.5\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":5,\"output\":30,\"cacheRead\":0.5,\"cacheWrite\":0,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":10,\"output\":45,\"cacheRead\":1,\"cacheWrite\":0}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},\"thinkingLevel\":\"off\",\"isStreaming\":false,\"isCompacting\":false,\"steeringMode\":\"one-at-a-time\",\"followUpMode\":\"one-at-a-time\",\"sessionFile\":\"/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/basic-r2/sessions/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.jsonl\",\"sessionId\":\"00000000-0000-4000-8000-000000000017\",\"autoCompactionEnabled\":false,\"messageCount\":3,\"pendingMessageCount\":0}}"} +{"dir":"in","raw":"{\"id\": \"q9\", \"type\": \"get_session_stats\"}"} +{"dir":"out","raw":"{\"id\":\"q9\",\"type\":\"response\",\"command\":\"get_session_stats\",\"success\":true,\"data\":{\"sessionFile\":\"/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/basic-r2/sessions/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.jsonl\",\"sessionId\":\"00000000-0000-4000-8000-000000000017\",\"userMessages\":1,\"assistantMessages\":1,\"toolCalls\":0,\"toolResults\":0,\"totalMessages\":3,\"tokens\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0},\"cost\":0,\"contextUsage\":{\"tokens\":820,\"contextWindow\":272000,\"percent\":0.3014705882352941}}}"} +{"dir":"in","raw":"{\"id\": \"q10\", \"type\": \"get_messages\"}"} +{"dir":"out","raw":"{\"id\":\"q10\",\"type\":\"response\",\"command\":\"get_messages\",\"success\":true,\"data\":{\"messages\":[{\"role\":\"system\",\"content\":\"\",\"sections\":{\"preamble\":\"You are a protocol capture assistant. Follow exact requests and use only the requested tools. Read and write only within this project. Never read credentials, home directories, or account details.\",\"cwd\":\"\\n/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/basic-r2\\n\"},\"timestamp\":1791279374657,\"toolsAdded\":[{\"name\":\"read\",\"description\":\"Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to read (relative or absolute)\"},\"offset\":{\"type\":\"number\",\"description\":\"Line number to start reading from (1-indexed)\"},\"limit\":{\"type\":\"number\",\"description\":\"Maximum number of lines to read\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"bash\",\"description\":\"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.\",\"parameters\":{\"type\":\"object\",\"required\":[\"command\"],\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout\":{\"type\":\"number\",\"description\":\"Timeout in seconds (optional, no default timeout)\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"edit\",\"description\":\"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"edits\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to edit (relative or absolute)\"},\"edits\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"oldText\",\"newText\"],\"properties\":{\"oldText\":{\"type\":\"string\",\"description\":\"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.\"},\"newText\":{\"type\":\"string\",\"description\":\"Replacement text for this targeted edit.\"}}},\"description\":\"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"write\",\"description\":\"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"content\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to write (relative or absolute)\"},\"content\":{\"type\":\"string\",\"description\":\"Content to write to the file\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Reply exactly RPC_CAPTURE_OK.\"}],\"timestamp\":1791279374657},{\"role\":\"assistant\",\"content\":[],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"error\",\"timestamp\":1791279374699,\"diagnostics\":[{\"type\":\"provider_transport_failure\",\"timestamp\":1791279385277,\"error\":{\"name\":\"Error\",\"message\":\"WebSocket error\",\"stack\":\"Error: WebSocket error\\n at extractWebSocketError (file:///Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/@earendil-works/pi-ai/dist/api/openai-codex-responses.js:973:12)\\n at WebSocket.onError (file:///Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/@earendil-works/pi-ai/dist/api/openai-codex-responses.js:845:18)\\n at [nodejs.internal.kHybridDispatch] (node:internal/event_target:856:20)\\n at WebSocket.dispatchEvent (node:internal/event_target:789:26)\\n at fireEvent (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/util.js:69:10)\\n at #onSocketClose (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/websocket.js:630:7)\\n at Object.onSocketClose (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/websocket.js:99:45)\\n at failWebsocketConnection (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/connection.js:319:13)\\n at Object.processResponse (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/websocket/connection.js:104:11)\\n at /Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/undici/lib/web/fetch/index.js:1120:19\"},\"details\":{\"configuredTransport\":\"auto\",\"fallbackTransport\":\"sse\",\"eventsEmitted\":false,\"phase\":\"before_message_stream_start\",\"requestBytes\":3761}}],\"errorMessage\":\"fetch failed\",\"thinkingLevel\":\"off\"}]}}"} diff --git a/src/main/pi/__fixtures__/provider-socket-error.jsonl b/src/main/pi/__fixtures__/provider-socket-error.jsonl new file mode 100644 index 00000000000..f54d5804761 --- /dev/null +++ b/src/main/pi/__fixtures__/provider-socket-error.jsonl @@ -0,0 +1,32 @@ +{"dir":"in","raw":"{\"id\": \"q1\", \"type\": \"get_state\"}"} +{"dir":"out","raw":"{\"id\":\"q1\",\"type\":\"response\",\"command\":\"get_state\",\"success\":true,\"data\":{\"model\":{\"id\":\"gpt-6.1-sol\",\"name\":\"GPT-6.1 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":10,\"cacheRead\":0.1,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":15,\"cacheRead\":0.2,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":null,\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},\"thinkingLevel\":\"medium\",\"isStreaming\":false,\"isCompacting\":false,\"steeringMode\":\"one-at-a-time\",\"followUpMode\":\"one-at-a-time\",\"sessionFile\":\"/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/tool-bun/sessions/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.jsonl\",\"sessionId\":\"00000000-0000-4000-8000-000000000018\",\"autoCompactionEnabled\":true,\"messageCount\":0,\"pendingMessageCount\":0}}"} +{"dir":"in","raw":"{\"id\": \"q2\", \"type\": \"set_auto_retry\", \"enabled\": false}"} +{"dir":"out","raw":"{\"id\":\"q2\",\"type\":\"response\",\"command\":\"set_auto_retry\",\"success\":true}"} +{"dir":"in","raw":"{\"id\": \"q3\", \"type\": \"set_auto_compaction\", \"enabled\": false}"} +{"dir":"out","raw":"{\"id\":\"q3\",\"type\":\"response\",\"command\":\"set_auto_compaction\",\"success\":true}"} +{"dir":"in","raw":"{\"id\": \"q4\", \"type\": \"get_available_models\"}"} +{"dir":"out","raw":"{\"id\":\"q4\",\"type\":\"response\",\"command\":\"get_available_models\",\"success\":true,\"data\":{\"models\":[{\"id\":\"gpt-5.3-codex-spark\",\"name\":\"GPT-5.3 Codex Spark\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\"],\"cost\":{\"input\":1.75,\"output\":14,\"cacheRead\":0.175,\"cacheWrite\":0},\"contextWindow\":128000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true},\"type\":\"chat\"},{\"id\":\"gpt-5.5\",\"name\":\"GPT-5.5\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":5,\"output\":30,\"cacheRead\":0.5,\"cacheWrite\":0,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":10,\"output\":45,\"cacheRead\":1,\"cacheWrite\":0}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-5.6-luna\",\"name\":\"GPT-5.6 Luna\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":0.2,\"output\":1.2,\"cacheRead\":0.02,\"cacheWrite\":0.25,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":0.4,\"output\":1.8,\"cacheRead\":0.04,\"cacheWrite\":0.5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"max\":\"max\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-5.6-sol\",\"name\":\"GPT-5.6 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":4,\"output\":20,\"cacheRead\":0.4,\"cacheWrite\":5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":8,\"output\":30,\"cacheRead\":0.8,\"cacheWrite\":10}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"max\":\"max\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-5.6-terra\",\"name\":\"GPT-5.6 Terra\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":12,\"cacheRead\":0.2,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":18,\"cacheRead\":0.4,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"max\":\"max\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6-astra\",\"name\":\"GPT-6 Astra\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":10,\"output\":50,\"cacheRead\":1,\"cacheWrite\":12.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":20,\"output\":75,\"cacheRead\":2,\"cacheWrite\":25}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":null,\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6-luna\",\"name\":\"GPT-6 Luna\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":0.1,\"output\":0.5,\"cacheRead\":0.01,\"cacheWrite\":0.125,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":0.2,\"output\":0.75,\"cacheRead\":0.02,\"cacheWrite\":0.25}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":\"none\",\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6-sol\",\"name\":\"GPT-6 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":10,\"cacheRead\":0.2,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":15,\"cacheRead\":0.4,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":\"none\",\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6.1-sol\",\"name\":\"GPT-6.1 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":10,\"cacheRead\":0.1,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":15,\"cacheRead\":0.2,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":null,\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"}]}}"} +{"dir":"in","raw":"{\"id\": \"q5\", \"type\": \"set_model\", \"provider\": \"openai-codex\", \"modelId\": \"gpt-5.5\"}"} +{"dir":"out","raw":"{\"id\":\"q5\",\"type\":\"response\",\"command\":\"set_model\",\"success\":true,\"data\":{\"id\":\"gpt-5.5\",\"name\":\"GPT-5.5\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":5,\"output\":30,\"cacheRead\":0.5,\"cacheWrite\":0,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":10,\"output\":45,\"cacheRead\":1,\"cacheWrite\":0}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"}}"} +{"dir":"in","raw":"{\"id\": \"q6\", \"type\": \"set_thinking_level\", \"level\": \"off\"}"} +{"dir":"out","raw":"{\"type\":\"thinking_level_changed\",\"level\":\"off\"}"} +{"dir":"out","raw":"{\"id\":\"q6\",\"type\":\"response\",\"command\":\"set_thinking_level\",\"success\":true}"} +{"dir":"in","raw":"{\"id\": \"q7\", \"type\": \"prompt\", \"message\": \"Use the write tool to create capture.txt containing exactly fixture-ok. Then use read to read capture.txt. Reply exactly TOOL_CAPTURE_OK.\"}"} +{"dir":"out","raw":"{\"id\":\"q7\",\"type\":\"response\",\"command\":\"prompt\",\"success\":true,\"data\":{\"disposition\":\"started\"}}"} +{"dir":"out","raw":"{\"type\":\"agent_start\"}"} +{"dir":"out","raw":"{\"type\":\"turn_start\"}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"system\",\"content\":\"\",\"sections\":{\"preamble\":\"You are a protocol capture assistant. Follow exact requests and use only the requested tools. Read and write only within this project. Never read credentials, home directories, or account details.\",\"cwd\":\"\\n/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/tool-bun\\n\"},\"timestamp\":1791279674452,\"toolsAdded\":[{\"name\":\"read\",\"description\":\"Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to read (relative or absolute)\"},\"offset\":{\"type\":\"number\",\"description\":\"Line number to start reading from (1-indexed)\"},\"limit\":{\"type\":\"number\",\"description\":\"Maximum number of lines to read\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"bash\",\"description\":\"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.\",\"parameters\":{\"type\":\"object\",\"required\":[\"command\"],\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout\":{\"type\":\"number\",\"description\":\"Timeout in seconds (optional, no default timeout)\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"edit\",\"description\":\"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"edits\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to edit (relative or absolute)\"},\"edits\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"oldText\",\"newText\"],\"properties\":{\"oldText\":{\"type\":\"string\",\"description\":\"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.\"},\"newText\":{\"type\":\"string\",\"description\":\"Replacement text for this targeted edit.\"}}},\"description\":\"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"write\",\"description\":\"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"content\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to write (relative or absolute)\"},\"content\":{\"type\":\"string\",\"description\":\"Content to write to the file\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}}]}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"system\",\"content\":\"\",\"sections\":{\"preamble\":\"You are a protocol capture assistant. Follow exact requests and use only the requested tools. Read and write only within this project. Never read credentials, home directories, or account details.\",\"cwd\":\"\\n/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/tool-bun\\n\"},\"timestamp\":1791279674452,\"toolsAdded\":[{\"name\":\"read\",\"description\":\"Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to read (relative or absolute)\"},\"offset\":{\"type\":\"number\",\"description\":\"Line number to start reading from (1-indexed)\"},\"limit\":{\"type\":\"number\",\"description\":\"Maximum number of lines to read\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"bash\",\"description\":\"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.\",\"parameters\":{\"type\":\"object\",\"required\":[\"command\"],\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout\":{\"type\":\"number\",\"description\":\"Timeout in seconds (optional, no default timeout)\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"edit\",\"description\":\"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"edits\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to edit (relative or absolute)\"},\"edits\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"oldText\",\"newText\"],\"properties\":{\"oldText\":{\"type\":\"string\",\"description\":\"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.\"},\"newText\":{\"type\":\"string\",\"description\":\"Replacement text for this targeted edit.\"}}},\"description\":\"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"write\",\"description\":\"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"content\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to write (relative or absolute)\"},\"content\":{\"type\":\"string\",\"description\":\"Content to write to the file\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}}]}}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use the write tool to create capture.txt containing exactly fixture-ok. Then use read to read capture.txt. Reply exactly TOOL_CAPTURE_OK.\"}],\"timestamp\":1791279674451}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use the write tool to create capture.txt containing exactly fixture-ok. Then use read to read capture.txt. Reply exactly TOOL_CAPTURE_OK.\"}],\"timestamp\":1791279674451}}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"assistant\",\"content\":[],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"error\",\"timestamp\":1791279674486,\"diagnostics\":[{\"type\":\"provider_transport_failure\",\"timestamp\":1791279689495,\"error\":{\"name\":\"Error\",\"message\":\"WebSocket connect timeout after 15000ms\",\"stack\":\"Error: WebSocket connect timeout after 15000ms\\n at (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/@earendil-works/pi-ai/dist/api/openai-codex-responses.js:859:26)\"},\"details\":{\"configuredTransport\":\"auto\",\"fallbackTransport\":\"sse\",\"eventsEmitted\":false,\"phase\":\"before_message_stream_start\",\"requestBytes\":3869}}],\"errorMessage\":\"The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()\",\"thinkingLevel\":\"off\"}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"assistant\",\"content\":[],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"error\",\"timestamp\":1791279674486,\"diagnostics\":[{\"type\":\"provider_transport_failure\",\"timestamp\":1791279689495,\"error\":{\"name\":\"Error\",\"message\":\"WebSocket connect timeout after 15000ms\",\"stack\":\"Error: WebSocket connect timeout after 15000ms\\n at (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/@earendil-works/pi-ai/dist/api/openai-codex-responses.js:859:26)\"},\"details\":{\"configuredTransport\":\"auto\",\"fallbackTransport\":\"sse\",\"eventsEmitted\":false,\"phase\":\"before_message_stream_start\",\"requestBytes\":3869}}],\"errorMessage\":\"The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()\",\"thinkingLevel\":\"off\"}}"} +{"dir":"out","raw":"{\"type\":\"turn_end\",\"message\":{\"role\":\"assistant\",\"content\":[],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"error\",\"timestamp\":1791279674486,\"diagnostics\":[{\"type\":\"provider_transport_failure\",\"timestamp\":1791279689495,\"error\":{\"name\":\"Error\",\"message\":\"WebSocket connect timeout after 15000ms\",\"stack\":\"Error: WebSocket connect timeout after 15000ms\\n at (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/@earendil-works/pi-ai/dist/api/openai-codex-responses.js:859:26)\"},\"details\":{\"configuredTransport\":\"auto\",\"fallbackTransport\":\"sse\",\"eventsEmitted\":false,\"phase\":\"before_message_stream_start\",\"requestBytes\":3869}}],\"errorMessage\":\"The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()\",\"thinkingLevel\":\"off\"},\"toolResults\":[]}"} +{"dir":"out","raw":"{\"type\":\"agent_end\",\"messages\":[{\"role\":\"system\",\"content\":\"\",\"sections\":{\"preamble\":\"You are a protocol capture assistant. Follow exact requests and use only the requested tools. Read and write only within this project. Never read credentials, home directories, or account details.\",\"cwd\":\"\\n/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/tool-bun\\n\"},\"timestamp\":1791279674452,\"toolsAdded\":[{\"name\":\"read\",\"description\":\"Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to read (relative or absolute)\"},\"offset\":{\"type\":\"number\",\"description\":\"Line number to start reading from (1-indexed)\"},\"limit\":{\"type\":\"number\",\"description\":\"Maximum number of lines to read\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"bash\",\"description\":\"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.\",\"parameters\":{\"type\":\"object\",\"required\":[\"command\"],\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout\":{\"type\":\"number\",\"description\":\"Timeout in seconds (optional, no default timeout)\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"edit\",\"description\":\"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"edits\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to edit (relative or absolute)\"},\"edits\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"oldText\",\"newText\"],\"properties\":{\"oldText\":{\"type\":\"string\",\"description\":\"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.\"},\"newText\":{\"type\":\"string\",\"description\":\"Replacement text for this targeted edit.\"}}},\"description\":\"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"write\",\"description\":\"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"content\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to write (relative or absolute)\"},\"content\":{\"type\":\"string\",\"description\":\"Content to write to the file\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use the write tool to create capture.txt containing exactly fixture-ok. Then use read to read capture.txt. Reply exactly TOOL_CAPTURE_OK.\"}],\"timestamp\":1791279674451},{\"role\":\"assistant\",\"content\":[],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"error\",\"timestamp\":1791279674486,\"diagnostics\":[{\"type\":\"provider_transport_failure\",\"timestamp\":1791279689495,\"error\":{\"name\":\"Error\",\"message\":\"WebSocket connect timeout after 15000ms\",\"stack\":\"Error: WebSocket connect timeout after 15000ms\\n at (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/@earendil-works/pi-ai/dist/api/openai-codex-responses.js:859:26)\"},\"details\":{\"configuredTransport\":\"auto\",\"fallbackTransport\":\"sse\",\"eventsEmitted\":false,\"phase\":\"before_message_stream_start\",\"requestBytes\":3869}}],\"errorMessage\":\"The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()\",\"thinkingLevel\":\"off\"}],\"willRetry\":false}"} +{"dir":"in","raw":"{\"id\": \"q8\", \"type\": \"get_state\"}"} +{"dir":"out","raw":"{\"type\":\"agent_settled\"}"} +{"dir":"out","raw":"{\"id\":\"q8\",\"type\":\"response\",\"command\":\"get_state\",\"success\":true,\"data\":{\"model\":{\"id\":\"gpt-5.5\",\"name\":\"GPT-5.5\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":5,\"output\":30,\"cacheRead\":0.5,\"cacheWrite\":0,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":10,\"output\":45,\"cacheRead\":1,\"cacheWrite\":0}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},\"thinkingLevel\":\"off\",\"isStreaming\":false,\"isCompacting\":false,\"steeringMode\":\"one-at-a-time\",\"followUpMode\":\"one-at-a-time\",\"sessionFile\":\"/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/tool-bun/sessions/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.jsonl\",\"sessionId\":\"00000000-0000-4000-8000-000000000018\",\"autoCompactionEnabled\":false,\"messageCount\":3,\"pendingMessageCount\":0}}"} +{"dir":"in","raw":"{\"id\": \"q9\", \"type\": \"get_session_stats\"}"} +{"dir":"out","raw":"{\"id\":\"q9\",\"type\":\"response\",\"command\":\"get_session_stats\",\"success\":true,\"data\":{\"sessionFile\":\"/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/tool-bun/sessions/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.jsonl\",\"sessionId\":\"00000000-0000-4000-8000-000000000018\",\"userMessages\":1,\"assistantMessages\":1,\"toolCalls\":0,\"toolResults\":0,\"totalMessages\":3,\"tokens\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0},\"cost\":0,\"contextUsage\":{\"tokens\":847,\"contextWindow\":272000,\"percent\":0.3113970588235294}}}"} +{"dir":"in","raw":"{\"id\": \"q10\", \"type\": \"get_messages\"}"} +{"dir":"out","raw":"{\"id\":\"q10\",\"type\":\"response\",\"command\":\"get_messages\",\"success\":true,\"data\":{\"messages\":[{\"role\":\"system\",\"content\":\"\",\"sections\":{\"preamble\":\"You are a protocol capture assistant. Follow exact requests and use only the requested tools. Read and write only within this project. Never read credentials, home directories, or account details.\",\"cwd\":\"\\n/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/tool-bun\\n\"},\"timestamp\":1791279674452,\"toolsAdded\":[{\"name\":\"read\",\"description\":\"Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to read (relative or absolute)\"},\"offset\":{\"type\":\"number\",\"description\":\"Line number to start reading from (1-indexed)\"},\"limit\":{\"type\":\"number\",\"description\":\"Maximum number of lines to read\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"bash\",\"description\":\"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.\",\"parameters\":{\"type\":\"object\",\"required\":[\"command\"],\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout\":{\"type\":\"number\",\"description\":\"Timeout in seconds (optional, no default timeout)\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"edit\",\"description\":\"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"edits\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to edit (relative or absolute)\"},\"edits\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"oldText\",\"newText\"],\"properties\":{\"oldText\":{\"type\":\"string\",\"description\":\"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.\"},\"newText\":{\"type\":\"string\",\"description\":\"Replacement text for this targeted edit.\"}}},\"description\":\"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"write\",\"description\":\"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"content\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to write (relative or absolute)\"},\"content\":{\"type\":\"string\",\"description\":\"Content to write to the file\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use the write tool to create capture.txt containing exactly fixture-ok. Then use read to read capture.txt. Reply exactly TOOL_CAPTURE_OK.\"}],\"timestamp\":1791279674451},{\"role\":\"assistant\",\"content\":[],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"error\",\"timestamp\":1791279674486,\"diagnostics\":[{\"type\":\"provider_transport_failure\",\"timestamp\":1791279689495,\"error\":{\"name\":\"Error\",\"message\":\"WebSocket connect timeout after 15000ms\",\"stack\":\"Error: WebSocket connect timeout after 15000ms\\n at (/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/@earendil-works/pi-ai/dist/api/openai-codex-responses.js:859:26)\"},\"details\":{\"configuredTransport\":\"auto\",\"fallbackTransport\":\"sse\",\"eventsEmitted\":false,\"phase\":\"before_message_stream_start\",\"requestBytes\":3869}}],\"errorMessage\":\"The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()\",\"thinkingLevel\":\"off\"}]}}"} diff --git a/src/main/pi/__fixtures__/signed-out.jsonl b/src/main/pi/__fixtures__/signed-out.jsonl new file mode 100644 index 00000000000..0c01eb5cc17 --- /dev/null +++ b/src/main/pi/__fixtures__/signed-out.jsonl @@ -0,0 +1,12 @@ +{"dir":"in","raw":"{\"id\": \"q1\", \"type\": \"get_state\"}"} +{"dir":"out","raw":"{\"id\":\"q1\",\"type\":\"response\",\"command\":\"get_state\",\"success\":true,\"data\":{\"model\":{\"id\":\"unknown\",\"name\":\"unknown\",\"api\":\"unknown\",\"provider\":\"unknown\",\"baseUrl\":\"\",\"reasoning\":false,\"input\":[],\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0},\"contextWindow\":0,\"maxTokens\":0},\"thinkingLevel\":\"off\",\"isStreaming\":false,\"isCompacting\":false,\"steeringMode\":\"one-at-a-time\",\"followUpMode\":\"one-at-a-time\",\"sessionFile\":\"/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/signed-out/sessions/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.jsonl\",\"sessionId\":\"00000000-0000-4000-8000-000000000013\",\"autoCompactionEnabled\":true,\"messageCount\":0,\"pendingMessageCount\":0}}"} +{"dir":"in","raw":"{\"id\": \"q2\", \"type\": \"set_auto_retry\", \"enabled\": false}"} +{"dir":"out","raw":"{\"id\":\"q2\",\"type\":\"response\",\"command\":\"set_auto_retry\",\"success\":true}"} +{"dir":"in","raw":"{\"id\": \"q3\", \"type\": \"set_auto_compaction\", \"enabled\": false}"} +{"dir":"out","raw":"{\"id\":\"q3\",\"type\":\"response\",\"command\":\"set_auto_compaction\",\"success\":true}"} +{"dir":"in","raw":"{\"id\": \"q4\", \"type\": \"get_available_models\"}"} +{"dir":"out","raw":"{\"id\":\"q4\",\"type\":\"response\",\"command\":\"get_available_models\",\"success\":true,\"data\":{\"models\":[]}}"} +{"dir":"in","raw":"{\"id\": \"q5\", \"type\": \"prompt\", \"message\": \"Reply exactly SIGNED_OUT_CHECK.\"}"} +{"dir":"out","raw":"{\"id\":\"q5\",\"type\":\"response\",\"command\":\"prompt\",\"success\":false,\"error\":\"No API key found for the selected model.\\n\\nUse /login to log into a provider via OAuth or API key. See:\\n /Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/@earendil-works/pi-coding-agent/docs/providers.md\\n /Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/pi-1.0.4/node_modules/@earendil-works/pi-coding-agent/docs/models.md\"}"} +{"dir":"in","raw":"{\"id\": \"q6\", \"type\": \"get_state\"}"} +{"dir":"out","raw":"{\"id\":\"q6\",\"type\":\"response\",\"command\":\"get_state\",\"success\":true,\"data\":{\"model\":{\"id\":\"unknown\",\"name\":\"unknown\",\"api\":\"unknown\",\"provider\":\"unknown\",\"baseUrl\":\"\",\"reasoning\":false,\"input\":[],\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0},\"contextWindow\":0,\"maxTokens\":0},\"thinkingLevel\":\"off\",\"isStreaming\":false,\"isCompacting\":false,\"steeringMode\":\"one-at-a-time\",\"followUpMode\":\"one-at-a-time\",\"sessionFile\":\"/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/signed-out/sessions/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.jsonl\",\"sessionId\":\"00000000-0000-4000-8000-000000000013\",\"autoCompactionEnabled\":false,\"messageCount\":0,\"pendingMessageCount\":0}}"} diff --git a/src/main/pi/__fixtures__/steer-followup.jsonl b/src/main/pi/__fixtures__/steer-followup.jsonl new file mode 100644 index 00000000000..c747d395d65 --- /dev/null +++ b/src/main/pi/__fixtures__/steer-followup.jsonl @@ -0,0 +1,93 @@ +{"dir":"in","raw":"{\"id\": \"q1\", \"type\": \"get_state\"}"} +{"dir":"out","raw":"{\"id\":\"q1\",\"type\":\"response\",\"command\":\"get_state\",\"success\":true,\"data\":{\"model\":{\"id\":\"gpt-6.1-sol\",\"name\":\"GPT-6.1 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":10,\"cacheRead\":0.1,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":15,\"cacheRead\":0.2,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":null,\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},\"thinkingLevel\":\"medium\",\"isStreaming\":false,\"isCompacting\":false,\"steeringMode\":\"one-at-a-time\",\"followUpMode\":\"one-at-a-time\",\"sessionFile\":\"/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/steer-followup-r2/sessions/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.jsonl\",\"sessionId\":\"00000000-0000-4000-8000-000000000015\",\"autoCompactionEnabled\":true,\"messageCount\":0,\"pendingMessageCount\":0}}"} +{"dir":"in","raw":"{\"id\": \"q2\", \"type\": \"set_auto_retry\", \"enabled\": false}"} +{"dir":"out","raw":"{\"id\":\"q2\",\"type\":\"response\",\"command\":\"set_auto_retry\",\"success\":true}"} +{"dir":"in","raw":"{\"id\": \"q3\", \"type\": \"set_auto_compaction\", \"enabled\": false}"} +{"dir":"out","raw":"{\"id\":\"q3\",\"type\":\"response\",\"command\":\"set_auto_compaction\",\"success\":true}"} +{"dir":"in","raw":"{\"id\": \"q4\", \"type\": \"get_available_models\"}"} +{"dir":"out","raw":"{\"id\":\"q4\",\"type\":\"response\",\"command\":\"get_available_models\",\"success\":true,\"data\":{\"models\":[{\"id\":\"gpt-5.3-codex-spark\",\"name\":\"GPT-5.3 Codex Spark\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\"],\"cost\":{\"input\":1.75,\"output\":14,\"cacheRead\":0.175,\"cacheWrite\":0},\"contextWindow\":128000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true},\"type\":\"chat\"},{\"id\":\"gpt-5.5\",\"name\":\"GPT-5.5\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":5,\"output\":30,\"cacheRead\":0.5,\"cacheWrite\":0,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":10,\"output\":45,\"cacheRead\":1,\"cacheWrite\":0}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-5.6-luna\",\"name\":\"GPT-5.6 Luna\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":0.2,\"output\":1.2,\"cacheRead\":0.02,\"cacheWrite\":0.25,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":0.4,\"output\":1.8,\"cacheRead\":0.04,\"cacheWrite\":0.5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"max\":\"max\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-5.6-sol\",\"name\":\"GPT-5.6 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":4,\"output\":20,\"cacheRead\":0.4,\"cacheWrite\":5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":8,\"output\":30,\"cacheRead\":0.8,\"cacheWrite\":10}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"max\":\"max\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-5.6-terra\",\"name\":\"GPT-5.6 Terra\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":12,\"cacheRead\":0.2,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":18,\"cacheRead\":0.4,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"max\":\"max\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6-astra\",\"name\":\"GPT-6 Astra\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":10,\"output\":50,\"cacheRead\":1,\"cacheWrite\":12.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":20,\"output\":75,\"cacheRead\":2,\"cacheWrite\":25}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":null,\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6-luna\",\"name\":\"GPT-6 Luna\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":0.1,\"output\":0.5,\"cacheRead\":0.01,\"cacheWrite\":0.125,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":0.2,\"output\":0.75,\"cacheRead\":0.02,\"cacheWrite\":0.25}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":\"none\",\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6-sol\",\"name\":\"GPT-6 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":10,\"cacheRead\":0.2,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":15,\"cacheRead\":0.4,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":\"none\",\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6.1-sol\",\"name\":\"GPT-6.1 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":10,\"cacheRead\":0.1,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":15,\"cacheRead\":0.2,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":null,\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"}]}}"} +{"dir":"in","raw":"{\"id\": \"q5\", \"type\": \"set_model\", \"provider\": \"openai-codex\", \"modelId\": \"gpt-5.5\"}"} +{"dir":"out","raw":"{\"id\":\"q5\",\"type\":\"response\",\"command\":\"set_model\",\"success\":true,\"data\":{\"id\":\"gpt-5.5\",\"name\":\"GPT-5.5\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":5,\"output\":30,\"cacheRead\":0.5,\"cacheWrite\":0,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":10,\"output\":45,\"cacheRead\":1,\"cacheWrite\":0}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"}}"} +{"dir":"in","raw":"{\"id\": \"q6\", \"type\": \"set_thinking_level\", \"level\": \"off\"}"} +{"dir":"out","raw":"{\"type\":\"thinking_level_changed\",\"level\":\"off\"}"} +{"dir":"out","raw":"{\"id\":\"q6\",\"type\":\"response\",\"command\":\"set_thinking_level\",\"success\":true}"} +{"dir":"in","raw":"{\"id\": \"q7\", \"type\": \"prompt\", \"message\": \"Use bash to run exactly: printf capture-start; sleep 20; printf capture-finish. Do not background it. Then reply ORIGINAL_FINISHED.\"}"} +{"dir":"out","raw":"{\"id\":\"q7\",\"type\":\"response\",\"command\":\"prompt\",\"success\":true,\"data\":{\"disposition\":\"started\"}}"} +{"dir":"out","raw":"{\"type\":\"agent_start\"}"} +{"dir":"out","raw":"{\"type\":\"turn_start\"}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"system\",\"content\":\"\",\"sections\":{\"preamble\":\"You are a protocol capture assistant. Follow exact requests and use only the requested tools. Read and write only within this project. Never read credentials, home directories, or account details.\",\"cwd\":\"\\n/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/steer-followup-r2\\n\"},\"timestamp\":1791280240975,\"toolsAdded\":[{\"name\":\"read\",\"description\":\"Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to read (relative or absolute)\"},\"offset\":{\"type\":\"number\",\"description\":\"Line number to start reading from (1-indexed)\"},\"limit\":{\"type\":\"number\",\"description\":\"Maximum number of lines to read\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"bash\",\"description\":\"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.\",\"parameters\":{\"type\":\"object\",\"required\":[\"command\"],\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout\":{\"type\":\"number\",\"description\":\"Timeout in seconds (optional, no default timeout)\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"edit\",\"description\":\"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"edits\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to edit (relative or absolute)\"},\"edits\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"oldText\",\"newText\"],\"properties\":{\"oldText\":{\"type\":\"string\",\"description\":\"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.\"},\"newText\":{\"type\":\"string\",\"description\":\"Replacement text for this targeted edit.\"}}},\"description\":\"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"write\",\"description\":\"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"content\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to write (relative or absolute)\"},\"content\":{\"type\":\"string\",\"description\":\"Content to write to the file\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}}]}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"system\",\"content\":\"\",\"sections\":{\"preamble\":\"You are a protocol capture assistant. Follow exact requests and use only the requested tools. Read and write only within this project. Never read credentials, home directories, or account details.\",\"cwd\":\"\\n/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/steer-followup-r2\\n\"},\"timestamp\":1791280240975,\"toolsAdded\":[{\"name\":\"read\",\"description\":\"Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to read (relative or absolute)\"},\"offset\":{\"type\":\"number\",\"description\":\"Line number to start reading from (1-indexed)\"},\"limit\":{\"type\":\"number\",\"description\":\"Maximum number of lines to read\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"bash\",\"description\":\"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.\",\"parameters\":{\"type\":\"object\",\"required\":[\"command\"],\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout\":{\"type\":\"number\",\"description\":\"Timeout in seconds (optional, no default timeout)\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"edit\",\"description\":\"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"edits\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to edit (relative or absolute)\"},\"edits\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"oldText\",\"newText\"],\"properties\":{\"oldText\":{\"type\":\"string\",\"description\":\"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.\"},\"newText\":{\"type\":\"string\",\"description\":\"Replacement text for this targeted edit.\"}}},\"description\":\"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"write\",\"description\":\"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"content\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to write (relative or absolute)\"},\"content\":{\"type\":\"string\",\"description\":\"Content to write to the file\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}}]}}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use bash to run exactly: printf capture-start; sleep 20; printf capture-finish. Do not background it. Then reply ORIGINAL_FINISHED.\"}],\"timestamp\":1791280240975}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use bash to run exactly: printf capture-start; sleep 20; printf capture-finish. Do not background it. Then reply ORIGINAL_FINISHED.\"}],\"timestamp\":1791280240975}}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"assistant\",\"content\":[],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"pending\",\"timestamp\":1791280240987}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_start\",\"contentIndex\":0,\"id\":\"captured.id.000001.xxxxxxxxxx|captured.id.000002.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"{\\\"\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"command\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"\\\":\\\"\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"printf\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\" capture\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"-start\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\";\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\" sleep\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\" \"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"20\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\";\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\" printf\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\" capture\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"-f\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"inish\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"\\\",\\\"\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"timeout\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"\\\":\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"30\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"}\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_end\",\"contentIndex\":0,\"toolCall\":{\"type\":\"toolCall\",\"id\":\"captured.id.000001.xxxxxxxxxx|captured.id.000002.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"name\":\"bash\",\"arguments\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":30}}}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"toolCall\",\"id\":\"captured.id.000001.xxxxxxxxxx|captured.id.000002.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"name\":\"bash\",\"arguments\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":30}}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":606,\"output\":33,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":639,\"cost\":{\"input\":0.00303,\"output\":0.00099,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.00402}},\"stopReason\":\"toolUse\",\"timestamp\":1791280240987,\"responseId\":\"captured.id.000003.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"}}"} +{"dir":"out","raw":"{\"type\":\"tool_execution_start\",\"toolCallId\":\"captured.id.000001.xxxxxxxxxx|captured.id.000002.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"args\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":30}}"} +{"dir":"in","raw":"{\"id\": \"q8\", \"type\": \"prompt\", \"message\": \"After the running tool completes, reply STEER_CONSUMED instead of ORIGINAL_FINISHED.\", \"streamingBehavior\": \"steer\"}"} +{"dir":"in","raw":"{\"id\": \"q9\", \"type\": \"prompt\", \"message\": \"Then reply FOLLOWUP_CONSUMED on the next turn.\", \"streamingBehavior\": \"followUp\"}"} +{"dir":"out","raw":"{\"type\":\"tool_execution_update\",\"toolCallId\":\"captured.id.000001.xxxxxxxxxx|captured.id.000002.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"args\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":30},\"partialResult\":{\"content\":[]}}"} +{"dir":"out","raw":"{\"type\":\"queue_update\",\"steering\":[\"After the running tool completes, reply STEER_CONSUMED instead of ORIGINAL_FINISHED.\"],\"followUp\":[]}"} +{"dir":"out","raw":"{\"type\":\"queue_update\",\"steering\":[\"After the running tool completes, reply STEER_CONSUMED instead of ORIGINAL_FINISHED.\"],\"followUp\":[\"Then reply FOLLOWUP_CONSUMED on the next turn.\"]}"} +{"dir":"out","raw":"{\"id\":\"q8\",\"type\":\"response\",\"command\":\"prompt\",\"success\":true,\"data\":{\"disposition\":\"queued\"}}"} +{"dir":"out","raw":"{\"id\":\"q9\",\"type\":\"response\",\"command\":\"prompt\",\"success\":true,\"data\":{\"disposition\":\"queued\"}}"} +{"dir":"out","raw":"{\"type\":\"tool_execution_update\",\"toolCallId\":\"captured.id.000001.xxxxxxxxxx|captured.id.000002.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"args\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":30},\"partialResult\":{\"content\":[{\"type\":\"text\",\"text\":\"capture-start\"}],\"details\":{}}}"} +{"dir":"out","raw":"{\"type\":\"tool_execution_update\",\"toolCallId\":\"captured.id.000001.xxxxxxxxxx|captured.id.000002.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"args\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":30},\"partialResult\":{\"content\":[{\"type\":\"text\",\"text\":\"capture-startcapture-finish\"}],\"details\":{}}}"} +{"dir":"out","raw":"{\"type\":\"tool_execution_end\",\"toolCallId\":\"captured.id.000001.xxxxxxxxxx|captured.id.000002.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"capture-startcapture-finish\"}],\"structuredContent\":{\"output\":\"capture-startcapture-finish\",\"truncated\":false,\"exit_code\":0,\"wall_time_seconds\":20}},\"isError\":false}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"toolResult\",\"toolCallId\":\"captured.id.000001.xxxxxxxxxx|captured.id.000002.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"content\":[{\"type\":\"text\",\"text\":\"capture-startcapture-finish\"}],\"isError\":false,\"timestamp\":1791280270461}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"toolResult\",\"toolCallId\":\"captured.id.000001.xxxxxxxxxx|captured.id.000002.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"content\":[{\"type\":\"text\",\"text\":\"capture-startcapture-finish\"}],\"isError\":false,\"timestamp\":1791280270461}}"} +{"dir":"out","raw":"{\"type\":\"turn_end\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"toolCall\",\"id\":\"captured.id.000001.xxxxxxxxxx|captured.id.000002.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"name\":\"bash\",\"arguments\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":30}}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":606,\"output\":33,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":639,\"cost\":{\"input\":0.00303,\"output\":0.00099,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.00402}},\"stopReason\":\"toolUse\",\"timestamp\":1791280240987,\"responseId\":\"captured.id.000003.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"},\"toolResults\":[{\"role\":\"toolResult\",\"toolCallId\":\"captured.id.000001.xxxxxxxxxx|captured.id.000002.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"content\":[{\"type\":\"text\",\"text\":\"capture-startcapture-finish\"}],\"isError\":false,\"timestamp\":1791280270461}]}"} +{"dir":"out","raw":"{\"type\":\"turn_start\"}"} +{"dir":"out","raw":"{\"type\":\"queue_update\",\"steering\":[],\"followUp\":[\"Then reply FOLLOWUP_CONSUMED on the next turn.\"]}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"After the running tool completes, reply STEER_CONSUMED instead of ORIGINAL_FINISHED.\"}],\"timestamp\":1791280250446}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"After the running tool completes, reply STEER_CONSUMED instead of ORIGINAL_FINISHED.\"}],\"timestamp\":1791280250446}}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"assistant\",\"content\":[],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"pending\",\"timestamp\":1791280270464,\"responseId\":\"captured.id.000004.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_start\",\"contentIndex\":0}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"STE\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"ER\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"_CON\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"SUM\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"ED\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":677,\"output\":9,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":686,\"cost\":{\"input\":0.0033850000000000004,\"output\":0.00027,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.0036550000000000003}},\"assistantMessageEvent\":{\"type\":\"text_end\",\"contentIndex\":0,\"content\":\"STEER_CONSUMED\"}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"STEER_CONSUMED\",\"textSignature\":\"{\\\"v\\\":1,\\\"id\\\":\\\"captured.id.000005.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxx\\\",\\\"phase\\\":\\\"final_answer\\\"}\"}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":677,\"output\":9,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":686,\"cost\":{\"input\":0.0033850000000000004,\"output\":0.00027,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.0036550000000000003}},\"stopReason\":\"stop\",\"timestamp\":1791280270464,\"responseId\":\"captured.id.000004.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"}}"} +{"dir":"out","raw":"{\"type\":\"turn_end\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"STEER_CONSUMED\",\"textSignature\":\"{\\\"v\\\":1,\\\"id\\\":\\\"captured.id.000005.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxx\\\",\\\"phase\\\":\\\"final_answer\\\"}\"}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":677,\"output\":9,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":686,\"cost\":{\"input\":0.0033850000000000004,\"output\":0.00027,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.0036550000000000003}},\"stopReason\":\"stop\",\"timestamp\":1791280270464,\"responseId\":\"captured.id.000004.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"},\"toolResults\":[]}"} +{"dir":"out","raw":"{\"type\":\"turn_start\"}"} +{"dir":"out","raw":"{\"type\":\"queue_update\",\"steering\":[],\"followUp\":[]}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Then reply FOLLOWUP_CONSUMED on the next turn.\"}],\"timestamp\":1791280250446}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Then reply FOLLOWUP_CONSUMED on the next turn.\"}],\"timestamp\":1791280250446}}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"assistant\",\"content\":[],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"pending\",\"timestamp\":1791280279250}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_start\",\"contentIndex\":0}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"FOLLOW\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"UP\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"_CON\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"SUM\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_delta\",\"contentIndex\":0,\"delta\":\"ED\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"text_end\",\"contentIndex\":0,\"content\":\"FOLLOWUP_CONSUMED\"}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"FOLLOWUP_CONSUMED\",\"textSignature\":\"{\\\"v\\\":1,\\\"id\\\":\\\"captured.id.000006.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxx\\\",\\\"phase\\\":\\\"final_answer\\\"}\"}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":704,\"output\":9,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":713,\"cost\":{\"input\":0.00352,\"output\":0.00027,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.00379}},\"stopReason\":\"stop\",\"timestamp\":1791280279250,\"responseId\":\"captured.id.000007.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"}}"} +{"dir":"out","raw":"{\"type\":\"turn_end\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"FOLLOWUP_CONSUMED\",\"textSignature\":\"{\\\"v\\\":1,\\\"id\\\":\\\"captured.id.000006.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxx\\\",\\\"phase\\\":\\\"final_answer\\\"}\"}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":704,\"output\":9,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":713,\"cost\":{\"input\":0.00352,\"output\":0.00027,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.00379}},\"stopReason\":\"stop\",\"timestamp\":1791280279250,\"responseId\":\"captured.id.000007.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"},\"toolResults\":[]}"} +{"dir":"out","raw":"{\"type\":\"agent_end\",\"messages\":[{\"role\":\"system\",\"content\":\"\",\"sections\":{\"preamble\":\"You are a protocol capture assistant. Follow exact requests and use only the requested tools. Read and write only within this project. Never read credentials, home directories, or account details.\",\"cwd\":\"\\n/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/steer-followup-r2\\n\"},\"timestamp\":1791280240975,\"toolsAdded\":[{\"name\":\"read\",\"description\":\"Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to read (relative or absolute)\"},\"offset\":{\"type\":\"number\",\"description\":\"Line number to start reading from (1-indexed)\"},\"limit\":{\"type\":\"number\",\"description\":\"Maximum number of lines to read\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"bash\",\"description\":\"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.\",\"parameters\":{\"type\":\"object\",\"required\":[\"command\"],\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout\":{\"type\":\"number\",\"description\":\"Timeout in seconds (optional, no default timeout)\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"edit\",\"description\":\"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"edits\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to edit (relative or absolute)\"},\"edits\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"oldText\",\"newText\"],\"properties\":{\"oldText\":{\"type\":\"string\",\"description\":\"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.\"},\"newText\":{\"type\":\"string\",\"description\":\"Replacement text for this targeted edit.\"}}},\"description\":\"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"write\",\"description\":\"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"content\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to write (relative or absolute)\"},\"content\":{\"type\":\"string\",\"description\":\"Content to write to the file\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use bash to run exactly: printf capture-start; sleep 20; printf capture-finish. Do not background it. Then reply ORIGINAL_FINISHED.\"}],\"timestamp\":1791280240975},{\"role\":\"assistant\",\"content\":[{\"type\":\"toolCall\",\"id\":\"captured.id.000001.xxxxxxxxxx|captured.id.000002.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"name\":\"bash\",\"arguments\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":30}}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":606,\"output\":33,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":639,\"cost\":{\"input\":0.00303,\"output\":0.00099,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.00402}},\"stopReason\":\"toolUse\",\"timestamp\":1791280240987,\"responseId\":\"captured.id.000003.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"},{\"role\":\"toolResult\",\"toolCallId\":\"captured.id.000001.xxxxxxxxxx|captured.id.000002.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"content\":[{\"type\":\"text\",\"text\":\"capture-startcapture-finish\"}],\"isError\":false,\"timestamp\":1791280270461},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"After the running tool completes, reply STEER_CONSUMED instead of ORIGINAL_FINISHED.\"}],\"timestamp\":1791280250446},{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"STEER_CONSUMED\",\"textSignature\":\"{\\\"v\\\":1,\\\"id\\\":\\\"captured.id.000005.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxx\\\",\\\"phase\\\":\\\"final_answer\\\"}\"}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":677,\"output\":9,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":686,\"cost\":{\"input\":0.0033850000000000004,\"output\":0.00027,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.0036550000000000003}},\"stopReason\":\"stop\",\"timestamp\":1791280270464,\"responseId\":\"captured.id.000004.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Then reply FOLLOWUP_CONSUMED on the next turn.\"}],\"timestamp\":1791280250446},{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"FOLLOWUP_CONSUMED\",\"textSignature\":\"{\\\"v\\\":1,\\\"id\\\":\\\"captured.id.000006.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxx\\\",\\\"phase\\\":\\\"final_answer\\\"}\"}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":704,\"output\":9,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":713,\"cost\":{\"input\":0.00352,\"output\":0.00027,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.00379}},\"stopReason\":\"stop\",\"timestamp\":1791280279250,\"responseId\":\"captured.id.000007.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"}],\"willRetry\":false}"} +{"dir":"in","raw":"{\"id\": \"q10\", \"type\": \"get_state\"}"} +{"dir":"out","raw":"{\"type\":\"agent_settled\"}"} +{"dir":"out","raw":"{\"id\":\"q10\",\"type\":\"response\",\"command\":\"get_state\",\"success\":true,\"data\":{\"model\":{\"id\":\"gpt-5.5\",\"name\":\"GPT-5.5\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":5,\"output\":30,\"cacheRead\":0.5,\"cacheWrite\":0,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":10,\"output\":45,\"cacheRead\":1,\"cacheWrite\":0}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},\"thinkingLevel\":\"off\",\"isStreaming\":false,\"isCompacting\":false,\"steeringMode\":\"one-at-a-time\",\"followUpMode\":\"one-at-a-time\",\"sessionFile\":\"/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/steer-followup-r2/sessions/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.jsonl\",\"sessionId\":\"00000000-0000-4000-8000-000000000015\",\"autoCompactionEnabled\":false,\"messageCount\":8,\"pendingMessageCount\":0}}"} +{"dir":"in","raw":"{\"id\": \"q11\", \"type\": \"get_messages\"}"} +{"dir":"out","raw":"{\"id\":\"q11\",\"type\":\"response\",\"command\":\"get_messages\",\"success\":true,\"data\":{\"messages\":[{\"role\":\"system\",\"content\":\"\",\"sections\":{\"preamble\":\"You are a protocol capture assistant. Follow exact requests and use only the requested tools. Read and write only within this project. Never read credentials, home directories, or account details.\",\"cwd\":\"\\n/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/steer-followup-r2\\n\"},\"timestamp\":1791280240975,\"toolsAdded\":[{\"name\":\"read\",\"description\":\"Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to read (relative or absolute)\"},\"offset\":{\"type\":\"number\",\"description\":\"Line number to start reading from (1-indexed)\"},\"limit\":{\"type\":\"number\",\"description\":\"Maximum number of lines to read\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"bash\",\"description\":\"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.\",\"parameters\":{\"type\":\"object\",\"required\":[\"command\"],\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout\":{\"type\":\"number\",\"description\":\"Timeout in seconds (optional, no default timeout)\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"edit\",\"description\":\"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"edits\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to edit (relative or absolute)\"},\"edits\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"oldText\",\"newText\"],\"properties\":{\"oldText\":{\"type\":\"string\",\"description\":\"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.\"},\"newText\":{\"type\":\"string\",\"description\":\"Replacement text for this targeted edit.\"}}},\"description\":\"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"write\",\"description\":\"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"content\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to write (relative or absolute)\"},\"content\":{\"type\":\"string\",\"description\":\"Content to write to the file\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use bash to run exactly: printf capture-start; sleep 20; printf capture-finish. Do not background it. Then reply ORIGINAL_FINISHED.\"}],\"timestamp\":1791280240975},{\"role\":\"assistant\",\"content\":[{\"type\":\"toolCall\",\"id\":\"captured.id.000001.xxxxxxxxxx|captured.id.000002.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"name\":\"bash\",\"arguments\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":30}}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":606,\"output\":33,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":639,\"cost\":{\"input\":0.00303,\"output\":0.00099,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.00402}},\"stopReason\":\"toolUse\",\"timestamp\":1791280240987,\"responseId\":\"captured.id.000003.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"},{\"role\":\"toolResult\",\"toolCallId\":\"captured.id.000001.xxxxxxxxxx|captured.id.000002.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"content\":[{\"type\":\"text\",\"text\":\"capture-startcapture-finish\"}],\"isError\":false,\"timestamp\":1791280270461},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"After the running tool completes, reply STEER_CONSUMED instead of ORIGINAL_FINISHED.\"}],\"timestamp\":1791280250446},{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"STEER_CONSUMED\",\"textSignature\":\"{\\\"v\\\":1,\\\"id\\\":\\\"captured.id.000005.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxx\\\",\\\"phase\\\":\\\"final_answer\\\"}\"}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":677,\"output\":9,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":686,\"cost\":{\"input\":0.0033850000000000004,\"output\":0.00027,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.0036550000000000003}},\"stopReason\":\"stop\",\"timestamp\":1791280270464,\"responseId\":\"captured.id.000004.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Then reply FOLLOWUP_CONSUMED on the next turn.\"}],\"timestamp\":1791280250446},{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"FOLLOWUP_CONSUMED\",\"textSignature\":\"{\\\"v\\\":1,\\\"id\\\":\\\"captured.id.000006.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxx\\\",\\\"phase\\\":\\\"final_answer\\\"}\"}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":704,\"output\":9,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":713,\"cost\":{\"input\":0.00352,\"output\":0.00027,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.00379}},\"stopReason\":\"stop\",\"timestamp\":1791280279250,\"responseId\":\"captured.id.000007.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"}]}}"} diff --git a/src/main/pi/__fixtures__/stop-mid-tool.jsonl b/src/main/pi/__fixtures__/stop-mid-tool.jsonl new file mode 100644 index 00000000000..6f68cddfd61 --- /dev/null +++ b/src/main/pi/__fixtures__/stop-mid-tool.jsonl @@ -0,0 +1,64 @@ +{"dir":"in","raw":"{\"id\": \"q1\", \"type\": \"get_state\"}"} +{"dir":"out","raw":"{\"id\":\"q1\",\"type\":\"response\",\"command\":\"get_state\",\"success\":true,\"data\":{\"model\":{\"id\":\"gpt-6.1-sol\",\"name\":\"GPT-6.1 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":10,\"cacheRead\":0.1,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":15,\"cacheRead\":0.2,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":null,\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},\"thinkingLevel\":\"medium\",\"isStreaming\":false,\"isCompacting\":false,\"steeringMode\":\"one-at-a-time\",\"followUpMode\":\"one-at-a-time\",\"sessionFile\":\"/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/stop-tool-bun/sessions/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.jsonl\",\"sessionId\":\"00000000-0000-4000-8000-000000000016\",\"autoCompactionEnabled\":true,\"messageCount\":0,\"pendingMessageCount\":0}}"} +{"dir":"in","raw":"{\"id\": \"q2\", \"type\": \"set_auto_retry\", \"enabled\": false}"} +{"dir":"out","raw":"{\"id\":\"q2\",\"type\":\"response\",\"command\":\"set_auto_retry\",\"success\":true}"} +{"dir":"in","raw":"{\"id\": \"q3\", \"type\": \"set_auto_compaction\", \"enabled\": false}"} +{"dir":"out","raw":"{\"id\":\"q3\",\"type\":\"response\",\"command\":\"set_auto_compaction\",\"success\":true}"} +{"dir":"in","raw":"{\"id\": \"q4\", \"type\": \"get_available_models\"}"} +{"dir":"out","raw":"{\"id\":\"q4\",\"type\":\"response\",\"command\":\"get_available_models\",\"success\":true,\"data\":{\"models\":[{\"id\":\"gpt-5.3-codex-spark\",\"name\":\"GPT-5.3 Codex Spark\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\"],\"cost\":{\"input\":1.75,\"output\":14,\"cacheRead\":0.175,\"cacheWrite\":0},\"contextWindow\":128000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true},\"type\":\"chat\"},{\"id\":\"gpt-5.5\",\"name\":\"GPT-5.5\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":5,\"output\":30,\"cacheRead\":0.5,\"cacheWrite\":0,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":10,\"output\":45,\"cacheRead\":1,\"cacheWrite\":0}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-5.6-luna\",\"name\":\"GPT-5.6 Luna\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":0.2,\"output\":1.2,\"cacheRead\":0.02,\"cacheWrite\":0.25,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":0.4,\"output\":1.8,\"cacheRead\":0.04,\"cacheWrite\":0.5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"max\":\"max\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-5.6-sol\",\"name\":\"GPT-5.6 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":4,\"output\":20,\"cacheRead\":0.4,\"cacheWrite\":5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":8,\"output\":30,\"cacheRead\":0.8,\"cacheWrite\":10}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"max\":\"max\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-5.6-terra\",\"name\":\"GPT-5.6 Terra\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":12,\"cacheRead\":0.2,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":18,\"cacheRead\":0.4,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"max\":\"max\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6-astra\",\"name\":\"GPT-6 Astra\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":10,\"output\":50,\"cacheRead\":1,\"cacheWrite\":12.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":20,\"output\":75,\"cacheRead\":2,\"cacheWrite\":25}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":null,\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6-luna\",\"name\":\"GPT-6 Luna\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":0.1,\"output\":0.5,\"cacheRead\":0.01,\"cacheWrite\":0.125,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":0.2,\"output\":0.75,\"cacheRead\":0.02,\"cacheWrite\":0.25}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":\"none\",\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6-sol\",\"name\":\"GPT-6 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":10,\"cacheRead\":0.2,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":15,\"cacheRead\":0.4,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":\"none\",\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},{\"id\":\"gpt-6.1-sol\",\"name\":\"GPT-6.1 Sol\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":2,\"output\":10,\"cacheRead\":0.1,\"cacheWrite\":2.5,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":4,\"output\":15,\"cacheRead\":0.2,\"cacheWrite\":5}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"off\":null,\"minimal\":\"low\",\"low\":\"low\",\"medium\":\"medium\",\"high\":\"high\",\"xhigh\":\"xhigh\",\"max\":\"max\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsAdditionalTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"}]}}"} +{"dir":"in","raw":"{\"id\": \"q5\", \"type\": \"set_model\", \"provider\": \"openai-codex\", \"modelId\": \"gpt-5.5\"}"} +{"dir":"out","raw":"{\"id\":\"q5\",\"type\":\"response\",\"command\":\"set_model\",\"success\":true,\"data\":{\"id\":\"gpt-5.5\",\"name\":\"GPT-5.5\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":5,\"output\":30,\"cacheRead\":0.5,\"cacheWrite\":0,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":10,\"output\":45,\"cacheRead\":1,\"cacheWrite\":0}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"}}"} +{"dir":"in","raw":"{\"id\": \"q6\", \"type\": \"set_thinking_level\", \"level\": \"off\"}"} +{"dir":"out","raw":"{\"type\":\"thinking_level_changed\",\"level\":\"off\"}"} +{"dir":"out","raw":"{\"id\":\"q6\",\"type\":\"response\",\"command\":\"set_thinking_level\",\"success\":true}"} +{"dir":"in","raw":"{\"id\": \"q7\", \"type\": \"prompt\", \"message\": \"Use bash to run exactly: printf capture-start; sleep 20; printf capture-finish. Do not background it. Then reply ORIGINAL_FINISHED.\"}"} +{"dir":"out","raw":"{\"id\":\"q7\",\"type\":\"response\",\"command\":\"prompt\",\"success\":true,\"data\":{\"disposition\":\"started\"}}"} +{"dir":"out","raw":"{\"type\":\"agent_start\"}"} +{"dir":"out","raw":"{\"type\":\"turn_start\"}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"system\",\"content\":\"\",\"sections\":{\"preamble\":\"You are a protocol capture assistant. Follow exact requests and use only the requested tools. Read and write only within this project. Never read credentials, home directories, or account details.\",\"cwd\":\"\\n/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/stop-tool-bun\\n\"},\"timestamp\":1791279790624,\"toolsAdded\":[{\"name\":\"read\",\"description\":\"Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to read (relative or absolute)\"},\"offset\":{\"type\":\"number\",\"description\":\"Line number to start reading from (1-indexed)\"},\"limit\":{\"type\":\"number\",\"description\":\"Maximum number of lines to read\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"bash\",\"description\":\"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.\",\"parameters\":{\"type\":\"object\",\"required\":[\"command\"],\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout\":{\"type\":\"number\",\"description\":\"Timeout in seconds (optional, no default timeout)\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"edit\",\"description\":\"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"edits\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to edit (relative or absolute)\"},\"edits\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"oldText\",\"newText\"],\"properties\":{\"oldText\":{\"type\":\"string\",\"description\":\"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.\"},\"newText\":{\"type\":\"string\",\"description\":\"Replacement text for this targeted edit.\"}}},\"description\":\"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"write\",\"description\":\"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"content\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to write (relative or absolute)\"},\"content\":{\"type\":\"string\",\"description\":\"Content to write to the file\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}}]}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"system\",\"content\":\"\",\"sections\":{\"preamble\":\"You are a protocol capture assistant. Follow exact requests and use only the requested tools. Read and write only within this project. Never read credentials, home directories, or account details.\",\"cwd\":\"\\n/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/stop-tool-bun\\n\"},\"timestamp\":1791279790624,\"toolsAdded\":[{\"name\":\"read\",\"description\":\"Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to read (relative or absolute)\"},\"offset\":{\"type\":\"number\",\"description\":\"Line number to start reading from (1-indexed)\"},\"limit\":{\"type\":\"number\",\"description\":\"Maximum number of lines to read\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"bash\",\"description\":\"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.\",\"parameters\":{\"type\":\"object\",\"required\":[\"command\"],\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout\":{\"type\":\"number\",\"description\":\"Timeout in seconds (optional, no default timeout)\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"edit\",\"description\":\"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"edits\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to edit (relative or absolute)\"},\"edits\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"oldText\",\"newText\"],\"properties\":{\"oldText\":{\"type\":\"string\",\"description\":\"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.\"},\"newText\":{\"type\":\"string\",\"description\":\"Replacement text for this targeted edit.\"}}},\"description\":\"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"write\",\"description\":\"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"content\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to write (relative or absolute)\"},\"content\":{\"type\":\"string\",\"description\":\"Content to write to the file\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}}]}}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use bash to run exactly: printf capture-start; sleep 20; printf capture-finish. Do not background it. Then reply ORIGINAL_FINISHED.\"}],\"timestamp\":1791279790624}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use bash to run exactly: printf capture-start; sleep 20; printf capture-finish. Do not background it. Then reply ORIGINAL_FINISHED.\"}],\"timestamp\":1791279790624}}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"assistant\",\"content\":[],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"pending\",\"timestamp\":1791279790635}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_start\",\"contentIndex\":0,\"id\":\"captured.id.000008.xxxxxxxxxxxxxxxxxx|captured.id.000009.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"{\\\"\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"command\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"\\\":\\\"\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"printf\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\" capture\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"-start\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\";\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\" sleep\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\" \"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"20\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\";\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\" printf\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\" capture\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"-f\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"inish\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"\\\",\\\"\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"timeout\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"\\\":\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"25\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_delta\",\"contentIndex\":0,\"delta\":\"}\"}}"} +{"dir":"out","raw":"{\"type\":\"message_update\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"assistantMessageEvent\":{\"type\":\"toolcall_end\",\"contentIndex\":0,\"toolCall\":{\"type\":\"toolCall\",\"id\":\"captured.id.000008.xxxxxxxxxxxxxxxxxx|captured.id.000009.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"name\":\"bash\",\"arguments\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":25}}}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"toolCall\",\"id\":\"captured.id.000008.xxxxxxxxxxxxxxxxxx|captured.id.000009.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"name\":\"bash\",\"arguments\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":25}}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":604,\"output\":33,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":637,\"cost\":{\"input\":0.00302,\"output\":0.00099,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.00401}},\"stopReason\":\"toolUse\",\"timestamp\":1791279790635,\"responseId\":\"captured.id.000010.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"}}"} +{"dir":"out","raw":"{\"type\":\"tool_execution_start\",\"toolCallId\":\"captured.id.000008.xxxxxxxxxxxxxxxxxx|captured.id.000009.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"args\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":25}}"} +{"dir":"out","raw":"{\"type\":\"tool_execution_update\",\"toolCallId\":\"captured.id.000008.xxxxxxxxxxxxxxxxxx|captured.id.000009.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"args\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":25},\"partialResult\":{\"content\":[]}}"} +{"dir":"out","raw":"{\"type\":\"tool_execution_update\",\"toolCallId\":\"captured.id.000008.xxxxxxxxxxxxxxxxxx|captured.id.000009.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"args\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":25},\"partialResult\":{\"content\":[{\"type\":\"text\",\"text\":\"capture-start\"}],\"details\":{}}}"} +{"dir":"in","raw":"{\"id\": \"q8\", \"type\": \"abort\"}"} +{"dir":"out","raw":"{\"type\":\"tool_execution_end\",\"toolCallId\":\"captured.id.000008.xxxxxxxxxxxxxxxxxx|captured.id.000009.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"capture-start\\n\\nCommand aborted\"}],\"details\":{}},\"isError\":true}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"toolResult\",\"toolCallId\":\"captured.id.000008.xxxxxxxxxxxxxxxxxx|captured.id.000009.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"content\":[{\"type\":\"text\",\"text\":\"capture-start\\n\\nCommand aborted\"}],\"details\":{},\"isError\":true,\"timestamp\":1791279805864}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"toolResult\",\"toolCallId\":\"captured.id.000008.xxxxxxxxxxxxxxxxxx|captured.id.000009.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"content\":[{\"type\":\"text\",\"text\":\"capture-start\\n\\nCommand aborted\"}],\"details\":{},\"isError\":true,\"timestamp\":1791279805864}}"} +{"dir":"out","raw":"{\"type\":\"turn_end\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"toolCall\",\"id\":\"captured.id.000008.xxxxxxxxxxxxxxxxxx|captured.id.000009.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"name\":\"bash\",\"arguments\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":25}}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":604,\"output\":33,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":637,\"cost\":{\"input\":0.00302,\"output\":0.00099,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.00401}},\"stopReason\":\"toolUse\",\"timestamp\":1791279790635,\"responseId\":\"captured.id.000010.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"},\"toolResults\":[{\"role\":\"toolResult\",\"toolCallId\":\"captured.id.000008.xxxxxxxxxxxxxxxxxx|captured.id.000009.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"content\":[{\"type\":\"text\",\"text\":\"capture-start\\n\\nCommand aborted\"}],\"details\":{},\"isError\":true,\"timestamp\":1791279805864}]}"} +{"dir":"out","raw":"{\"type\":\"turn_start\"}"} +{"dir":"out","raw":"{\"type\":\"message_start\",\"message\":{\"role\":\"assistant\",\"content\":[],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"error\",\"errorMessage\":\"The operation was aborted.\",\"timestamp\":1791279805865,\"thinkingLevel\":\"off\"}}"} +{"dir":"out","raw":"{\"type\":\"message_end\",\"message\":{\"role\":\"assistant\",\"content\":[],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"error\",\"errorMessage\":\"The operation was aborted.\",\"timestamp\":1791279805865,\"thinkingLevel\":\"off\"}}"} +{"dir":"out","raw":"{\"type\":\"turn_end\",\"message\":{\"role\":\"assistant\",\"content\":[],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"error\",\"errorMessage\":\"The operation was aborted.\",\"timestamp\":1791279805865,\"thinkingLevel\":\"off\"},\"toolResults\":[]}"} +{"dir":"out","raw":"{\"type\":\"agent_end\",\"messages\":[{\"role\":\"system\",\"content\":\"\",\"sections\":{\"preamble\":\"You are a protocol capture assistant. Follow exact requests and use only the requested tools. Read and write only within this project. Never read credentials, home directories, or account details.\",\"cwd\":\"\\n/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/stop-tool-bun\\n\"},\"timestamp\":1791279790624,\"toolsAdded\":[{\"name\":\"read\",\"description\":\"Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to read (relative or absolute)\"},\"offset\":{\"type\":\"number\",\"description\":\"Line number to start reading from (1-indexed)\"},\"limit\":{\"type\":\"number\",\"description\":\"Maximum number of lines to read\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"bash\",\"description\":\"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.\",\"parameters\":{\"type\":\"object\",\"required\":[\"command\"],\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout\":{\"type\":\"number\",\"description\":\"Timeout in seconds (optional, no default timeout)\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"edit\",\"description\":\"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"edits\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to edit (relative or absolute)\"},\"edits\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"oldText\",\"newText\"],\"properties\":{\"oldText\":{\"type\":\"string\",\"description\":\"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.\"},\"newText\":{\"type\":\"string\",\"description\":\"Replacement text for this targeted edit.\"}}},\"description\":\"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"write\",\"description\":\"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"content\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to write (relative or absolute)\"},\"content\":{\"type\":\"string\",\"description\":\"Content to write to the file\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use bash to run exactly: printf capture-start; sleep 20; printf capture-finish. Do not background it. Then reply ORIGINAL_FINISHED.\"}],\"timestamp\":1791279790624},{\"role\":\"assistant\",\"content\":[{\"type\":\"toolCall\",\"id\":\"captured.id.000008.xxxxxxxxxxxxxxxxxx|captured.id.000009.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"name\":\"bash\",\"arguments\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":25}}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":604,\"output\":33,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":637,\"cost\":{\"input\":0.00302,\"output\":0.00099,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.00401}},\"stopReason\":\"toolUse\",\"timestamp\":1791279790635,\"responseId\":\"captured.id.000010.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"},{\"role\":\"toolResult\",\"toolCallId\":\"captured.id.000008.xxxxxxxxxxxxxxxxxx|captured.id.000009.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"content\":[{\"type\":\"text\",\"text\":\"capture-start\\n\\nCommand aborted\"}],\"details\":{},\"isError\":true,\"timestamp\":1791279805864},{\"role\":\"assistant\",\"content\":[],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"error\",\"errorMessage\":\"The operation was aborted.\",\"timestamp\":1791279805865,\"thinkingLevel\":\"off\"}],\"willRetry\":false}"} +{"dir":"out","raw":"{\"type\":\"agent_settled\"}"} +{"dir":"out","raw":"{\"id\":\"q8\",\"type\":\"response\",\"command\":\"abort\",\"success\":true}"} +{"dir":"in","raw":"{\"id\": \"q9\", \"type\": \"get_state\"}"} +{"dir":"out","raw":"{\"id\":\"q9\",\"type\":\"response\",\"command\":\"get_state\",\"success\":true,\"data\":{\"model\":{\"id\":\"gpt-5.5\",\"name\":\"GPT-5.5\",\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"baseUrl\":\"https://chatgpt.com/backend-api\",\"reasoning\":true,\"input\":[\"text\",\"image\"],\"cost\":{\"input\":5,\"output\":30,\"cacheRead\":0.5,\"cacheWrite\":0,\"tiers\":[{\"inputTokensAbove\":272000,\"input\":10,\"output\":45,\"cacheRead\":1,\"cacheWrite\":0}]},\"contextWindow\":272000,\"maxTokens\":128000,\"thinkingLevelMap\":{\"xhigh\":\"xhigh\",\"minimal\":\"low\"},\"compat\":{\"supportsOpenAIGrammarTools\":true,\"supportsToolSearch\":true,\"supportsMidConvoSystemMessages\":true},\"inputLimits\":{\"images\":{\"resize\":{\"maxWidth\":2000,\"maxHeight\":2000,\"maxBytes\":4718592,\"jpegQuality\":80}}},\"type\":\"chat\"},\"thinkingLevel\":\"off\",\"isStreaming\":false,\"isCompacting\":false,\"steeringMode\":\"one-at-a-time\",\"followUpMode\":\"one-at-a-time\",\"sessionFile\":\"/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/stop-tool-bun/sessions/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.jsonl\",\"sessionId\":\"00000000-0000-4000-8000-000000000016\",\"autoCompactionEnabled\":false,\"messageCount\":5,\"pendingMessageCount\":0}}"} +{"dir":"in","raw":"{\"id\": \"q10\", \"type\": \"get_messages\"}"} +{"dir":"out","raw":"{\"id\":\"q10\",\"type\":\"response\",\"command\":\"get_messages\",\"success\":true,\"data\":{\"messages\":[{\"role\":\"system\",\"content\":\"\",\"sections\":{\"preamble\":\"You are a protocol capture assistant. Follow exact requests and use only the requested tools. Read and write only within this project. Never read credentials, home directories, or account details.\",\"cwd\":\"\\n/Users/xxxxxxxxxxxx/orca-qa/task_7daea3cb4c53/projects/pi/stop-tool-bun\\n\"},\"timestamp\":1791279790624,\"toolsAdded\":[{\"name\":\"read\",\"description\":\"Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to read (relative or absolute)\"},\"offset\":{\"type\":\"number\",\"description\":\"Line number to start reading from (1-indexed)\"},\"limit\":{\"type\":\"number\",\"description\":\"Maximum number of lines to read\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"bash\",\"description\":\"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.\",\"parameters\":{\"type\":\"object\",\"required\":[\"command\"],\"properties\":{\"command\":{\"type\":\"string\",\"description\":\"Shell command to execute\"},\"timeout\":{\"type\":\"number\",\"description\":\"Timeout in seconds (optional, no default timeout)\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"edit\",\"description\":\"Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"edits\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to edit (relative or absolute)\"},\"edits\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"oldText\",\"newText\"],\"properties\":{\"oldText\":{\"type\":\"string\",\"description\":\"Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.\"},\"newText\":{\"type\":\"string\",\"description\":\"Replacement text for this targeted edit.\"}}},\"description\":\"One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}},{\"name\":\"write\",\"description\":\"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.\",\"parameters\":{\"type\":\"object\",\"required\":[\"path\",\"content\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the file to write (relative or absolute)\"},\"content\":{\"type\":\"string\",\"description\":\"Content to write to the file\"}}},\"constrainedSampling\":{\"type\":\"json_schema\",\"strict\":\"prefer\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use bash to run exactly: printf capture-start; sleep 20; printf capture-finish. Do not background it. Then reply ORIGINAL_FINISHED.\"}],\"timestamp\":1791279790624},{\"role\":\"assistant\",\"content\":[{\"type\":\"toolCall\",\"id\":\"captured.id.000008.xxxxxxxxxxxxxxxxxx|captured.id.000009.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"name\":\"bash\",\"arguments\":{\"command\":\"printf capture-start; sleep 20; printf capture-finish\",\"timeout\":25}}],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":604,\"output\":33,\"cacheRead\":0,\"cacheWrite\":0,\"reasoning\":0,\"totalTokens\":637,\"cost\":{\"input\":0.00302,\"output\":0.00099,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0.00401}},\"stopReason\":\"toolUse\",\"timestamp\":1791279790635,\"responseId\":\"captured.id.000010.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxx\",\"rawStopReason\":\"completed\",\"thinkingLevel\":\"off\"},{\"role\":\"toolResult\",\"toolCallId\":\"captured.id.000008.xxxxxxxxxxxxxxxxxx|captured.id.000009.xxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxx\",\"toolName\":\"bash\",\"content\":[{\"type\":\"text\",\"text\":\"capture-start\\n\\nCommand aborted\"}],\"details\":{},\"isError\":true,\"timestamp\":1791279805864},{\"role\":\"assistant\",\"content\":[],\"api\":\"openai-codex-responses\",\"provider\":\"openai-codex\",\"model\":\"gpt-5.5\",\"usage\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":0,\"cost\":{\"input\":0,\"output\":0,\"cacheRead\":0,\"cacheWrite\":0,\"total\":0}},\"stopReason\":\"error\",\"errorMessage\":\"The operation was aborted.\",\"timestamp\":1791279805865,\"thinkingLevel\":\"off\"}]}}"} diff --git a/src/main/pi/rpc-agent-definition.ts b/src/main/pi/rpc-agent-definition.ts new file mode 100644 index 00000000000..bbbd2d8429a --- /dev/null +++ b/src/main/pi/rpc-agent-definition.ts @@ -0,0 +1,21 @@ +import type { DirectoryAccountAgentDefinition } from '../native-chat/agent-session-wire/structured-agent-definition' + +export const PI_RPC_AGENT: DirectoryAccountAgentDefinition = { + agent: 'pi', + handleTransport: 'jsonl-rpc', + accountHomeVariable: 'PI_CODING_AGENT_DIR', + capabilities: { + rewind: false, + compact: true, + threadGoal: false, + contextUsage: true, + imagePrompts: true, + steering: 'inject', + approvalEnforcement: 'provider' + }, + restingOptions: { + acceptsKey: (key) => key === 'model' || key === 'effort', + fallbackModels: () => null, + effortDefaultsToModel: false + } +} diff --git a/src/main/pi/rpc-capture-replay.test.ts b/src/main/pi/rpc-capture-replay.test.ts new file mode 100644 index 00000000000..a216583e821 --- /dev/null +++ b/src/main/pi/rpc-capture-replay.test.ts @@ -0,0 +1,155 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import { JsonlRpcTimelineLane } from '../jsonl-rpc/timeline-lane' +import { PiRpcTurns } from './rpc-turns' +import { piRpcDialogPresentation } from './rpc-extension-dialogs' +import { + closeProviderTimelineRigs, + openProviderTimelineRig +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' + +const envelope = z.object({ dir: z.enum(['in', 'out']), raw: z.string() }) +const record = z.looseObject({ type: z.string() }) +function capture(name: string) { + return readFileSync(join(import.meta.dirname, '__fixtures__', `${name}.jsonl`), 'utf8') + .split('\n') + .filter(Boolean) + .map((line) => { + const parsed = envelope.parse(JSON.parse(line)) + return { dir: parsed.dir, frame: record.parse(JSON.parse(parsed.raw)) } + }) +} +const disposals: (() => void)[] = [] +afterEach(async () => { + disposals.splice(0).forEach((dispose) => dispose()) + await closeProviderTimelineRigs() + vi.useRealTimers() +}) + +async function replay(name: string) { + const frames = capture(name) + const states = frames.filter( + ({ dir, frame }) => dir === 'out' && frame.type === 'response' && frame.command === 'get_state' + ) + const stats = frames.findLast( + ({ dir, frame }) => + dir === 'out' && frame.type === 'response' && frame.command === 'get_session_stats' + )?.frame.data + const rig = await openProviderTimelineRig({ agent: 'pi', sessionId: 'session-timeline' }) + const accepted = vi.fn(), + failed = vi.fn(), + idle = vi.fn(), + settled = vi.fn() + const lane = new JsonlRpcTimelineLane({ + sink: rig.sink, + sessionId: 'session-timeline', + agent: 'pi', + generation: 'gen-pi', + namespace: 'pi-session', + pauseReading: vi.fn(), + resumeReading: vi.fn(), + onInputAccepted: accepted, + onFailed: failed + }) + const turns = new PiRpcTurns({ + lane, + generation: 'gen-pi', + send: vi.fn(async () => {}), + request: vi.fn(async (command: string) => + command === 'get_state' ? states.at(-1)?.frame.data : stats + ), + settled, + failed, + idle + }) + disposals.push(() => { + turns.end() + lane.dispose() + }) + let sequence = 0 + for (const { dir, frame } of frames) { + if (dir === 'in' && frame.type === 'prompt') { + await turns.submit(`send-${++sequence}`, 100, frame) + } + if (dir === 'in' && frame.type === 'abort') { + turns.stop() + } + if (dir === 'out' && (frame.type !== 'response' || frame.command === 'prompt')) { + turns.receive(frame) + } + } + await new Promise((resolve) => setImmediate(resolve)) + lane.flush() + const rows = await rig.rows() + return { rows, turns: await rig.turns(), accepted, failed, idle, settled } +} + +describe('scrubbed Pi 1.0.4 RPC captures', () => { + it('records tool output, streamed text, and all three consumed inputs in one settled run', async () => { + const result = await replay('steer-followup') + expect(result.failed).not.toHaveBeenCalled() + expect(result.accepted.mock.calls.map(([id]) => id)).toEqual(['send-1', 'send-2', 'send-3']) + expect(result.turns).toHaveLength(1) + expect(result.turns[0]?.outcome).toBe('success') + expect( + result.rows.some((row) => row.body.kind === 'tool-call' && row.body.state === 'completed') + ).toBe(true) + const texts = result.rows.flatMap((row) => + row.body.kind === 'message' + ? row.body.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])) + : [] + ) + expect(texts.join('\n')).toContain('STEER_CONSUMED') + expect(texts.join('\n')).toContain('FOLLOWUP_CONSUMED') + }) + it.each(['provider-fetch-error', 'provider-socket-error'])( + 'marks captured %s as failure with the provider diagnostic', + async (name) => { + const result = await replay(name) + expect(result.failed).not.toHaveBeenCalled() + expect(result.turns[0]?.outcome).toBe('failure') + expect( + result.rows.filter( + (row) => row.body.kind === 'status' && row.body.failure?.kind === 'providerRejected' + ) + ).toHaveLength(1) + } + ) + it('treats the captured abort error as cancellation', async () => { + const result = await replay('stop-mid-tool') + expect(result.failed).not.toHaveBeenCalled() + expect(result.turns[0]?.outcome).toBe('cancellation') + expect(result.rows.some((row) => row.body.kind === 'status' && row.body.failure)).toBe(false) + }) + it('settles a handled extension command with no agent events', async () => { + const result = await replay('command-only') + expect(result.failed).not.toHaveBeenCalled() + expect(result.turns[0]?.outcome).toBe('success') + expect(result.accepted).toHaveBeenCalledWith('send-1') + }) + it('preserves empty answers and literal Unicode separators in captured dialogs', () => { + const dialogs = capture('dialogs').flatMap(({ dir, frame }) => + dir === 'out' && frame.type === 'extension_ui_request' ? [frame] : [] + ) + const input = piRpcDialogPresentation(dialogs.find((frame) => frame.method === 'input')) + const editor = piRpcDialogPresentation(dialogs.find((frame) => frame.method === 'editor')) + expect( + input?.reply({ + kind: 'answers', + answers: [{ questionId: String(input.id), optionIds: [], other: '' }] + }) + ).toEqual({ value: '' }) + const body = editor?.body + expect(body?.kind === 'question' && body.freeTextInput?.initialValue).toContain('\n') + expect( + editor?.reply({ + kind: 'answers', + answers: [ + { questionId: String(editor.id), optionIds: [], other: 'first\u2028second\u2029last' } + ] + }) + ).toEqual({ value: 'first\u2028second\u2029last' }) + }) +}) diff --git a/src/main/pi/rpc-compaction.ts b/src/main/pi/rpc-compaction.ts new file mode 100644 index 00000000000..728c5c604d1 --- /dev/null +++ b/src/main/pi/rpc-compaction.ts @@ -0,0 +1,45 @@ +import { agentSessionFailureFact, providerDiagnostic } from '../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' +import type { + AgentSessionCommandAdmission, + StructuredAgentSessionCommandRun +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { JsonlRpcResponseError } from '../jsonl-rpc/peer' +import type { PiRpcSession } from './rpc-session' + +export async function compactPiRpcSession( + session: PiRpcSession, + command: StructuredAgentSessionCommandRun +): Promise { + session.turns.beginCommand(command) + try { + await session.connection.request('compact', {}, { timeoutMs: null }) + session.turns.commandCompleted() + return { state: 'accepted', providerIdentity: null } + } catch (error) { + if (!(error instanceof JsonlRpcResponseError)) { + throw error + } + if (error.message === 'Nothing to compact (session too small)') { + session.lane.apply([ + { + type: 'item.close', + item: `compact-noop:${command.turnId}`, + body: { kind: 'status', tone: 'warning', text: error.message } + } + ]) + session.turns.commandCompleted() + return { state: 'accepted', providerIdentity: null } + } + session.turns.commandRejected() + return { + state: 'rejected', + ...agentSessionFailureWords( + agentSessionFailureFact('providerRejected', { + detail: providerDiagnostic(error.message, 'person') + }), + { agentName: 'Pi', surface: 'rejection' } + ) + } + } +} diff --git a/src/main/pi/rpc-context-usage.ts b/src/main/pi/rpc-context-usage.ts new file mode 100644 index 00000000000..9f9b19eaaed --- /dev/null +++ b/src/main/pi/rpc-context-usage.ts @@ -0,0 +1,108 @@ +import { z } from 'zod' +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import { piRpcUsageSchema, type PiRpcModel } from './rpc-protocol' + +const statsSchema = z.object({ + contextUsage: z + .object({ + tokens: z.number().nonnegative().nullable().optional(), + contextWindow: z.number().positive(), + percent: z.number().nullable().optional() + }) + .optional() +}) + +export class PiRpcContextUsage { + private model?: PiRpcModel + private compactedTokens?: number + + compacting(): ProviderTimelineEvent[] { + this.compactedTokens = undefined + return [{ type: 'context.usage', usage: { used: { kind: 'unknown', capturedAt: Date.now() } } }] + } + + compacted(result: unknown): void { + const parsed = z.object({ estimatedTokensAfter: z.number().nonnegative() }).safeParse(result) + this.compactedTokens = parsed.success ? parsed.data.estimatedTokensAfter : undefined + } + + setModel(model: PiRpcModel | undefined, at: number): ProviderTimelineEvent[] { + const changed = model?.id !== this.model?.id || model?.provider !== this.model?.provider + this.model = model + return model?.contextWindow && model.contextWindow > 0 + ? [ + { + type: 'context.usage', + usage: { + window: { tokens: model.contextWindow, capturedAt: at }, + ...(changed ? { used: { kind: 'unknown' as const, capturedAt: at } } : {}) + } + } + ] + : [] + } + + live(value: unknown, at: number): ProviderTimelineEvent[] { + const usage = piRpcUsageSchema.safeParse(value) + if (!usage.success || Object.values(usage.data).every((tokens) => tokens === 0)) { + return [] + } + this.compactedTokens = undefined + return [ + { + type: 'context.usage', + usage: { + used: { + kind: 'estimate', + usage: { + inputTokens: usage.data.input, + outputTokens: usage.data.output, + cacheCreationInputTokens: usage.data.cacheWrite, + cacheReadInputTokens: usage.data.cacheRead + }, + capturedAt: at + } + } + } + ] + } + + stats(value: unknown, at: number): ProviderTimelineEvent[] { + const parsed = statsSchema.safeParse(value) + const context = parsed.success ? parsed.data.contextUsage : undefined + if (!context) { + return [] + } + return [ + { + type: 'context.usage', + usage: { + window: { tokens: context.contextWindow, capturedAt: at }, + used: + context.tokens == null + ? this.compactedTokens === undefined + ? { kind: 'unknown', capturedAt: at } + : { + kind: 'estimate', + usage: { + inputTokens: this.compactedTokens, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0 + }, + capturedAt: at + } + : { + kind: 'report', + model: this.model ? `${this.model.provider}/${this.model.id}` : 'unknown', + usedTokens: context.tokens, + windowTokens: context.contextWindow, + percentage: context.percent ?? (context.tokens / context.contextWindow) * 100, + categories: [], + capturedAt: at + } + } + } + ] + } +} diff --git a/src/main/pi/rpc-dialog-callbacks.ts b/src/main/pi/rpc-dialog-callbacks.ts new file mode 100644 index 00000000000..c9b2f02fa05 --- /dev/null +++ b/src/main/pi/rpc-dialog-callbacks.ts @@ -0,0 +1,98 @@ +import type { AgentSessionPromptResponse } from '../../shared/agent-session-question-answer' +import { + AgentSessionPromptAnswerRejectedError, + AgentSessionPromptUnavailableError +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { JsonlRpcTimelineLane } from '../jsonl-rpc/timeline-lane' +import type { JsonlRpcRecord } from '../jsonl-rpc/peer' +import { piRpcDialogPresentation, type PiRpcDialogPresentation } from './rpc-extension-dialogs' + +type Callback = { presentation: PiRpcDialogPresentation; claimed: boolean; bytes: number } +const DECORATION = new Set(['notify', 'setStatus', 'setWidget', 'setTitle', 'setEditorText']) + +export class PiRpcDialogCallbacks { + private readonly pending = new Map() + private pendingBytes = 0 + constructor( + private readonly lane: JsonlRpcTimelineLane, + private readonly send: (frame: JsonlRpcRecord) => Promise, + private readonly onFailed: (error: Error) => void + ) {} + + receive(frame: JsonlRpcRecord): void { + if (typeof frame.method === 'string' && DECORATION.has(frame.method)) { + return + } + const presentation = piRpcDialogPresentation(frame) + if (!presentation) { + if (typeof frame.id === 'string' || typeof frame.id === 'number') { + void this.send({ type: 'extension_ui_response', id: frame.id, cancelled: true }).catch( + (error: unknown) => + this.onFailed(error instanceof Error ? error : new Error(String(error))) + ) + } + return + } + const key = String(presentation.id) + const bytes = Buffer.byteLength(JSON.stringify(presentation.body)) + if ( + this.pending.has(key) || + this.pending.size >= 64 || + this.pendingBytes + bytes > 8 * 1024 * 1024 + ) { + throw new Error('Pi dialog identity or capacity violation') + } + this.pending.set(key, { presentation, claimed: false, bytes }) + this.pendingBytes += bytes + this.lane.apply([{ type: 'request.open', request: key, body: presentation.body }]) + } + + async respond( + itemId: string, + response: AgentSessionPromptResponse | null, + commit: () => Promise, + answer = true + ): Promise { + const entry = [...this.pending].find(([key]) => this.lane.isRequestRow(itemId, key)) + if (!entry || entry[1].claimed) { + throw new AgentSessionPromptUnavailableError(itemId) + } + const [key, callback] = entry + let reply: Record + try { + reply = callback.presentation.reply(response) + } catch (error) { + throw new AgentSessionPromptAnswerRejectedError( + error instanceof Error ? error.message : 'Invalid Pi dialog answer' + ) + } + callback.claimed = true + try { + await commit() + if (this.pending.get(key) !== callback) { + throw new AgentSessionPromptUnavailableError(itemId) + } + if (answer) { + await this.send({ type: 'extension_ui_response', id: callback.presentation.id, ...reply }) + } + this.pending.delete(key) + this.pendingBytes -= callback.bytes + } catch (error) { + callback.claimed = false + throw error + } + } + + cancelAll(): void { + for (const [key, callback] of this.pending) { + this.lane.apply([{ type: 'request.withdrawn', request: key }]) + void this.send({ + type: 'extension_ui_response', + id: callback.presentation.id, + cancelled: true + }).catch(() => {}) + } + this.pending.clear() + this.pendingBytes = 0 + } +} diff --git a/src/main/pi/rpc-extension-dialogs.ts b/src/main/pi/rpc-extension-dialogs.ts new file mode 100644 index 00000000000..0f2f37e1c7d --- /dev/null +++ b/src/main/pi/rpc-extension-dialogs.ts @@ -0,0 +1,102 @@ +import type { AgentSessionPromptResponse } from '../../shared/agent-session-question-answer' +import type { ProviderTimelineRequestBody } from '../native-chat/agent-session-timeline/provider-timeline-event' +import { piRpcDialogSchema } from './rpc-protocol' + +const pendingResolution = { + state: 'pending', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null +} as const + +export type PiRpcDialogPresentation = { + id: string | number + body: ProviderTimelineRequestBody + reply: (response: AgentSessionPromptResponse | null) => Record +} + +/** Terminal decoration is deliberately absent from the chat projection. */ +export function piRpcDialogPresentation(value: unknown): PiRpcDialogPresentation | null { + const parsed = piRpcDialogSchema.safeParse(value) + if (!parsed.success) { + return null + } + const dialog = parsed.data + if (dialog.method === 'confirm') { + return { + id: dialog.id, + body: { + kind: 'approval', + title: dialog.title, + detail: dialog.message ?? null, + options: [ + { id: 'yes', label: 'Yes' }, + { id: 'no', label: 'No' } + ], + resolution: pendingResolution + }, + reply: (response) => { + if (response === null) { + return { cancelled: true } + } + if (response.kind !== 'option' || !['yes', 'no'].includes(response.optionId)) { + throw new Error('Confirmation must select an offered option') + } + return { confirmed: response.optionId === 'yes' } + } + } + } + const questionId = String(dialog.id) + const options = + dialog.method === 'select' + ? dialog.options.map((label, index) => ({ + id: `option-${index}`, + label: label || 'Empty value' + })) + : [] + return { + id: dialog.id, + body: { + kind: 'question', + question: [dialog.title, dialog.message].filter((text) => text !== undefined).join('\n\n'), + options, + resolution: pendingResolution, + ...(dialog.method === 'select' + ? {} + : { + freeTextQuestionId: questionId, + freeTextInput: { + allowEmpty: true, + ...(dialog.method === 'editor' + ? { multiline: true, initialValue: dialog.prefill ?? '' } + : {}), + ...(dialog.placeholder === undefined ? {} : { placeholder: dialog.placeholder }) + } + }) + }, + reply: (response) => { + if (response === null) { + return { cancelled: true } + } + if (response.kind !== 'answers' || response.answers.length !== 1) { + throw new Error('Dialog requires one answer') + } + const answer = response.answers[0] + if (dialog.method === 'select') { + const selected = options.findIndex((option) => option.id === answer.optionIds[0]) + if (answer.optionIds.length !== 1 || answer.other !== undefined || selected === -1) { + throw new Error('Selection must choose an offered option') + } + return { value: dialog.options[selected] } + } + if ( + answer.questionId !== questionId || + answer.optionIds.length !== 0 || + answer.other === undefined + ) { + throw new Error('Dialog requires a text answer') + } + return { value: answer.other } + } + } +} diff --git a/src/main/pi/rpc-idle-check.ts b/src/main/pi/rpc-idle-check.ts new file mode 100644 index 00000000000..5ffe453ac84 --- /dev/null +++ b/src/main/pi/rpc-idle-check.ts @@ -0,0 +1,57 @@ +import { piRpcIdle, piRpcStateSchema, type PiRpcState } from './rpc-protocol' + +type IdleCheckDeps = { + request: () => Promise + current: (revision: number) => boolean + settled: (state: PiRpcState) => void + failed: (error: Error) => void +} + +/** Detached compaction and new input invalidate an older settled signal and its idle reply. */ +export class PiRpcIdleCheck { + private readonly timers = new Set>() + private ended = false + constructor(private readonly deps: IdleCheckDeps) {} + + schedule(revision: number, attempt = 0): void { + queueMicrotask(() => { + if (this.ended || !this.deps.current(revision)) { + return + } + void this.deps + .request() + .then((value) => { + if (this.ended || !this.deps.current(revision)) { + return + } + const state = piRpcStateSchema.parse(value) + if (piRpcIdle(state)) { + this.deps.settled(state) + } + }) + .catch((error: unknown) => { + if (this.ended || !this.deps.current(revision)) { + return + } + if (attempt >= 2) { + this.deps.failed(error instanceof Error ? error : new Error(String(error))) + return + } + const timer = setTimeout(() => { + this.timers.delete(timer) + this.schedule(revision, attempt + 1) + }, 100) + timer.unref() + this.timers.add(timer) + }) + }) + } + + dispose(): void { + this.ended = true + for (const timer of this.timers) { + clearTimeout(timer) + } + this.timers.clear() + } +} diff --git a/src/main/pi/rpc-launch-resolution.test.ts b/src/main/pi/rpc-launch-resolution.test.ts new file mode 100644 index 00000000000..38132a8d13b --- /dev/null +++ b/src/main/pi/rpc-launch-resolution.test.ts @@ -0,0 +1,227 @@ +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import { agentSessionProviderHandleKey } from '../../shared/agent-session-provider-handle' +import { closeTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { openTestAgentSessionRecordStore } from '../runtime/agent-session-record-store-test-harness' +import { buildPiRpcLaunch } from './rpc-launch' +import { createPiRpcLaunchResolver, piRpcProviderLink } from './rpc-launch-resolution' + +const probeVersion = vi.fn(async (..._input: unknown[]) => true) + +const identity: AgentSessionJournalIdentity = { + sessionId: 'session-pi-resolve', + workspaceId: 'folder-1', + hostId: 'local', + agent: 'pi', + providerHandle: null +} +let root: string +beforeEach(async () => { + probeVersion.mockReset().mockResolvedValue(true) + root = await mkdtemp(join(tmpdir(), 'orca-pi-resolver-')) +}) +afterEach(async () => { + closeTestJournalHostDatabase(root) + await rm(root, { recursive: true, force: true }) +}) + +async function setup() { + const store = await openTestAgentSessionRecordStore(root) + const { record } = await store.reserveOwner({ + sessionId: identity.sessionId, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'folder-1', + workspaceKind: 'folder' + }, + provider: 'pi', + accountHome: { variable: 'PI_CODING_AGENT_DIR', path: '/host/account' }, + expectedFence: null, + spawnToken: 'spawn-pi', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { + callerKey: 'client-1', + operationId: '1800000000000-00000000000000000000000000000001', + fingerprint: 'pi-create' + }, + now: 1_800_000_000_000 + }) + const workspace = join(root, 'workspace') + await mkdir(workspace) + const resolveEnvironment = vi.fn(async () => ({ + PATH: '/host/bin', + HOME: '/host/home', + PI_CODING_AGENT_DIR: '/inherited/wrong' + })) + const resolveCommand = vi.fn((..._input: unknown[]) => '/host/bin/pi') + const settings: { agentCmdOverrides: Record } = { agentCmdOverrides: {} } + const resolver = createPiRpcLaunchResolver({ + store, + resolveWorkspacePath: async () => workspace, + resolveEnvironment, + resolveCommandSettings: () => settings, + resolveCommand, + probeVersion + }) + const prior = ( + file: string, + origin: AgentSessionProviderHandleLink['origin'] = 'created' + ): AgentSessionProviderHandleLink => ({ + linkId: 'prior-link', + handle: { transport: 'jsonl-rpc', agent: 'pi', nativeId: file }, + origin, + mintedAtFence: 1, + observedAt: 100 + }) + const withPrior = (link: AgentSessionProviderHandleLink) => { + vi.spyOn(store, 'getRecord').mockReturnValue({ ...record, providerHandleChain: [link] }) + } + return { store, record, workspace, resolver, resolveCommand, settings, prior, withPrior } +} + +describe('Pi host launch resolution', () => { + it('refuses acquisition if the selected binary was replaced by an unsupported version', async () => { + const h = await setup() + probeVersion.mockResolvedValue(false) + await expect(h.resolver(identity)).rejects.toThrow('structured_agent_session_unsupported') + expect(probeVersion).toHaveBeenCalledWith( + { + program: '/host/bin/pi', + cwd: h.workspace, + env: { PATH: '/host/bin', HOME: '/host/home', PI_CODING_AGENT_DIR: '/host/account' } + }, + expect.any(Function) + ) + }) + + it('uses the runtime workspace, binary and account home for a new folder session', async () => { + const h = await setup() + const launch = await h.resolver(identity) + expect(launch).toMatchObject({ + command: '/host/bin/pi', + cwd: h.workspace, + env: { PATH: '/host/bin', HOME: '/host/home', PI_CODING_AGENT_DIR: '/host/account' }, + previous: null, + fullAccess: true + }) + expect(h.resolveCommand).toHaveBeenCalledWith('pi', { + pathEnv: '/host/bin', + homePath: '/host/home' + }) + }) + + it('spawns the binary the Command setting names, and refuses one that is not runnable', async () => { + const h = await setup() + h.settings.agentCmdOverrides = { pi: `"${process.execPath}"` } + await expect(h.resolver(identity)).resolves.toMatchObject({ command: process.execPath }) + expect(h.resolveCommand).not.toHaveBeenCalled() + + probeVersion.mockClear() + h.settings.agentCmdOverrides = { pi: '/missing/pi' } + await expect(h.resolver(identity)).rejects.toMatchObject({ + reason: 'agentCommandNotRunnable' + }) + expect(probeVersion).not.toHaveBeenCalled() + }) + + it('resumes the same directory and forks a session from a different directory', async () => { + const h = await setup() + const same = join(root, 'same.jsonl') + await writeFile(same, `${JSON.stringify({ type: 'session', cwd: h.workspace })}\n`) + h.withPrior(h.prior(same)) + const resumed = await h.resolver(identity) + expect(resumed.sessionFile).toBe(same) + expect(resumed.forkFile).toBeUndefined() + expect(piRpcProviderLink(resumed, same, 2, 'next', 200).origin).toBe('resumed') + expect(() => piRpcProviderLink(resumed, join(root, 'wrong.jsonl'), 2, 'bad', 200)).toThrow( + 'different session file' + ) + + const elsewhere = join(root, 'elsewhere.jsonl') + await writeFile(elsewhere, `${JSON.stringify({ type: 'session', cwd: join(root, 'other') })}\n`) + h.withPrior(h.prior(elsewhere)) + const forked = await h.resolver(identity) + expect(forked.forkFile).toBe(elsewhere) + expect(forked.sessionFile).toBeUndefined() + expect(piRpcProviderLink(forked, join(root, 'fork.jsonl'), 2, 'fork', 200)).toMatchObject({ + origin: 'forked', + forkedFromKey: agentSessionProviderHandleKey(h.prior(elsewhere).handle) + }) + expect(() => piRpcProviderLink(forked, elsewhere, 2, 'bad', 200)).toThrow('did not fork') + }) + + it('resumes the stored session file after Orca restarts and reopens the store', async () => { + const h = await setup() + const file = join(root, 'conversation.jsonl') + await writeFile(file, `${JSON.stringify({ type: 'session', cwd: h.workspace })}\n`) + const fence = h.record.lease.runtimeFence + await h.store.commitProcessIdentity({ + sessionId: identity.sessionId, + fence, + process: { hostId: 'local', pid: 4242, processStartTimeMs: 1, spawnToken: 'spawn-pi' }, + now: 1_800_000_000_001 + }) + await h.store.proveOwner({ + sessionId: identity.sessionId, + fence, + link: { ...h.prior(file), mintedAtFence: fence }, + now: 1_800_000_000_002 + }) + // A restart: the store is closed and a fresh one reads the record back from disk. + closeTestJournalHostDatabase(root) + const reopened = await openTestAgentSessionRecordStore(root) + const launch = await createPiRpcLaunchResolver({ + store: reopened, + resolveWorkspacePath: async () => h.workspace, + resolveEnvironment: async () => ({ PATH: '/host/bin', HOME: '/host/home' }), + resolveCommand: () => '/host/bin/pi', + probeVersion + })(identity) + expect(launch.sessionFile).toBe(file) + expect(buildPiRpcLaunch(launch).args).toEqual(['--mode', 'rpc', '--session', file]) + }) + + it('distinguishes an unsaved creation from an existing session that could not restore', async () => { + const h = await setup() + const missing = join(root, 'missing.jsonl') + h.withPrior(h.prior(missing)) + const unsaved = await h.resolver(identity) + expect(unsaved.replacement).toBe('unsaved') + expect(piRpcProviderLink(unsaved, join(root, 'new.jsonl'), 2, 'new', 200)).toMatchObject({ + origin: 'created', + supersedesKey: agentSessionProviderHandleKey(h.prior(missing).handle) + }) + h.withPrior(h.prior(missing, 'resumed')) + const lost = await h.resolver(identity) + expect(lost.replacement).toBe('restore-failed') + expect( + piRpcProviderLink(lost, join(root, 'replacement.jsonl'), 2, 'replace', 200) + ).toMatchObject({ replaces: { reason: 'restore-failed', replacedAt: 200 } }) + }) + + it('refuses a different execution host and a foreign account home before resolving paths', async () => { + const h = await setup() + const record = h.store.getRecord(identity.sessionId) + if (!record) { + throw new Error('record missing') + } + vi.spyOn(h.store, 'getRecord').mockReturnValue({ + ...record, + location: { ...record.location, executionHostId: 'ssh:remote' } + }) + await expect(h.resolver(identity)).rejects.toThrow('another execution host') + vi.spyOn(h.store, 'getRecord').mockReturnValue({ + ...record, + accountHome: { variable: 'CODEX_HOME', path: '/wrong' } + }) + await expect(h.resolver(identity)).rejects.toThrow('account home') + expect(h.resolveCommand).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/pi/rpc-launch-resolution.ts b/src/main/pi/rpc-launch-resolution.ts new file mode 100644 index 00000000000..0bc63833b5e --- /dev/null +++ b/src/main/pi/rpc-launch-resolution.ts @@ -0,0 +1,211 @@ +import { open, realpath } from 'node:fs/promises' +import { isAbsolute, normalize } from 'node:path' +import { z } from 'zod' +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' +import { + agentSessionProviderHandleChainHead, + agentSessionProviderHandleKey, + type AgentSessionProviderHandleLink +} from '../../shared/agent-session-provider-handle' +import { isLegacyAgentSessionAccountHome } from '../../shared/agent-session-account-home' +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import type { resolveCliCommand } from '../../shared/node-cli-command-resolution' +import { isStableCliVersionOnLine, probeAgentCliVersion } from '../agent-cli-version-probe' +import { + resolveStructuredAgentCommand, + type StructuredAgentCommandSettings +} from '../native-chat/structured-agent-command-resolution' +import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' +import { isWindowsProcessStartTimeAvailable } from '../windows/windows-process-table' +import type { PiRpcLaunchOptions } from './rpc-launch' + +export type PiRpcResolvedLaunch = PiRpcLaunchOptions & { + previous: AgentSessionProviderHandleLink | null + replacement?: 'unsaved' | 'restore-failed' +} +export type PiRpcLaunchResolverDeps = { + store: AgentSessionRecordStore + resolveWorkspacePath: (id: string) => Promise + resolveEnvironment: () => Promise + /** The user's Pi Command setting, re-read per acquisition; none runs the stock binary. */ + resolveCommandSettings?: () => StructuredAgentCommandSettings + resolveCommand?: typeof resolveCliCommand + probeVersion?: typeof probeAgentCliVersion + resolveFullAccess?: () => boolean +} + +// Stable releases from 0.84.0 emit agent_settled after retries and detached compaction; older +// Pi (the 0.73.x package included) keeps the terminal chat. +const PI_RPC_RELEASE_LINES = [ + { major: 0, floor: '0.84.0' }, + { major: 1, floor: '1.0.0' } +] as const + +function supportsPiRpcVersion(version: string): boolean { + return PI_RPC_RELEASE_LINES.some((line) => isStableCliVersionOnLine(version, line)) +} + +/** The binary a Pi launch with `env` spawns; create support resolves through here too. */ +export function resolvePiRpcCommand( + env: Readonly>, + commandSettings: StructuredAgentCommandSettings = {}, + resolve?: typeof resolveCliCommand +): string { + const homePath = env.HOME ?? env.USERPROFILE + return resolveStructuredAgentCommand( + 'pi', + commandSettings, + { pathEnv: env.PATH ?? env.Path ?? null, ...(homePath ? { homePath } : {}) }, + resolve ? { resolve } : {} + ) +} + +export function piRpcVersionSupported( + launch: { command: string; cwd: string; env: Record }, + probe: typeof probeAgentCliVersion = probeAgentCliVersion +): Promise { + return probe({ program: launch.command, cwd: launch.cwd, env: launch.env }, supportsPiRpcVersion) +} + +const headerSchema = z.object({ type: z.literal('session'), cwd: z.string().min(1) }) + +async function sessionDirectory(file: string): Promise { + let descriptor + try { + descriptor = await open(file, 'r') + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return null + } + throw error + } + try { + const buffer = Buffer.alloc(64 * 1024) + const { bytesRead } = await descriptor.read(buffer, 0, buffer.length, 0) + const data = buffer.subarray(0, bytesRead).toString('utf8') + const lf = data.indexOf('\n') + if (lf === -1) { + throw new Error('Pi session header is incomplete or oversized') + } + return headerSchema.parse(JSON.parse(data.slice(0, lf))).cwd + } finally { + await descriptor.close() + } +} + +async function sameDirectory(left: string, right: string): Promise { + const canonical = async (value: string): Promise => + realpath(value).catch(() => normalize(value)) + const [a, b] = await Promise.all([canonical(left), canonical(right)]) + return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b +} + +/** Reads the execution host's durable record; never resolves another host's files or account. */ +export function createPiRpcLaunchResolver( + deps: PiRpcLaunchResolverDeps +): (identity: AgentSessionJournalIdentity) => Promise { + return async (identity) => { + const record = deps.store.getRecord(identity.sessionId) + if (!record || record.provider !== 'pi') { + throw new Error('Pi session record unavailable') + } + if ( + record.location.executionHostId !== LOCAL_EXECUTION_HOST_ID || + record.location.wslDistro !== null + ) { + throw new Error('Pi structured session belongs to another execution host') + } + if (process.platform === 'win32' && !isWindowsProcessStartTimeAvailable()) { + throw new Error('Pi structured sessions require Windows process creation-time proof') + } + const accountHome = record.accountHome + if ( + !isLegacyAgentSessionAccountHome(accountHome) || + accountHome.variable !== 'PI_CODING_AGENT_DIR' + ) { + throw new Error('Pi account home does not match its binary') + } + const cwd = await deps.resolveWorkspacePath(record.location.workspaceId) + const env: Record = {} + for (const [key, value] of Object.entries(await deps.resolveEnvironment())) { + if (value !== undefined) { + env[key] = value + } + } + env.PI_CODING_AGENT_DIR = accountHome.path + const previous = agentSessionProviderHandleChainHead(record.providerHandleChain) + let sessionFile: string | undefined + let forkFile: string | undefined + let replacement: PiRpcResolvedLaunch['replacement'] + if (previous) { + if ( + previous.handle.transport !== 'jsonl-rpc' || + previous.handle.agent !== 'pi' || + !isAbsolute(previous.handle.nativeId) + ) { + throw new Error('Pi resume handle is not a host session file') + } + const source = previous.handle.nativeId + const directory = await sessionDirectory(source) + if (directory === null) { + replacement = previous.origin === 'created' ? 'unsaved' : 'restore-failed' + } else if (await sameDirectory(directory, cwd)) { + sessionFile = source + } else { + forkFile = source + } + } + const command = resolvePiRpcCommand(env, deps.resolveCommandSettings?.(), deps.resolveCommand) + // Again at every launch: the binary on PATH may have changed since the chat was created. + if (!(await piRpcVersionSupported({ command, cwd, env }, deps.probeVersion))) { + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'hostUnsupported' + }) + } + return { + command, + cwd, + env, + fullAccess: deps.resolveFullAccess?.() ?? true, + previous, + ...(sessionFile ? { sessionFile } : {}), + ...(forkFile ? { forkFile } : {}), + ...(replacement ? { replacement } : {}) + } + } +} + +export function piRpcProviderLink( + launch: PiRpcResolvedLaunch, + file: string, + fence: number, + linkId: string, + at: number +): AgentSessionProviderHandleLink { + if (!isAbsolute(file)) { + throw new Error('Pi did not return an absolute session file') + } + const previous = launch.previous + if (launch.sessionFile && file !== launch.sessionFile) { + throw new Error('Pi resumed a different session file') + } + if (launch.forkFile && file === launch.forkFile) { + throw new Error('Pi did not fork the session for its new directory') + } + const key = previous ? agentSessionProviderHandleKey(previous.handle) : undefined + return { + linkId, + handle: { transport: 'jsonl-rpc', agent: 'pi', nativeId: file }, + origin: launch.forkFile ? 'forked' : launch.sessionFile ? 'resumed' : 'created', + mintedAtFence: fence, + observedAt: at, + ...(launch.forkFile && key ? { forkedFromKey: key } : {}), + ...(launch.replacement === 'unsaved' && key + ? { supersedesKey: key, ...(previous?.replaces ? { replaces: previous.replaces } : {}) } + : {}), + ...(launch.replacement === 'restore-failed' && key + ? { replaces: { key, reason: 'restore-failed', replacedAt: at } } + : {}) + } +} diff --git a/src/main/pi/rpc-launch.test.ts b/src/main/pi/rpc-launch.test.ts new file mode 100644 index 00000000000..678e84dadf3 --- /dev/null +++ b/src/main/pi/rpc-launch.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest' +import { resolveProviderChildEnv } from '../provider-process/provider-process-launch' +import { buildPiRpcLaunch } from './rpc-launch' + +const options = { command: 'host-resolved-pi', cwd: '/host/folder', fullAccess: true } +describe('Pi RPC launch', () => { + it('uses the supplied host binary and folder, and lets Pi choose its own session store', () => { + expect(buildPiRpcLaunch(options)).toMatchObject({ + command: options.command, + cwd: options.cwd, + args: ['--mode', 'rpc'] + }) + }) + + it.each([ + '/host/.pi/agent/sessions/session.jsonl', + 'C:\\Users\\dev\\.pi\\agent\\sessions\\session.jsonl' + ])('passes the native session file unchanged: %s', (sessionFile) => { + expect(buildPiRpcLaunch({ ...options, sessionFile }).args).toEqual([ + '--mode', + 'rpc', + '--session', + sessionFile + ]) + }) + + it('retains selected account and user extension args, but strips inherited pane hook identities after overlays', () => { + const launch = buildPiRpcLaunch({ + ...options, + extraArgs: ['--extension', '/host/my extension.ts'], + env: { + PI_CODING_AGENT_DIR: '/host/account', + ORCA_PANE_KEY: 'other-pane', + ORCA_AGENT_PANE: 'alias' + } + }) + const env = resolveProviderChildEnv(launch, { + ORCA_AGENT_HOOK_TOKEN: 'inherited', + API_KEY: 'retained' + }) + expect(env).toEqual({ API_KEY: 'retained', PI_CODING_AGENT_DIR: '/host/account' }) + expect(launch.args).toEqual(['--mode', 'rpc', '--extension', '/host/my extension.ts']) + }) + + it.each([ + ['--provider', 'anthropic', '--model', 'model-id'], + ['--provider=anthropic', '--model=model-id'], + ['--provider=anthropic', '-m', 'model-id'], + ['--model', 'anthropic/model-id'] + ])('accepts paired model/provider selections: %j', (...extraArgs) => { + expect(buildPiRpcLaunch({ ...options, extraArgs }).args).toEqual([ + '--mode', + 'rpc', + ...extraArgs + ]) + }) + + it.each([['--provider', 'anthropic'], ['--provider=anthropic'], ['--model'], ['--model=']])( + 'rejects incomplete provider/model args: %j', + (...extraArgs) => { + expect(() => buildPiRpcLaunch({ ...options, extraArgs })).toThrow(/requires/) + } + ) + + it.each([ + '--mode=json', + '--print', + '-p', + '--no-session', + '--session=other', + '--resume', + '--continue' + ])('rejects arguments that bypass the chat mode or its session handle: %s', (arg) => { + expect(() => buildPiRpcLaunch({ ...options, extraArgs: [arg] })).toThrow('conflicts') + }) + + it('rejects a supervised permissions request instead of silently running in full access', () => { + expect(() => buildPiRpcLaunch({ ...options, fullAccess: false })).toThrow('full access only') + }) + + it('rejects an empty supplied resume handle', () => { + expect(() => buildPiRpcLaunch({ ...options, sessionFile: '' })).toThrow( + 'requires a session file' + ) + }) + + it('scrubs inherited structured identity and injects only this acquisition identity', () => { + const inherited = { + ORCA_AGENT_SESSION_ID: 'stale', + ORCA_STRUCTURED_SESSION: '1', + ORCA_TERMINAL_HANDLE: 'structworker_stale', + ORCA_AGENT_SESSION_SPAWN_TOKEN: 'old-token' + } + expect(resolveProviderChildEnv(buildPiRpcLaunch(options), inherited)).toEqual({}) + const childEnv = resolveProviderChildEnv( + buildPiRpcLaunch({ + ...options, + env: inherited, + structuredSession: { id: 'this-session', spawnToken: 'this-token' } + }), + inherited + ) + expect(childEnv).toMatchObject({ + ORCA_AGENT_SESSION_ID: 'this-session', + ORCA_STRUCTURED_SESSION: '1', + ORCA_AGENT_SESSION_SPAWN_TOKEN: 'this-token' + }) + expect(childEnv).not.toHaveProperty('ORCA_TERMINAL_HANDLE') + }) +}) diff --git a/src/main/pi/rpc-launch.ts b/src/main/pi/rpc-launch.ts new file mode 100644 index 00000000000..98fba1dfb93 --- /dev/null +++ b/src/main/pi/rpc-launch.ts @@ -0,0 +1,119 @@ +import { + ORCA_SCRUB_SAFE_LAUNCH_ENV, + ORCA_SCRUB_SAFE_PANE_ENV +} from '../../shared/agent-hook-scrub-safe-env' +import { + AGENT_HOOK_RUNTIME_ENV_KEYS, + ORCA_AGENT_SESSION_CALLER_ENV_KEYS +} from '../ipc/pty/host-env/spawn-env-keys' +import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch' +import { structuredSessionChildIdentityEnv } from '../runtime/structured-session-child-identity-env' + +export type PiRpcLaunchOptions = { + /** Binary and paths are resolved by the execution host before building the launch. */ + command: string + cwd: string + fullAccess: boolean + extraArgs?: readonly string[] + env?: Record + sessionFile?: string + forkFile?: string + structuredSession?: { id: string; spawnToken: string } +} + +const CHILD_ENV_TO_DELETE: readonly string[] = [ + 'ORCA_PANE_KEY', + 'ORCA_TAB_ID', + 'ORCA_WORKTREE_ID', + 'ORCA_AGENT_LAUNCH_TOKEN', + ORCA_SCRUB_SAFE_PANE_ENV, + ORCA_SCRUB_SAFE_LAUNCH_ENV, + ...AGENT_HOOK_RUNTIME_ENV_KEYS +] +const CALLER_ENV_TO_DELETE = [ + ...ORCA_AGENT_SESSION_CALLER_ENV_KEYS, + 'ORCA_TERMINAL_HANDLE', + 'ORCA_AGENT_SESSION_SPAWN_TOKEN' +] + +/** Pi stores its own sessions; an explicit session file is shared with terminal resumes. */ +export function buildPiRpcLaunch(options: PiRpcLaunchOptions): ProviderProcessLaunch { + if (!options.fullAccess) { + throw new Error('Pi structured chat supports full access only') + } + if (options.sessionFile !== undefined && !options.sessionFile.trim()) { + throw new Error('Pi resume requires a session file') + } + if ( + options.forkFile !== undefined && + (!options.forkFile.trim() || options.sessionFile !== undefined) + ) { + throw new Error('Pi fork requires one source session file') + } + const env = { ...options.env } + for (const key of CALLER_ENV_TO_DELETE) { + delete env[key] + } + const childEnv = options.structuredSession + ? { + ...structuredSessionChildIdentityEnv(options.structuredSession.id, env), + ORCA_AGENT_SESSION_SPAWN_TOKEN: options.structuredSession.spawnToken + } + : env + const args = [...(options.extraArgs ?? [])] + let provider = false + let model = false + for (let index = 0; index < args.length; index++) { + const arg = args[index] + const flag = arg.split('=')[0] + if (flag === '--provider' || flag === '--model' || flag === '-m') { + const value = arg.includes('=') ? arg.slice(arg.indexOf('=') + 1) : args[++index] + if (!value || value.startsWith('-')) { + throw new Error(`Pi ${flag} requires a value`) + } + if (flag === '--provider') { + provider = true + } else { + model = true + } + } else if ( + [ + '--mode', + '--print', + '-p', + '--no-session', + '--session', + '-r', + '--resume', + '-c', + '--continue', + '--fork' + ].includes(flag) + ) { + throw new Error(`Pi ${flag} conflicts with the structured chat launch`) + } + } + if (provider && !model) { + throw new Error('Pi --provider requires --model') + } + return { + command: options.command, + cwd: options.cwd, + args: [ + '--mode', + 'rpc', + ...args, + ...(options.sessionFile ? ['--session', options.sessionFile] : []), + ...(options.forkFile ? ['--fork', options.forkFile] : []) + ], + env: childEnv, + envToDelete: [ + ...CHILD_ENV_TO_DELETE, + ...(options.structuredSession + ? Object.hasOwn(childEnv, 'ORCA_TERMINAL_HANDLE') + ? [] + : ['ORCA_TERMINAL_HANDLE'] + : CALLER_ENV_TO_DELETE) + ] + } +} diff --git a/src/main/pi/rpc-location.test.ts b/src/main/pi/rpc-location.test.ts new file mode 100644 index 00000000000..b3c500ba45f --- /dev/null +++ b/src/main/pi/rpc-location.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' +import { structuredAgentRuntimeRegistration } from '../runtime/structured-agent-runtime-registrations' +import { isWindowsProcessStartTimeAvailable } from '../windows/windows-process-table' +import { PiRpcSessionAdapter } from './rpc-session-adapter' + +vi.mock('../windows/windows-process-table', () => ({ isWindowsProcessStartTimeAvailable: vi.fn() })) + +const originalPlatform = process.platform +afterEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + vi.resetAllMocks() +}) + +const local: AgentSessionExecutionLocation = { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'folder-1', + workspaceKind: 'folder' +} + +describe('Pi execution host support', () => { + it.each([false, true])( + 'requires Windows process identity proof at both support seams (%s)', + (proof) => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + vi.mocked(isWindowsProcessStartTimeAvailable).mockReturnValue(proof) + const adapter = new PiRpcSessionAdapter({ + resolveLaunch: async () => { + throw new Error('A location check must not acquire a process') + }, + onLifecycle: vi.fn(), + onSettled: vi.fn(), + onIdle: vi.fn(), + logger: { warn: vi.fn(), error: vi.fn() } + }) + expect(structuredAgentRuntimeRegistration('pi')?.supportsLocation(local)).toBe(proof) + expect(adapter.supportsCreate(local, 'pi')).toBe(proof) + } + ) + + it('refuses another execution host and WSL without probing local process identity', () => { + const registration = structuredAgentRuntimeRegistration('pi') + expect(registration?.supportsLocation({ ...local, executionHostId: 'runtime:remote' })).toBe( + false + ) + expect(registration?.supportsLocation({ ...local, wslDistro: 'Ubuntu' })).toBe(false) + expect(isWindowsProcessStartTimeAvailable).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/pi/rpc-message-dialog-shapes.test.ts b/src/main/pi/rpc-message-dialog-shapes.test.ts new file mode 100644 index 00000000000..26dc5b78121 --- /dev/null +++ b/src/main/pi/rpc-message-dialog-shapes.test.ts @@ -0,0 +1,80 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { z } from 'zod' +import { PiRpcMessages } from './rpc-messages' +import { PiRpcContextUsage } from './rpc-context-usage' +import { piRpcDialogPresentation } from './rpc-extension-dialogs' + +describe('recorded Pi message and extension shapes', () => { + it('publishes top-level live usage before the assistant message ends', () => { + const frameSchema = z.looseObject({ + type: z.string(), + usage: z + .object({ + input: z.number(), + output: z.number(), + cacheRead: z.number(), + cacheWrite: z.number() + }) + .optional() + }) + const frames = readFileSync( + join(import.meta.dirname, '__fixtures__', 'steer-followup.jsonl'), + 'utf8' + ) + .split('\n') + .filter(Boolean) + .map((line) => { + const envelope = z.object({ raw: z.string() }).parse(JSON.parse(line)) + return frameSchema.parse(JSON.parse(envelope.raw)) + }) + const update = frames.find( + (frame) => + frame.type === 'message_update' && + frame.usage && + Object.values(frame.usage).some((tokens) => tokens > 0) + ) + expect(update).toBeDefined() + const messages = new PiRpcMessages('generation', new PiRpcContextUsage()) + expect(messages.message(update, 123)).toContainEqual({ + type: 'context.usage', + usage: { + used: { + kind: 'estimate', + usage: { + inputTokens: update?.usage?.input, + outputTokens: update?.usage?.output, + cacheCreationInputTokens: update?.usage?.cacheWrite, + cacheReadInputTokens: update?.usage?.cacheRead + }, + capturedAt: 123 + } + } + }) + }) + + it.each(['input', 'editor', 'select'])('preserves the provider message for %s', (method) => { + const dialog = piRpcDialogPresentation({ + id: 'dialog', + method, + title: 'Choose', + message: 'Use staging', + options: ['value'] + }) + expect(dialog?.body).toMatchObject({ kind: 'question', question: 'Choose\n\nUse staging' }) + }) + + it('labels an empty select value while delivering its original string', () => { + const dialog = piRpcDialogPresentation({ + id: 'select', + method: 'select', + title: 'Choose', + options: [''] + }) + expect(dialog?.body).toMatchObject({ options: [{ id: 'option-0', label: 'Empty value' }] }) + expect( + dialog?.reply({ kind: 'answers', answers: [{ questionId: 'q1', optionIds: ['option-0'] }] }) + ).toEqual({ value: '' }) + }) +}) diff --git a/src/main/pi/rpc-messages.ts b/src/main/pi/rpc-messages.ts new file mode 100644 index 00000000000..c463f49c8e1 --- /dev/null +++ b/src/main/pi/rpc-messages.ts @@ -0,0 +1,162 @@ +import { z } from 'zod' +import type { AgentJournalToolCallItem } from '../../shared/agent-session-journal-types' +import { + boundPayload, + boundToolInput, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../native-chat/agent-session-journal/journal-payload-bounds' +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import { piRpcMessageEventSchema, piRpcToolEventSchema } from './rpc-protocol' +import type { PiRpcContextUsage } from './rpc-context-usage' + +const resultSchema = z.object({ + content: z.array(z.object({ type: z.string(), text: z.string().optional() })) +}) + +/** Pi does not name assistant messages; ordinals are local to this exact child. */ +export class PiRpcMessages { + private ordinal = 0 + private active?: number + private readonly text = new Map() + private readonly tools = new Map() + + constructor( + private readonly generation: string, + private readonly context: PiRpcContextUsage + ) {} + + reset(): void { + this.active = undefined + this.text.clear() + this.tools.clear() + } + + message(value: unknown, at: number): ProviderTimelineEvent[] { + const parsed = piRpcMessageEventSchema.safeParse(value) + if (!parsed.success) { + throw new Error('Invalid Pi message event') + } + const event = parsed.data + const message = event.message ?? event.assistantMessageEvent?.partial + if (message && message.role !== 'assistant') { + return [] + } + if (event.type === 'message_start') { + this.active = ++this.ordinal + this.text.clear() + return [] + } + const events = this.context.live(event.usage ?? message?.usage, at) + this.active ??= ++this.ordinal + const update = event.assistantMessageEvent + if ( + event.type === 'message_update' && + update?.delta !== undefined && + ['text_delta', 'thinking_delta'].includes(update.type) + ) { + const channel = update.type === 'text_delta' ? 'assistant' : 'reasoning' + const index = update.contentIndex ?? 0 + const item = this.key(index, channel) + if (this.text.size >= 128 && !this.text.has(item)) { + throw new Error('Pi message stream capacity exceeded') + } + this.text.set(item, { index, channel }) + events.push({ type: 'text.delta', item: { id: item }, channel, text: update.delta }) + } + if (event.type === 'message_end' && message) { + const content = + typeof message.content === 'string' + ? [{ type: 'text', text: message.content }] + : message.content + content.forEach((block, index) => { + const channel = + block.type === 'text' ? 'assistant' : block.type === 'thinking' ? 'reasoning' : undefined + if (!channel) { + return + } + const item = this.key(index, channel) + const text = channel === 'assistant' ? block.text : block.thinking + if (text !== undefined) { + // A snapshot without deltas still opens the same message row. + if (!this.text.has(item) && text) { + events.push({ type: 'text.delta', item: { id: item }, channel, text }) + } + events.push({ type: 'text.close', item: { id: item }, text }) + this.text.delete(item) + } + }) + for (const item of this.text.keys()) { + events.push({ type: 'text.close', item: { id: item } }) + } + this.text.clear() + this.active = undefined + } + return events + } + + tool(value: unknown): ProviderTimelineEvent[] { + const parsed = piRpcToolEventSchema.safeParse(value) + if (!parsed.success) { + throw new Error('Invalid Pi tool event') + } + const event = parsed.data + const previous = this.tools.get(event.toolCallId) + const result = event.result ?? event.partialResult + const content = resultSchema.safeParse(result) + const output = + result === undefined + ? undefined + : content.success + ? content.data.content + .flatMap((block) => (block.text === undefined ? [] : [block.text])) + .join('\n') + : JSON.stringify(result) + const state = + event.type === 'tool_execution_end' ? (event.isError ? 'failed' : 'completed') : 'running' + const body: AgentJournalToolCallItem = { + kind: 'tool-call', + name: event.toolName, + callId: event.toolCallId, + input: + event.args === undefined + ? (previous?.input ?? null) + : boundToolInput(event.args, DEFAULT_JOURNAL_PAYLOAD_LIMITS), + state, + ...(output === undefined + ? previous?.output + ? { output: previous.output } + : {} + : { + output: boundPayload(output, DEFAULT_JOURNAL_PAYLOAD_LIMITS) + }) + } + if (state === 'running') { + if (this.tools.size >= 128 && !previous) { + throw new Error('Pi tool stream capacity exceeded') + } + this.tools.set(event.toolCallId, body) + if ( + [...this.tools.values()].reduce( + (bytes, tool) => bytes + Buffer.byteLength(JSON.stringify(tool)), + 0 + ) > + 1024 * 1024 + ) { + throw new Error('Pi tool stream byte capacity exceeded') + } + } else { + this.tools.delete(event.toolCallId) + } + return [ + { + type: state === 'running' ? (previous ? 'item.update' : 'item.open') : 'item.close', + item: `tool:${event.toolCallId}`, + body + } + ] + } + + private key(index: number, channel: string): string { + return `message:${this.generation}:${this.active}:${index}:${channel}` + } +} diff --git a/src/main/pi/rpc-options.test.ts b/src/main/pi/rpc-options.test.ts new file mode 100644 index 00000000000..5beaefb54ca --- /dev/null +++ b/src/main/pi/rpc-options.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it, vi } from 'vitest' +import { + applyPiRpcSessionOption, + parsePiModelOptionId, + readPiRpcCommands, + readPiRpcSessionOptions, + type PiRpcRequester +} from './rpc-options' + +function requester( + answers: Record +): PiRpcRequester & { request: ReturnType } { + return { request: vi.fn(async (command: string) => answers[command]) } +} + +describe('Pi RPC session options', () => { + it('reads provider-qualified models and the reported thinking level', async () => { + const rpc = requester({ + get_available_models: { + models: [ + { + provider: 'openai-codex', + id: 'gpt-6.1-sol', + name: 'GPT-6.1 Sol', + reasoning: true, + thinkingLevelMap: { off: null, low: 'low', medium: 'medium', high: 'high' } + }, + { provider: 'other', id: 'gpt-6.1-sol', name: 'Other Sol', reasoning: false } + ] + }, + get_state: { + model: { provider: 'openai-codex', id: 'gpt-6.1-sol' }, + thinkingLevel: 'medium', + sessionFile: '/sessions/current.jsonl', + isStreaming: false, + isCompacting: false + } + }) + const result = await readPiRpcSessionOptions(rpc) + expect(rpc.request.mock.calls.map((call) => call[0])).toEqual([ + 'get_available_models', + 'get_state' + ]) + expect(result.current).toEqual({ + model: 'openai-codex/gpt-6.1-sol', + effort: 'medium', + confirmed: ['model', 'effort'] + }) + expect(result.models.map((model) => [model.id, model.isDefault])).toEqual([ + ['openai-codex/gpt-6.1-sol', false], + ['other/gpt-6.1-sol', false] + ]) + expect(result.models[0]?.efforts.map((effort) => effort.value)).toEqual([ + 'minimal', + 'low', + 'medium', + 'high' + ]) + expect(result.models[1]?.efforts).toEqual([]) + }) + + it('sends both provider and model ID and stores only successful explicit selections', async () => { + const rpc = requester({ set_model: {}, set_thinking_level: undefined }) + const selected = new Map() + await expect( + applyPiRpcSessionOption(rpc, selected, 'model', 'openai-codex/gpt-6.1-sol') + ).resolves.toEqual({ model: 'openai-codex/gpt-6.1-sol' }) + await expect(applyPiRpcSessionOption(rpc, selected, 'effort', 'high')).resolves.toEqual({ + model: 'openai-codex/gpt-6.1-sol', + effort: 'high' + }) + expect(rpc.request.mock.calls).toEqual([ + ['set_model', { provider: 'openai-codex', modelId: 'gpt-6.1-sol' }], + ['set_thinking_level', { level: 'high' }] + ]) + expect(() => parsePiModelOptionId('openai-codex')).toThrow() + await expect(applyPiRpcSessionOption(rpc, selected, 'model', '/gpt-6.1-sol')).rejects.toThrow() + expect(selected.get('model')).toBe('openai-codex/gpt-6.1-sol') + const rejected = requester({}) + rejected.request.mockRejectedValueOnce(new Error('provider refused model')) + await expect( + applyPiRpcSessionOption(rejected, selected, 'model', 'other/new-model') + ).rejects.toThrow('provider refused model') + expect(selected.get('model')).toBe('openai-codex/gpt-6.1-sol') + }) + + it('rejects malformed provider data and reports only Pi-listed commands', async () => { + const malformed = requester({ get_available_models: { models: [{ id: 'missing-provider' }] } }) + await expect(readPiRpcSessionOptions(malformed)).rejects.toThrow() + const rpc = requester({ + get_commands: { + commands: [ + { name: 'review', description: 'Review changes', source: 'extension' }, + { name: 'search', source: 'skill' } + ] + } + }) + expect(await readPiRpcCommands(rpc)).toEqual({ + commands: [ + { name: 'review', kind: 'command', description: 'Review changes' }, + { name: 'search', kind: 'skill' } + ] + }) + }) + + it('handles a signed-out state with no model', async () => { + const rpc = requester({ + get_available_models: { models: [] }, + get_state: { + model: null, + thinkingLevel: 'off', + sessionFile: '/sessions/current.jsonl', + isStreaming: false, + isCompacting: false + } + }) + expect((await readPiRpcSessionOptions(rpc)).current).toEqual({ + model: '', + effort: 'off', + confirmed: ['effort'] + }) + }) + + it('keeps ordinary thinking levels when the map only supplies overrides', async () => { + const rpc = requester({ + get_available_models: { + models: [ + { + provider: 'openai-codex', + id: 'gpt-5.5', + reasoning: true, + thinkingLevelMap: { minimal: 'low', xhigh: 'xhigh' } + } + ] + }, + get_state: { sessionFile: '/sessions/current.jsonl', isStreaming: false, isCompacting: false } + }) + expect( + (await readPiRpcSessionOptions(rpc)).models[0]?.efforts.map(({ value }) => value) + ).toEqual(['off', 'minimal', 'low', 'medium', 'high', 'xhigh']) + }) +}) diff --git a/src/main/pi/rpc-options.ts b/src/main/pi/rpc-options.ts new file mode 100644 index 00000000000..9b59bec398a --- /dev/null +++ b/src/main/pi/rpc-options.ts @@ -0,0 +1,119 @@ +import { z } from 'zod' +import type { + AgentSessionCommandsResult, + AgentSessionOptionsResult +} from '../../shared/agent-session-wire' +import { piRpcModelSchema, piRpcStateSchema } from './rpc-protocol' + +export type PiRpcRequester = { + request( + command: string, + params?: Record, + options?: { timeoutMs?: number | null } + ): Promise +} + +const modelSchema = piRpcModelSchema.safeExtend({ + provider: z.string().min(1), + id: z.string().min(1), + thinkingLevelMap: z.record(z.string(), z.string().nullable()).optional() +}) +const modelsSchema = z.object({ models: z.array(modelSchema) }) +const stateSchema = piRpcStateSchema.safeExtend({ + model: modelSchema.nullish() +}) +const commandsSchema = z.object({ + commands: z.array( + z.object({ + name: z.string().min(1), + description: z.string().optional(), + source: z.string().optional() + }) + ) +}) + +const THINKING_LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] as const + +export function piModelOptionId(provider: string, modelId: string): string { + if (!provider || !modelId || provider.includes('/')) { + throw new Error('Pi model requires an explicit provider and model ID') + } + return `${provider}/${modelId}` +} + +export function parsePiModelOptionId(value: string): { provider: string; modelId: string } { + const slash = value.indexOf('/') + if (slash < 1 || slash === value.length - 1) { + throw new Error('Pi model requires an explicit provider and model ID') + } + return { provider: value.slice(0, slash), modelId: value.slice(slash + 1) } +} + +export async function readPiRpcSessionOptions( + rpc: PiRpcRequester +): Promise { + const models = modelsSchema.parse(await rpc.request('get_available_models')).models + const state = stateSchema.parse(await rpc.request('get_state')) + const currentModel = state.model + const currentId = currentModel ? piModelOptionId(currentModel.provider, currentModel.id) : '' + const listed = models.map((model) => { + const id = piModelOptionId(model.provider, model.id) + const efforts = + model.reasoning !== true + ? [] + : THINKING_LEVELS.filter((level) => { + const mapped = model.thinkingLevelMap?.[level] + return ( + mapped !== null && ((level !== 'xhigh' && level !== 'max') || mapped !== undefined) + ) + }).map((level) => ({ value: level, label: level })) + return { + id, + label: model.name || id, + isDefault: false, + efforts + } + }) + return { + models: listed, + current: { + model: currentId, + ...(state.thinkingLevel ? { effort: state.thinkingLevel } : {}), + confirmed: [...(currentId ? ['model'] : []), ...(state.thinkingLevel ? ['effort'] : [])] + } + } +} + +export async function applyPiRpcSessionOption( + rpc: PiRpcRequester, + selected: Map, + key: string, + value: string +): Promise>> { + if (key === 'model') { + const model = parsePiModelOptionId(value) + modelSchema.parse({ provider: model.provider, id: model.modelId }) + await rpc.request('set_model', model) + } else if (key === 'effort') { + if (!THINKING_LEVELS.some((level) => level === value)) { + throw new Error(`Unsupported Pi thinking level: ${value}`) + } + await rpc.request('set_thinking_level', { level: value }) + } else { + throw new Error(`Unsupported Pi option: ${key}`) + } + selected.set(key, value) + return Object.fromEntries(selected) +} + +export async function readPiRpcCommands(rpc: PiRpcRequester): Promise { + const { commands } = commandsSchema.parse(await rpc.request('get_commands')) + return { + commands: commands.map((command) => ({ + name: command.name, + kind: command.source === 'skill' ? 'skill' : 'command', + ...(command.source === undefined ? { kindUnspecified: true as const } : {}), + ...(command.description ? { description: command.description } : {}) + })) + } +} diff --git a/src/main/pi/rpc-prompt-delivery.test.ts b/src/main/pi/rpc-prompt-delivery.test.ts new file mode 100644 index 00000000000..471f9ecf0bc --- /dev/null +++ b/src/main/pi/rpc-prompt-delivery.test.ts @@ -0,0 +1,125 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { PiRpcPromptDelivery } from './rpc-prompt-delivery' + +const deliveries: PiRpcPromptDelivery[] = [] +afterEach(() => { + for (const delivery of deliveries.splice(0)) { + delivery.end() + } + vi.useRealTimers() +}) +function rig() { + const send = vi.fn(async () => {}), + accepted = vi.fn(), + settled = vi.fn(), + beforeWrite = vi.fn() + const delivery = new PiRpcPromptDelivery({ + send, + accepted, + settled, + beforeWrite, + commandOnly: vi.fn(), + rejectedAfterAcceptance: vi.fn(), + failed: vi.fn() + }) + deliveries.push(delivery) + return { delivery, send, accepted, settled, beforeWrite } +} +const queued = { + type: 'response', + command: 'prompt', + success: true, + data: { disposition: 'queued' } +} + +describe('Pi pending prompt delivery bounds and ownership', () => { + it('bounds queued prompts even after their acknowledgements arrive', async () => { + const h = rig() + for (let i = 0; i < 128; i++) { + await h.delivery.submit(`prompt:${i}`, i, { type: 'prompt', message: 'queued' }) + h.delivery.reply(queued) + } + const before = vi.fn(async () => {}) + expect( + await h.delivery.submit('overflow', 129, { type: 'prompt', message: 'overflow' }, before) + ).toMatchObject({ state: 'rejected', rejection: { kind: 'queueFull' } }) + expect(before).not.toHaveBeenCalled() + expect(h.send).toHaveBeenCalledTimes(128) + h.delivery.consumeNext() + await expect( + h.delivery.submit('after-consumption', 130, { type: 'prompt', message: 'next' }) + ).resolves.toEqual({ state: 'admitted' }) + }) + + it('bounds retained retry payload bytes before journal dispatch admission', async () => { + const h = rig() + const frame = { type: 'prompt', message: 'x'.repeat(8 * 1024 * 1024 - 128) } + for (let i = 0; i < 4; i++) { + await h.delivery.submit(`large:${i}`, i, frame) + } + const before = vi.fn(async () => {}) + expect(await h.delivery.submit('overflow', 5, frame, before)).toMatchObject({ + state: 'rejected', + rejection: { kind: 'queueFull' } + }) + expect(before).not.toHaveBeenCalled() + h.delivery.consumeNext() + await expect(h.delivery.submit('fits-after-consumption', 6, frame)).resolves.toEqual({ + state: 'admitted' + }) + }) + + it('does not consume an input whose dispatch commit has not finished', async () => { + const h = rig() + const pending = Promise.withResolvers() + const submitted = h.delivery.submit( + 'pending', + 1, + { type: 'prompt', message: 'pending' }, + () => pending.promise + ) + expect(h.delivery.consumeNext()).toBe(false) + expect(h.beforeWrite).not.toHaveBeenCalled() + pending.resolve() + await submitted + expect(h.delivery.consumeNext()).toBe(true) + expect(h.accepted).toHaveBeenCalledWith('pending', 1) + }) + + it('associates a later run with the prompt actually written while an auth retry waits', async () => { + vi.useFakeTimers() + const h = rig() + await h.delivery.submit('retrying', 1, { type: 'prompt', message: 'first' }) + h.delivery.reply({ + type: 'response', + command: 'prompt', + success: false, + error: 'No API key found for provider' + }) + await h.delivery.submit('second', 2, { type: 'prompt', message: 'second' }) + h.delivery.consumeNext() + expect(h.accepted).toHaveBeenCalledExactlyOnceWith('second', 2) + h.delivery.reply(queued) + await vi.advanceTimersByTimeAsync(250) + h.delivery.consumeNext() + expect(h.accepted).toHaveBeenLastCalledWith('retrying', 1) + }) + + it('settles a pending auth retry as unknown on process teardown', async () => { + vi.useFakeTimers() + const h = rig() + await h.delivery.submit('retrying', 1, { type: 'prompt', message: 'first' }) + h.delivery.reply({ + type: 'response', + command: 'prompt', + success: false, + error: 'No API key found for provider' + }) + h.delivery.end() + expect(h.settled).toHaveBeenCalledWith( + 'retrying', + expect.objectContaining({ state: 'unknown' }) + ) + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/src/main/pi/rpc-prompt-delivery.ts b/src/main/pi/rpc-prompt-delivery.ts new file mode 100644 index 00000000000..eef0f51c0bf --- /dev/null +++ b/src/main/pi/rpc-prompt-delivery.ts @@ -0,0 +1,203 @@ +import { agentSessionFailureFact, providerDiagnostic } from '../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' +import type { AgentSessionDispatchOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { JsonlRpcRecord } from '../jsonl-rpc/peer' +import { piRpcPromptReplySchema } from './rpc-protocol' + +type Submission = { + id: string + at: number + frame?: JsonlRpcRecord + bytes: number + accepted: boolean + retries: number +} +type DeliveryDeps = { + send: (frame: JsonlRpcRecord) => Promise + accepted: (id: string, at: number) => void + commandOnly: () => void + rejectedAfterAcceptance: (error: string) => void + settled: (id: string, outcome: AgentSessionDispatchOutcome) => void + failed: (error: Error) => void + beforeWrite?: () => void + refused?: () => void +} + +/** Idless prompt acknowledgements are FIFO and may wait indefinitely on extension dialogs. */ +export class PiRpcPromptDelivery { + private readonly acknowledgements: Submission[] = [] + private readonly waiting: Submission[] = [] + private readonly retries = new Set>() + private readonly pending = new Set() + private heldBytes = 0 + private ended = false + constructor(private readonly deps: DeliveryDeps) {} + get holdsDispatch(): boolean { + return this.waiting.length > 0 || this.retries.size > 0 + } + + async submit( + id: string, + at: number, + frame: JsonlRpcRecord, + before?: () => Promise + ): Promise { + if (this.ended) { + throw new Error('Pi prompt queue unavailable') + } + const bytes = Buffer.byteLength(JSON.stringify(frame)) + if (this.pending.size >= 128 || this.heldBytes + bytes > 32 * 1024 * 1024) { + return { + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('queueFull'), { + agentName: 'Pi', + surface: 'rejection' + }) + } + } + const submission: Submission = { id, at, frame, bytes, accepted: false, retries: 0 } + this.pending.add(submission) + this.heldBytes += bytes + try { + await before?.() + if (this.ended) { + throw new Error('Pi ended before writing the prompt') + } + this.deps.beforeWrite?.() + this.acknowledgements.push(submission) + this.waiting.push(submission) + await this.deps.send(frame) + return { state: 'admitted' } + } catch { + this.remove(submission) + this.deps.refused?.() + return { state: 'unknown', reason: 'Pi prompt write did not settle' } + } + } + + consumeNext(): boolean { + const submission = this.waiting[0] + if (!submission) { + return false + } + this.accept(submission) + return true + } + + reply(frame: JsonlRpcRecord): void { + const reply = piRpcPromptReplySchema.parse(frame) + const submission = this.acknowledgements.shift() + if (!submission) { + throw new Error('Pi prompt reply has no request') + } + if (!reply.success) { + if ( + !submission.accepted && + reply.error?.startsWith('No API key found for ') && + submission.retries++ < 8 + ) { + this.removeWaiting(submission) + const timer = setTimeout(() => { + this.retries.delete(timer) + if (this.ended) { + return + } + const frame = submission.frame + if (!frame) { + this.deps.failed(new Error('Pi retry lost its unaccepted prompt')) + return + } + this.acknowledgements.push(submission) + this.waiting.push(submission) + void this.deps + .send(frame) + .catch((error: unknown) => + this.deps.failed(error instanceof Error ? error : new Error(String(error))) + ) + }, 250) + timer.unref() + this.retries.add(timer) + return + } + this.remove(submission) + const fact = agentSessionFailureFact( + reply.error?.startsWith('No API key found for ') ? 'notSignedIn' : 'providerRejected', + { detail: providerDiagnostic(reply.error ?? 'Pi rejected the prompt', 'person') } + ) + this.deps.settled(submission.id, { + state: 'rejected', + ...agentSessionFailureWords(fact, { agentName: 'Pi', surface: 'rejection' }) + }) + if (submission.accepted) { + this.deps.rejectedAfterAcceptance(reply.error ?? 'Pi rejected the prompt') + } else { + this.deps.refused?.() + } + return + } + this.releaseFrame(submission) + if (reply.data?.disposition === 'handled' || reply.data?.agentInvoked === false) { + this.accept(submission) + this.deps.commandOnly() + } + this.finishIfConsumed(submission) + } + + end(): void { + this.ended = true + for (const timer of this.retries) { + clearTimeout(timer) + } + this.retries.clear() + for (const submission of this.pending) { + if (!submission.accepted) { + this.deps.settled(submission.id, { + state: 'unknown', + reason: 'Pi ended before confirming delivery' + }) + } + this.releaseFrame(submission) + } + this.waiting.length = 0 + this.acknowledgements.length = 0 + this.pending.clear() + } + + private accept(submission: Submission): void { + if (submission.accepted) { + return + } + submission.accepted = true + this.removeWaiting(submission) + this.releaseFrame(submission) + this.finishIfConsumed(submission) + this.deps.accepted(submission.id, submission.at) + } + private remove(submission: Submission): void { + for (const list of [this.waiting, this.acknowledgements]) { + const index = list.indexOf(submission) + if (index !== -1) { + list.splice(index, 1) + } + } + this.releaseFrame(submission) + this.pending.delete(submission) + } + private removeWaiting(submission: Submission): void { + const index = this.waiting.indexOf(submission) + if (index !== -1) { + this.waiting.splice(index, 1) + } + } + private releaseFrame(submission: Submission): void { + if (submission.frame) { + this.heldBytes -= submission.bytes + submission.frame = undefined + } + } + private finishIfConsumed(submission: Submission): void { + if (!this.waiting.includes(submission) && !this.acknowledgements.includes(submission)) { + this.pending.delete(submission) + } + } +} diff --git a/src/main/pi/rpc-prompt.test.ts b/src/main/pi/rpc-prompt.test.ts new file mode 100644 index 00000000000..f73dd02e550 --- /dev/null +++ b/src/main/pi/rpc-prompt.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import { PiRpcPromptError, preparePiRpcPrompt } from './rpc-prompt' + +const PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/lXcAAAAASUVORK5CYII=', + 'base64' +) + +function message(blocks: AgentJournalMessageItem['blocks']): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks } +} + +describe('Pi RPC prompt preparation', () => { + it('encodes host-read image bytes and sends steer/followUp as prompt behavior', async () => { + const readLocal = vi.fn(async () => PNG) + const body = message([ + { type: 'text', text: 'Look at this' }, + { type: 'image-ref', path: '/workspace/screenshot.png' }, + { type: 'text', text: 'then reply' } + ]) + expect(await preparePiRpcPrompt(body, 'steer', { readLocal })).toEqual({ + type: 'prompt', + message: 'Look at this\nthen reply', + streamingBehavior: 'steer', + images: [{ type: 'image', data: PNG.toString('base64'), mimeType: 'image/png' }] + }) + expect(readLocal).toHaveBeenCalledWith('/workspace/screenshot.png') + expect( + (await preparePiRpcPrompt(message([{ type: 'text', text: 'next' }]), 'followUp')) + .streamingBehavior + ).toBe('followUp') + }) + + it('rejects URL refs with a typed unsupported attachment failure', async () => { + const readLocal = vi.fn(async () => PNG) + const rejected = await preparePiRpcPrompt( + message([{ type: 'image-ref', url: 'https://example.test/image.png' }]), + undefined, + { readLocal } + ).catch((error: unknown) => error) + expect(rejected).toBeInstanceOf(PiRpcPromptError) + expect(rejected).toMatchObject({ + failure: { kind: 'attachmentInvalid', attachment: { reason: 'unsupportedType' } } + }) + expect(readLocal).not.toHaveBeenCalled() + }) + + it('checks local image MIME against bytes and rejects empty prompts', async () => { + const readLocal = vi.fn(async () => PNG) + await expect( + preparePiRpcPrompt(message([{ type: 'image-ref', path: '/a.jpg' }]), undefined, { readLocal }) + ).rejects.toThrow() + await expect(preparePiRpcPrompt(message([{ type: 'text', text: '' }]))).rejects.toThrow() + }) + + it('rejects a prompt beyond the RPC writer budget', async () => { + const oversizedText = 'x'.repeat(32 * 1024 * 1024) + await expect( + preparePiRpcPrompt(message([{ type: 'text', text: oversizedText }])) + ).rejects.toThrow('RPC write limit') + }) +}) diff --git a/src/main/pi/rpc-prompt.ts b/src/main/pi/rpc-prompt.ts new file mode 100644 index 00000000000..655c96167c5 --- /dev/null +++ b/src/main/pi/rpc-prompt.ts @@ -0,0 +1,174 @@ +import { extname } from 'node:path' +import { resolveJsonlRpcPeerOptions } from '../jsonl-rpc/peer-limits' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import type { NativeChatImageRefBlock } from '../../shared/native-chat-types' +import { + agentSessionFailureFact, + type AgentSessionAttachmentProblem, + type SubmissionRejectionFact +} from '../../shared/agent-session-failure' +import { + ClaudeDispatchContentError, + readClaudeImage +} from '../claude/claude-structured-dispatch-content' + +const MAX_IMAGE_BYTES = 5 * 1024 * 1024 +const MAX_IMAGE_COUNT = 20 +const MAX_TOTAL_IMAGE_BYTES = 20 * 1024 * 1024 +const MAX_PROMPT_WIRE_BYTES = resolveJsonlRpcPeerOptions().maxLineBytes - 1 +const MIME_BY_EXTENSION: Readonly> = { + '.gif': 'image/gif', + '.jpeg': 'image/jpeg', + '.jpg': 'image/jpeg', + '.png': 'image/png', + '.webp': 'image/webp' +} + +export type PiRpcPrompt = Record & { + type: 'prompt' + message: string + images: { type: 'image'; data: string; mimeType: string }[] + streamingBehavior?: 'steer' | 'followUp' +} + +type ImageReads = { + readLocal(path: string): Promise +} + +export class PiRpcPromptError extends Error { + constructor( + message: string, + readonly failure: SubmissionRejectionFact + ) { + super(message) + this.name = 'PiRpcPromptError' + } +} + +function attachmentError( + message: string, + attachment: AgentSessionAttachmentProblem +): PiRpcPromptError { + return new PiRpcPromptError(message, agentSessionFailureFact('attachmentInvalid', { attachment })) +} + +function imageMime(bytes: Uint8Array): string | null { + if ( + bytes.length >= 8 && + Buffer.from(bytes.subarray(0, 8)).equals(Buffer.from('89504e470d0a1a0a', 'hex')) + ) { + return 'image/png' + } + if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) { + return 'image/jpeg' + } + if ( + bytes.length >= 6 && + Buffer.from(bytes.subarray(0, 6)) + .toString('ascii') + .match(/^GIF8[79]a$/) + ) { + return 'image/gif' + } + if ( + bytes.length >= 12 && + Buffer.from(bytes.subarray(0, 4)).toString('ascii') === 'RIFF' && + Buffer.from(bytes.subarray(8, 12)).toString('ascii') === 'WEBP' + ) { + return 'image/webp' + } + return null +} + +async function readImage( + block: NativeChatImageRefBlock, + reads: ImageReads +): Promise<{ bytes: Buffer; mimeType: string }> { + if (block.url) { + throw attachmentError('Pi does not accept image URLs', { reason: 'unsupportedType' }) + } + if (!block.path) { + throw attachmentError('Pi image needs a local path', { reason: 'noSource' }) + } + const mimeType = MIME_BY_EXTENSION[extname(block.path).toLowerCase()] + if (!mimeType) { + throw attachmentError('Pi image file type is unsupported', { reason: 'unsupportedType' }) + } + let bytes: Buffer + try { + bytes = await reads.readLocal(block.path) + } catch (error) { + if (error instanceof ClaudeDispatchContentError) { + throw new PiRpcPromptError('Pi image could not be read', error.failure) + } + throw new PiRpcPromptError( + 'Pi image could not be read', + agentSessionFailureFact('attachmentUnreadable') + ) + } + if (bytes.byteLength === 0) { + throw attachmentError('Pi image is empty', { reason: 'empty' }) + } + if (bytes.byteLength > MAX_IMAGE_BYTES) { + throw attachmentError('Pi image exceeds the size limit', { + reason: 'tooLarge', + limit: MAX_IMAGE_BYTES + }) + } + if (imageMime(bytes) !== mimeType) { + throw attachmentError('Pi image file did not contain a supported image', { + reason: 'unsupportedType' + }) + } + return { bytes, mimeType } +} + +export async function preparePiRpcPrompt( + body: AgentJournalMessageItem, + streamingBehavior?: PiRpcPrompt['streamingBehavior'], + reads: ImageReads = { readLocal: readClaudeImage } +): Promise { + if (body.role !== 'user') { + throw new Error('Pi prompt must be a user message') + } + const texts: string[] = [] + const images: PiRpcPrompt['images'] = [] + let totalImageBytes = 0 + for (const block of body.blocks) { + if (block.type === 'text' && block.text.length > 0) { + texts.push(block.text) + } else if (block.type === 'image-ref') { + if (images.length >= MAX_IMAGE_COUNT) { + throw attachmentError('Pi prompt has too many images', { + reason: 'tooMany', + limit: MAX_IMAGE_COUNT + }) + } + const { bytes, mimeType } = await readImage(block, reads) + totalImageBytes += bytes.byteLength + if (totalImageBytes > MAX_TOTAL_IMAGE_BYTES) { + throw attachmentError('Pi images exceed the total size limit', { + reason: 'totalTooLarge', + limit: MAX_TOTAL_IMAGE_BYTES + }) + } + images.push({ type: 'image', data: bytes.toString('base64'), mimeType }) + } + } + if (texts.length === 0 && images.length === 0) { + throw new PiRpcPromptError('Pi prompt is empty', agentSessionFailureFact('emptyMessage')) + } + const prompt: PiRpcPrompt = { + type: 'prompt', + message: texts.join('\n'), + images, + ...(streamingBehavior ? { streamingBehavior } : {}) + } + if (Buffer.byteLength(JSON.stringify(prompt), 'utf8') > MAX_PROMPT_WIRE_BYTES) { + throw new PiRpcPromptError( + 'Pi prompt exceeds the RPC write limit', + agentSessionFailureFact('historyTooLarge') + ) + } + return prompt +} diff --git a/src/main/pi/rpc-protocol.ts b/src/main/pi/rpc-protocol.ts new file mode 100644 index 00000000000..b4aeab23a4a --- /dev/null +++ b/src/main/pi/rpc-protocol.ts @@ -0,0 +1,109 @@ +import { z } from 'zod' + +export const piRpcModelSchema = z.looseObject({ + id: z.string(), + provider: z.string(), + name: z.string().optional(), + contextWindow: z.number().nonnegative().optional(), + reasoning: z.boolean().optional(), + input: z.array(z.string()).optional() +}) +export type PiRpcModel = z.infer + +export const piRpcStateSchema = z.looseObject({ + model: piRpcModelSchema.nullish(), + thinkingLevel: z.string().optional(), + sessionFile: z.string().min(1), + sessionId: z.string().optional(), + isStreaming: z.boolean(), + isCompacting: z.boolean(), + pendingMessageCount: z.number().int().nonnegative().optional() +}) +export type PiRpcState = z.infer + +export function piRpcIdle(state: PiRpcState): boolean { + return !state.isStreaming && !state.isCompacting && (state.pendingMessageCount ?? 0) === 0 +} + +export const piRpcContentSchema = z.looseObject({ + type: z.string(), + text: z.string().optional(), + thinking: z.string().optional() +}) +export const piRpcUsageSchema = z.object({ + input: z.number().nonnegative(), + output: z.number().nonnegative(), + cacheRead: z.number().nonnegative(), + cacheWrite: z.number().nonnegative() +}) +export const piRpcMessageSchema = z.looseObject({ + role: z.string(), + content: z.union([z.string(), z.array(piRpcContentSchema)]), + stopReason: z.string().optional(), + errorMessage: z.string().optional(), + usage: piRpcUsageSchema.optional(), + timestamp: z.number().optional() +}) +export const piRpcMessageEventSchema = z.object({ + type: z.enum(['message_start', 'message_update', 'message_end']), + message: piRpcMessageSchema.optional(), + usage: piRpcUsageSchema.optional(), + assistantMessageEvent: z + .looseObject({ + type: z.string(), + delta: z.string().optional(), + contentIndex: z.number().int().nonnegative().optional(), + partial: piRpcMessageSchema.optional() + }) + .optional() +}) +export type PiRpcMessageEvent = z.infer + +export const piRpcToolEventSchema = z.object({ + type: z.enum(['tool_execution_start', 'tool_execution_update', 'tool_execution_end']), + toolCallId: z.string(), + toolName: z.string(), + args: z.unknown().optional(), + partialResult: z.unknown().optional(), + result: z.unknown().optional(), + isError: z.boolean().optional() +}) +export type PiRpcToolEvent = z.infer + +export const piRpcPromptReplySchema = z.object({ + type: z.literal('response'), + command: z.literal('prompt'), + success: z.boolean(), + error: z.string().optional(), + data: z + .object({ + disposition: z.enum(['started', 'queued', 'handled']).optional(), + agentInvoked: z.boolean().optional() + }) + .optional() +}) + +export const piRpcDialogSchema = z.discriminatedUnion('method', [ + z.object({ + id: z.union([z.string(), z.number()]), + method: z.literal('confirm'), + title: z.string(), + message: z.string().optional() + }), + z.object({ + id: z.union([z.string(), z.number()]), + method: z.literal('select'), + title: z.string(), + message: z.string().optional(), + options: z.array(z.string()).min(1).max(64) + }), + z.object({ + id: z.union([z.string(), z.number()]), + method: z.enum(['input', 'editor']), + title: z.string(), + message: z.string().optional(), + placeholder: z.string().optional(), + prefill: z.string().optional() + }) +]) +export type PiRpcDialog = z.infer diff --git a/src/main/pi/rpc-retry-activity.ts b/src/main/pi/rpc-retry-activity.ts new file mode 100644 index 00000000000..a5b9713dd4c --- /dev/null +++ b/src/main/pi/rpc-retry-activity.ts @@ -0,0 +1,23 @@ +import { agentSessionFailureFact, providerDiagnostic } from '../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' +import type { JsonlRpcRecord } from '../jsonl-rpc/peer' +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' + +/** Retries occupy the existing activity surface until the provider resumes or exhausts them. */ +export function piRpcRetryActivity(frame: JsonlRpcRecord): ProviderTimelineEvent { + return { + type: 'activity', + text: + frame.type === 'auto_retry_start' + ? agentSessionFailureWords( + agentSessionFailureFact('providerRetrying', { + detail: + typeof frame.errorMessage === 'string' + ? providerDiagnostic(frame.errorMessage, 'person') + : undefined + }), + { agentName: 'Pi', surface: 'row' } + ).text + : null + } +} diff --git a/src/main/pi/rpc-runtime-registration.test.ts b/src/main/pi/rpc-runtime-registration.test.ts new file mode 100644 index 00000000000..b993c63db6a --- /dev/null +++ b/src/main/pi/rpc-runtime-registration.test.ts @@ -0,0 +1,107 @@ +import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import { PI_RPC_RUNTIME_REGISTRATION } from './rpc-runtime-registration' + +vi.mock('../../shared/child-process/run-process', () => ({ runProcess: vi.fn() })) + +type LaunchInput = Parameters>[0] +function supportsLaunch(launch: LaunchInput): Promise { + const check = PI_RPC_RUNTIME_REGISTRATION.supportsLaunch + if (!check) { + throw new Error('Pi registers a launch check') + } + return check(launch) +} +let root: string +let stockPi: string + +function prints(stdout: string, result: Partial>> = {}) { + vi.mocked(runProcess).mockResolvedValue({ + code: 0, + signal: null, + timedOut: false, + stdout, + stderr: '', + ...result + }) +} + +beforeEach(async () => { + vi.mocked(runProcess).mockReset() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.spyOn(console, 'info').mockImplementation(() => {}) + root = await mkdtemp(join(tmpdir(), 'orca-pi-support-')) + stockPi = join(root, process.platform === 'win32' ? 'pi.cmd' : 'pi') + await writeFile(stockPi, '') + await chmod(stockPi, 0o755) +}) +afterEach(async () => { + vi.restoreAllMocks() + await rm(root, { recursive: true, force: true }) +}) + +const input = (commandSettings: LaunchInput['commandSettings'] = {}): LaunchInput => ({ + cwd: '/host/workspace', + env: { PATH: root, HOME: '/host/home' }, + commandSettings +}) + +describe('Pi launch support before session creation', () => { + it.each(['0.84.0', '0.99.0', '1.0.0', '1.0.4\n', 'pi v1.12.3'])( + 'accepts stable Pi from 0.84.0: %s', + async (out) => { + prints(out) + await expect(supportsLaunch(input())).resolves.toBe(true) + expect(runProcess).toHaveBeenCalledWith({ + program: stockPi, + cwd: '/host/workspace', + env: { PATH: root, HOME: '/host/home' }, + args: ['--version'], + timeoutMs: 5_000, + maxOutputBytes: 4_096, + killOnOutputLimit: true + }) + } + ) + + it.each([ + '0.73.1', + '0.83.9', + '0.84.0-rc.1', + '1.0.0-rc.1', + '1.1.0-preview.1', + '2.0.0', + 'unknown', + '' + ])('keeps the terminal chat for an older, prerelease or unknown version: %s', async (out) => { + prints(out) + await expect(supportsLaunch(input())).resolves.toBe(false) + }) + + it.each([ + { code: 1, timedOut: false, outputTruncated: false }, + { code: 0, timedOut: true, outputTruncated: false }, + { code: 0, timedOut: false, outputTruncated: true } + ])('does not infer support from incomplete or failed output: %j', async (result) => { + prints('1.0.4', result) + await expect(supportsLaunch(input())).resolves.toBe(false) + }) + + it('probes the binary the Command setting names, not the stock one', async () => { + prints('1.0.4') + await expect( + supportsLaunch(input({ agentCmdOverrides: { pi: `"${process.execPath}"` } })) + ).resolves.toBe(true) + expect(runProcess).toHaveBeenCalledWith(expect.objectContaining({ program: process.execPath })) + }) + + it('leaves an unrunnable Command setting to the launch to refuse', async () => { + await expect(supportsLaunch(input({ agentCmdOverrides: { pi: '/missing/pi' } }))).resolves.toBe( + true + ) + expect(runProcess).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/pi/rpc-runtime-registration.ts b/src/main/pi/rpc-runtime-registration.ts new file mode 100644 index 00000000000..fca0a4b411f --- /dev/null +++ b/src/main/pi/rpc-runtime-registration.ts @@ -0,0 +1,73 @@ +import { homedir } from 'node:os' +import { join } from 'node:path' +import { agentSessionAccountHome } from '../../shared/agent-session-account-home' +import { isAgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { supportsSupervisedProviderChildLocation } from '../provider-process/supervised-provider-child-location' +import type { + StructuredAgentAdapterContext, + StructuredAgentRuntimeAdapter, + StructuredAgentRuntimeRegistration +} from '../runtime/structured-agent-runtime-registrations' +import { PI_RPC_AGENT } from './rpc-agent-definition' +import { + createPiRpcLaunchResolver, + piRpcVersionSupported, + resolvePiRpcCommand +} from './rpc-launch-resolution' +import { PiRpcSessionAdapter } from './rpc-session-adapter' + +function createPiRpcAdapter(context: StructuredAgentAdapterContext): StructuredAgentRuntimeAdapter { + const { deps } = context + return new PiRpcSessionAdapter({ + resolveLaunch: createPiRpcLaunchResolver({ + store: context.store, + resolveWorkspacePath: deps.resolveWorkspacePath, + resolveEnvironment: async () => ({ + ...(await context.environment.resolveBaseEnvironment()), + ...deps.resolveAgentLaunchEnv?.('pi') + }), + ...(deps.resolveAgentCommandSettings + ? { resolveCommandSettings: deps.resolveAgentCommandSettings } + : {}) + }), + ...(deps.openPiConnection ? { openConnection: deps.openPiConnection } : {}), + ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), + onLifecycle: context.deliverLifecycle, + onSettled: ({ sessionId, clientMessageId, outcome }) => { + if (outcome.state === 'admitted') { + return + } + context.followUps.onDispatchSettledLate({ + sessionId, + clientMessageId, + ...(outcome.state === 'accepted' ? { providerIdentity: outcome.providerIdentity } : outcome) + }) + }, + onIdle: context.followUps.releaseUnansweredDispatches, + logger: deps.logger + }) +} + +export const PI_RPC_RUNTIME_REGISTRATION: StructuredAgentRuntimeRegistration = { + definition: PI_RPC_AGENT, + createAdapter: createPiRpcAdapter, + supportsLocation: supportsSupervisedProviderChildLocation, + supportsLaunch: async ({ cwd, env, commandSettings }) => { + let command: string + try { + command = resolvePiRpcCommand(env, commandSettings) + } catch (error) { + // An unrunnable Command setting is the launch's refusal to state, not a terminal. + if (isAgentSessionPreSpawnError(error)) { + return true + } + throw error + } + return piRpcVersionSupported({ command, cwd, env }) + }, + resolveAccountHome: async ({ launchEnv }) => + agentSessionAccountHome( + PI_RPC_AGENT, + launchEnv.PI_CODING_AGENT_DIR?.trim() || join(homedir(), '.pi', 'agent') + ) +} diff --git a/src/main/pi/rpc-session-adapter.test.ts b/src/main/pi/rpc-session-adapter.test.ts new file mode 100644 index 00000000000..023e972a448 --- /dev/null +++ b/src/main/pi/rpc-session-adapter.test.ts @@ -0,0 +1,562 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' +import { agentJournalTurnBody } from '../../shared/agent-session-turn-record' +import { + AgentSessionAcquisitionRootExitObservedError, + type StructuredAgentSessionAcquireInput +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { + closeProviderTimelineRigs, + openProviderTimelineRig +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import type { JsonlRpcAgentConnectionOptions } from '../jsonl-rpc/agent-connection' +import type { JsonlRpcRecord } from '../jsonl-rpc/peer' +import { JsonlRpcResponseError } from '../jsonl-rpc/peer' +import type { PiRpcConnection } from './rpc-session' +import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { PiRpcSessionAdapter } from './rpc-session-adapter' + +const sessionId = 'session-timeline' +const file = '/host/account/sessions/session.jsonl' +const state = { + sessionFile: file, + isStreaming: false, + isCompacting: false, + model: { provider: 'anthropic', id: 'model-1' } +} +const cleanups: (() => Promise)[] = [] +afterEach(async () => { + for (const cleanup of cleanups.splice(0)) { + await cleanup() + } + await closeProviderTimelineRigs() +}) + +class FakeConnection implements PiRpcConnection { + pid = 4123 + closed = false + rootVerdict: 'live' | 'unverifiable' | 'exited' = 'live' + processless = false + deferExit = false + lastCloseResult: PiRpcConnection['lastCloseResult'] = null + closeResult: Awaited> = { root: 'exited', tree: 'exited' } + readonly sent: JsonlRpcRecord[] = [] + readonly requests: string[] = [] + private readonly exitListeners: (() => void)[] = [] + requestOverride?: (command: string) => Promise | undefined + abortRequest?: () => void + constructor(readonly handlers: JsonlRpcAgentConnectionOptions) {} + async request(command: string): Promise { + this.requests.push(command) + const override = this.requestOverride?.(command) + if (override) { + return override + } + if (command === 'get_state') { + return state + } + if (command === 'get_available_models') { + return { models: [state.model] } + } + if (command === 'get_commands') { + return { commands: [{ name: 'help' }] } + } + if (command === 'set_thinking_level') { + throw new Error('saved effort unavailable') + } + return {} + } + async send(frame: JsonlRpcRecord): Promise { + this.sent.push(frame) + } + async close(): Promise>> { + this.abortRequest?.() + this.closed = true + this.rootVerdict = this.closeResult.root + this.lastCloseResult = this.closeResult + if (this.closeResult.root === 'exited' && !this.deferExit) { + this.exit() + } + return this.closeResult + } + pauseReading(): void {} + resumeReading(): void {} + onExit(listener: () => void): void { + if (this.rootVerdict === 'exited') { + listener() + } else { + this.exitListeners.push(listener) + } + } + receive(frame: JsonlRpcRecord): void { + this.handlers.onRecord?.(frame) + } + exit(error = new Error('child exited')): void { + this.closed = true + this.rootVerdict = 'exited' + for (const listener of this.exitListeners.splice(0)) { + listener() + } + this.handlers.onExit?.(error, { + expected: false, + exit: { code: 1, signal: null, processless: false } + }) + } +} + +async function setup( + options: Readonly> = {}, + eventSink?: (sink: StructuredAgentSessionEventSink) => StructuredAgentSessionEventSink +) { + const rig = await openProviderTimelineRig({ agent: 'pi', sessionId }) + const connections: FakeConnection[] = [] + const lifecycle = vi.fn(), + settled = vi.fn(), + idle = vi.fn() + const onSpawned = vi.fn(async (_process: AgentSessionProcessIdentity) => { + expect(connections.at(-1)?.requests).toEqual([]) + }) + const input: StructuredAgentSessionAcquireInput = { + identity: { + sessionId, + workspaceId: 'folder-1', + hostId: 'local', + agent: 'pi', + providerHandle: null + }, + fence: 7, + spawnToken: 'spawn-token', + options, + events: eventSink?.(rig.eventSink) ?? rig.eventSink, + onSpawned + } + const resolveLaunch = vi.fn(async () => ({ + command: '/host/bin/pi', + cwd: '/host/folder', + fullAccess: true, + previous: null + })) + const openConnection = vi.fn( + (_launch: ProviderProcessLaunch, handlers: JsonlRpcAgentConnectionOptions) => { + const connection = new FakeConnection(handlers) + connections.push(connection) + return connection + } + ) + const adapter = new PiRpcSessionAdapter({ + resolveLaunch, + readProcessStartTime: async () => 12345, + openConnection, + onLifecycle: lifecycle, + onSettled: settled, + onIdle: idle, + logger: { warn: vi.fn(), error: vi.fn() } + }) + const acquired = await adapter.acquire(input) + const connection = connections[0] + if (!connection) { + throw new Error('connection missing') + } + cleanups.push(async () => { + await adapter.closeAll().catch(() => {}) + await adapter.drainObservedExits() + adapter.acknowledgeSessionRelease(sessionId) + }) + return { + adapter, + connection, + rig, + acquired, + onSpawned, + lifecycle, + settled, + idle, + input, + resolveLaunch, + openConnection, + connections + } +} + +describe('Pi RPC session ownership and delivery', () => { + it('does not spawn a start cancelled while resolving its workspace', async () => { + const h = await setup() + await h.adapter.closeSession(sessionId) + const launch = await h.resolveLaunch.mock.results[0]?.value + const resolving = Promise.withResolvers>() + h.resolveLaunch.mockImplementationOnce(() => resolving.promise) + const controller = new AbortController() + const started = h.adapter.acquire({ ...h.input, fence: 8, signal: controller.signal }) + await Promise.resolve() + controller.abort(new Error('Pi closed while starting')) + await expect(started).rejects.toThrow('closed while starting') + expect(h.connections).toHaveLength(1) + await expect(h.adapter.acquire({ ...h.input, fence: 9 })).resolves.toMatchObject({ + link: { origin: 'created' } + }) + resolving.resolve(launch!) + await Promise.resolve() + expect(h.connections).toHaveLength(2) + }) + + it('kills the child and rejects a stalled startup when the host aborts it', async () => { + const h = await setup() + await h.adapter.closeSession(sessionId) + const controller = new AbortController() + const opened = Promise.withResolvers() + const reply = Promise.withResolvers() + h.openConnection.mockImplementationOnce((_launch, handlers) => { + const connection = new FakeConnection(handlers) + connection.requestOverride = (command) => + command === 'get_state' ? reply.promise : undefined + connection.abortRequest = () => reply.reject(new Error('closed while starting')) + h.connections.push(connection) + opened.resolve(connection) + return connection + }) + const started = h.adapter.acquire({ ...h.input, fence: 8, signal: controller.signal }) + const child = await opened.promise + await Promise.resolve() + controller.abort(new Error('Pi closed while starting')) + await expect(started).rejects.toThrow('closed while starting') + expect(child.rootVerdict).toBe('exited') + }) + + it('retains final events when exit publication times out under journal backpressure', async () => { + let blocked = true + const h = await setup({}, (sink) => ({ + ...sink, + tryAppendTransition: (transition) => + blocked + ? { accepted: false, reason: 'backpressure' } + : sink.tryAppendTransition!(transition) + })) + vi.useFakeTimers() + h.connection.receive({ + type: 'extension_ui_request', + id: 'last-dialog', + method: 'input', + title: 'Final tail' + }) + h.connection.exit() + await vi.advanceTimersByTimeAsync(2_001) + expect(h.lifecycle).toHaveBeenCalledOnce() + h.adapter.acknowledgeSessionRelease(sessionId) + let completed = false + const drained = h.adapter.drainObservedExits().then(() => { + completed = true + }) + await Promise.resolve() + expect(completed).toBe(false) + blocked = false + await vi.advanceTimersByTimeAsync(250) + await drained + expect( + (await h.rig.rows()).some( + (row) => row.body.kind === 'question' && row.body.question === 'Final tail' + ) + ).toBe(true) + vi.useRealTimers() + }) + it('lets a new acquisition proceed while the released child drains final stdout', async () => { + const h = await setup() + h.connection.deferExit = true + await h.adapter.closeSession(sessionId) + h.adapter.acknowledgeSessionRelease(sessionId) + await expect(h.adapter.acquire({ ...h.input, fence: 8 })).resolves.toMatchObject({ + link: { origin: 'created' } + }) + let drained = false + const delivery = h.adapter.drainObservedExits().then(() => { + drained = true + }) + await Promise.resolve() + expect(drained).toBe(false) + h.connection.exit() + await delivery + expect(h.lifecycle).toHaveBeenCalledWith( + expect.objectContaining({ fence: 7, cause: 'requested-close' }) + ) + }) + + it('preserves an unexpected failure cause during forced cleanup', async () => { + const h = await setup() + await h.adapter.forceCloseSession(sessionId) + await h.adapter.drainObservedExits() + expect(h.lifecycle).toHaveBeenCalledWith( + expect.objectContaining({ cause: 'unexpected-exit', reason: 'Pi event sink failed' }) + ) + }) + it('records the spawned child before the first startup RPC and links the native file', async () => { + const h = await setup({ effort: 'high', unknown: 'old-value' }) + expect(h.onSpawned).toHaveBeenCalledWith({ + hostId: 'local', + pid: 4123, + processStartTimeMs: 12345, + spawnToken: 'spawn-token' + }) + expect(h.acquired.process).toEqual(h.onSpawned.mock.calls[0]?.[0]) + expect(h.acquired.link).toMatchObject({ + handle: { transport: 'jsonl-rpc', agent: 'pi', nativeId: file }, + origin: 'created', + mintedAtFence: 7 + }) + expect(h.adapter.readOptionRestoreFailures(sessionId)).toEqual(['effort', 'unknown']) + expect(h.connection.requests).toContain('get_commands') + }) + + it('admits a send before Pi confirms it and settles only after journal acceptance', async () => { + const h = await setup() + const result = await h.adapter.dispatch({ + sessionId, + fence: 7, + clientMessageId: 'send-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] } + }) + expect(result).toEqual({ state: 'admitted' }) + expect(h.settled).not.toHaveBeenCalled() + h.connection.receive({ type: 'agent_start' }) + expect(h.settled).toHaveBeenCalledWith({ + sessionId, + clientMessageId: 'send-1', + fence: 7, + outcome: { + state: 'accepted', + providerIdentity: { provider: 'orca', clientMessageId: 'send-1' } + } + }) + expect(h.connection.sent[0]).toMatchObject({ type: 'prompt', message: 'hello' }) + }) + + it('rejects stale fences and a Stop for a different turn before sending abort', async () => { + const h = await setup() + await expect( + h.adapter.dispatch({ + sessionId, + fence: 6, + clientMessageId: 'stale', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'x' }] } + }) + ).rejects.toThrow('not live under this fence') + await h.adapter.dispatch({ + sessionId, + fence: 7, + clientMessageId: 'send-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'x' }] } + }) + h.connection.receive({ type: 'agent_start' }) + expect( + await h.adapter.cancelTurn({ + sessionId, + fence: 7, + turnId: 'other', + resolveLiveTurnId: () => h.rig.assembler.openTurnId + }) + ).toEqual({ cancelled: false }) + expect(h.connection.requests).not.toContain('abort') + expect((await h.adapter.cancelTurn({ sessionId, fence: 7 })).cancelled).toBe(true) + expect(h.connection.requests).toContain('abort') + expect(await h.adapter.closeSession(sessionId)).toBe(true) + expect(h.connection.closed).toBe(true) + }) + + it('keeps a pending manual compaction cancellable and reports root/tree proof honestly', async () => { + const h = await setup() + const turnId = 'manual-compact' + const command = { + clientMessageId: 'compact-1', + turnId, + identity: { provider: 'orca' as const, clientMessageId: 'compact-1' }, + resultIdentity: { provider: 'orca' as const, clientMessageId: 'compact-result' }, + running: { kind: 'turn' as const, turnId, state: 'running' as const } + } + let release: (() => void) | undefined + h.connection.requestOverride = (name) => + name === 'compact' + ? new Promise((resolve) => { + release = resolve + }) + : undefined + const compact = h.adapter.compact({ sessionId, fence: 7, command }) + expect((await h.adapter.cancelTurn({ sessionId, fence: 7 })).cancelled).toBe(true) + expect(h.connection.requests).toContain('abort') + release?.() + await compact + h.connection.closeResult = { root: 'exited', tree: 'unverifiable' } + await expect(h.adapter.closeSession(sessionId)).rejects.toBeInstanceOf( + AgentSessionAcquisitionRootExitObservedError + ) + }) + + it('ends the host command row under its original identity after a no-op compaction', async () => { + const h = await setup() + const command = { + clientMessageId: 'compact-1', + turnId: 'manual-compact', + identity: { provider: 'orca' as const, clientMessageId: 'compact-turn' }, + resultIdentity: { provider: 'orca' as const, clientMessageId: 'compact-result' }, + running: { kind: 'turn' as const, turnId: 'manual-compact', state: 'running' as const } + } + h.rig.journal.appendItem(command.identity, agentJournalTurnBody(command.running), { + fence: 1, + turnScope: { kind: 'thread' } + }) + h.connection.requestOverride = (name) => + name === 'compact' + ? Promise.reject(new JsonlRpcResponseError(name, 'Nothing to compact (session too small)')) + : undefined + expect(await h.adapter.compact({ sessionId, fence: 7, command })).toEqual({ + state: 'accepted', + providerIdentity: null + }) + await Promise.resolve() + await Promise.resolve() + expect( + (await h.rig.rows()).find((row) => row.itemId === agentJournalItemKey(command.identity))?.body + ).toMatchObject({ kind: 'turn', state: 'completed', outcome: 'success' }) + expect( + (await h.rig.rows()).some((row) => row.body.kind === 'status' && row.body.tone === 'warning') + ).toBe(true) + }) + + it('answers a live dialog once after commit and does not claim it after dismissal', async () => { + const h = await setup() + h.connection.receive({ + type: 'extension_ui_request', + id: 'input-1', + method: 'editor', + title: 'Edit', + prefill: 'draft' + }) + const row = (await h.rig.rows()).find((item) => item.body.kind === 'question') + expect(row?.body).toMatchObject({ + kind: 'question', + freeTextInput: { allowEmpty: true, initialValue: 'draft' } + }) + if (!row) { + throw new Error('dialog row missing') + } + let release: (() => void) | undefined + const commit = vi.fn( + () => + new Promise((resolve) => { + release = resolve + }) + ) + const response = { + kind: 'answers' as const, + answers: [{ questionId: 'input-1', optionIds: [], other: '' }] + } + const first = h.adapter.answerPrompt({ + sessionId, + fence: 7, + itemId: row.itemId, + kind: 'question', + response, + commit + }) + await expect( + h.adapter.answerPrompt({ + sessionId, + fence: 7, + itemId: row.itemId, + kind: 'question', + response, + commit: vi.fn() + }) + ).rejects.toThrow('no longer waiting') + release?.() + await first + expect(commit).toHaveBeenCalledTimes(1) + expect(h.connection.sent).toContainEqual({ + type: 'extension_ui_response', + id: 'input-1', + value: '' + }) + await expect( + h.adapter.dismissPrompt({ + sessionId, + fence: 7, + itemId: row.itemId, + answer: true, + commit: vi.fn() + }) + ).rejects.toThrow('no longer waiting') + }) + + it('releases a failed dialog commit for a later dismissal, with one provider reply', async () => { + const h = await setup() + h.connection.receive({ + type: 'extension_ui_request', + id: 2, + method: 'confirm', + title: 'Continue?' + }) + const row = (await h.rig.rows()).find((item) => item.body.kind === 'approval') + if (!row) { + throw new Error('approval row missing') + } + await expect( + h.adapter.answerPrompt({ + sessionId, + fence: 7, + itemId: row.itemId, + kind: 'approval', + response: { kind: 'option', optionId: 'yes' }, + commit: async () => { + throw new Error('CAS lost') + } + }) + ).rejects.toThrow('CAS lost') + expect(h.connection.sent).toEqual([]) + let release: (() => void) | undefined + const dismiss = h.adapter.dismissPrompt({ + sessionId, + fence: 7, + itemId: row.itemId, + answer: true, + commit: () => + new Promise((resolve) => { + release = resolve + }) + }) + await expect( + h.adapter.answerPrompt({ + sessionId, + fence: 7, + itemId: row.itemId, + kind: 'approval', + response: { kind: 'option', optionId: 'no' }, + commit: vi.fn() + }) + ).rejects.toThrow('no longer waiting') + release?.() + await dismiss + expect(h.connection.sent).toEqual([{ type: 'extension_ui_response', id: 2, cancelled: true }]) + }) + + it('reports unexpected child exit once and preserves an unproven root on close', async () => { + const h = await setup() + h.connection.exit(new Error('provider died')) + await h.adapter.drainObservedExits() + h.connection.exit(new Error('duplicate exit')) + await h.adapter.drainObservedExits() + expect(h.lifecycle).toHaveBeenCalledTimes(1) + expect(h.lifecycle).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'ended', + sessionId, + fence: 7, + acquisitionGeneration: h.acquired.acquisitionGeneration, + cause: 'unexpected-exit', + reason: 'provider died' + }) + ) + + const other = await setup() + other.connection.closeResult = { root: 'unverifiable', tree: null } + expect(await other.adapter.closeSession(sessionId)).toBe(false) + }) +}) diff --git a/src/main/pi/rpc-session-adapter.ts b/src/main/pi/rpc-session-adapter.ts new file mode 100644 index 00000000000..c746d15b2eb --- /dev/null +++ b/src/main/pi/rpc-session-adapter.ts @@ -0,0 +1,287 @@ +import { randomUUID } from 'node:crypto' +import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason' +import { agentSessionFailureFact } from '../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' +import { + AgentSessionPreSpawnError, + AgentSessionAcquisitionRootExitObservedError, + AgentSessionAcquisitionExitUnprovenError, + AgentSessionAcquisitionExitProvenError, + type AgentSessionAcquisition, + type StructuredAgentSessionAcquireInput, + type StructuredAgentSessionAdapter +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { providerSpawnedProcessIdentity } from '../provider-process/provider-spawned-process-identity' +import { ProviderAcquisitionStarts } from '../provider-process/provider-acquisition-starts' +import { compactPiRpcSession } from './rpc-compaction' +import { buildPiRpcLaunch } from './rpc-launch' +import { piRpcProviderLink, type PiRpcResolvedLaunch } from './rpc-launch-resolution' +import { PiRpcSession, type PiRpcSessionDeps, type PiRpcConnection } from './rpc-session' +import { PiRpcPromptError, preparePiRpcPrompt } from './rpc-prompt' +import { applyPiRpcSessionOption, readPiRpcSessionOptions } from './rpc-options' +import { supportsSupervisedProviderChildLocation } from '../provider-process/supervised-provider-child-location' +import { ClaudeDispatchContentError } from '../claude/claude-structured-dispatch-content' + +export type PiRpcSessionAdapterDeps = PiRpcSessionDeps & { + resolveLaunch: ( + identity: StructuredAgentSessionAcquireInput['identity'] + ) => Promise + readProcessStartTime?: (pid: number) => Promise +} + +export class PiRpcSessionAdapter implements StructuredAgentSessionAdapter { + private readonly sessions = new Map() + private readonly acquiring = new Set() + private readonly starts = new ProviderAcquisitionStarts() + private readonly retiring = new Set>() + constructor(private readonly deps: PiRpcSessionAdapterDeps) {} + + supportsLocation = supportsSupervisedProviderChildLocation + supportsCreate: NonNullable = ( + location, + agent + ) => agent === 'pi' && this.supportsLocation(location) + + async acquire(input: StructuredAgentSessionAcquireInput): Promise { + const id = input.identity.sessionId + if (this.acquiring.has(id)) { + throw new AgentSessionPreSpawnError(new Error('Pi session already owns a child')) + } + this.acquiring.add(id) + const attempt = this.starts.begin(input.signal) + let session: PiRpcSession | undefined + try { + if (attempt.signal.aborted) { + throw new AgentSessionPreSpawnError(new Error('Pi closed while starting')) + } + if (!(await this.starts.stopFailed(id))) { + throw new AgentSessionAcquisitionExitUnprovenError( + new Error('Previous Pi start has not exited') + ) + } + const previous = this.sessions.get(id) + if (previous?.connection.closed && previous.connection.rootVerdict === 'exited') { + this.acknowledgeSessionRelease(id) + } else if (previous) { + throw new AgentSessionPreSpawnError(new Error('Pi session already owns a child')) + } + let launch: PiRpcResolvedLaunch + try { + launch = await waitForPromiseWithSignal( + this.deps.resolveLaunch(input.identity), + attempt.signal + ) + } catch (error) { + throw new AgentSessionPreSpawnError(error) + } + if (attempt.signal.aborted) { + throw new AgentSessionPreSpawnError(new Error('Pi closed while starting')) + } + const spec = buildPiRpcLaunch({ + ...launch, + structuredSession: { id, spawnToken: input.spawnToken } + }) + session = new PiRpcSession(input, randomUUID(), spec, this.deps) + this.sessions.set(id, session) + this.starts.track(attempt, session.connection) + const spawned = providerSpawnedProcessIdentity( + input, + 'Pi RPC', + this.deps.readProcessStartTime + ) + if (session.connection.pid !== undefined) { + await spawned.onSpawned(session.connection.pid) + } + const process = await spawned.read(session.connection.pid) + const file = await waitForPromiseWithSignal(session.start(), attempt.signal) + if (session.connection.closed || attempt.signal.aborted) { + throw new Error('Pi exited while starting') + } + return { + process, + acquisitionGeneration: session.generation, + link: piRpcProviderLink(launch, file, input.fence, randomUUID(), Date.now()) + } + } catch (error) { + if (!session) { + throw error instanceof AgentSessionPreSpawnError || + error instanceof AgentSessionAcquisitionExitUnprovenError + ? error + : new AgentSessionPreSpawnError(error) + } + const result = await session.close(false).catch(() => null) + if (result?.root === 'exited') { + if (session.connection.processless) { + throw new AgentSessionAcquisitionExitProvenError(error) + } + throw new AgentSessionAcquisitionRootExitObservedError(error) + } + this.starts.retainFailed(id, session.connection) + throw new AgentSessionAcquisitionExitUnprovenError(error) + } finally { + this.starts.end(attempt) + this.acquiring.delete(id) + } + } + + dispatch: StructuredAgentSessionAdapter['dispatch'] = async (input) => { + const session = this.session(input.sessionId, input.fence) + let prompt + try { + prompt = await preparePiRpcPrompt(input.body) + } catch (error) { + this.deps.logger.warn('Pi prompt could not be prepared', { + scope: 'pi-prompt', + sessionId: input.sessionId, + error + }) + return { + state: 'rejected', + ...agentSessionFailureWords( + error instanceof PiRpcPromptError || error instanceof ClaudeDispatchContentError + ? error.failure + : agentSessionFailureFact('attachmentUnreadable'), + { agentName: 'Pi', surface: 'rejection' } + ) + } + } + return session.turns.submit( + input.clientMessageId, + input.requestedAt ?? Date.now(), + { ...prompt }, + input.beforeDispatch + ) + } + + compact: NonNullable = async (input) => + compactPiRpcSession(this.session(input.sessionId, input.fence), input.command) + + cancelTurn: StructuredAgentSessionAdapter['cancelTurn'] = async (input) => { + const session = this.session(input.sessionId, input.fence) + const turnId = input.resolveLiveTurnId ? input.resolveLiveTurnId() : session.lane.openTurnId + if (input.turnId !== undefined && input.turnId !== turnId) { + return { cancelled: false } + } + if (!turnId && !session.turns.holdsDispatch) { + return { cancelled: false } + } + session.turns.stop() + session.dialogs.cancelAll() + await session.connection.request('abort', {}, { timeoutMs: 2_000 }) + return { cancelled: true, ...(turnId ? { turnId } : {}) } + } + stopEndsSession(): boolean { + return true + } + awaitStoppedRequestEnd: NonNullable = + async (id, at) => { + const session = this.sessions.get(id) + const turn = session?.lane.openTurnId + if (!session) { + return + } + let timer: ReturnType | undefined + try { + if (turn) { + await Promise.race([ + session.lane.whenTurnLeaves(turn), + new Promise((resolve) => { + timer = setTimeout(resolve, Math.max(0, at + 2_000 - Date.now())) + timer.unref() + }) + ]) + } + } finally { + clearTimeout(timer) + } + try { + await session.connection.request('get_state', {}, { timeoutMs: 1_000 }) + } catch (error) { + this.deps.logger.warn('Pi checkpoint could not be read before stopping', { + scope: 'pi-stop-checkpoint', + sessionId: id, + error + }) + } + } + routePromptCancel(): { kind: 'dismiss' } { + return { kind: 'dismiss' } + } + dismissPrompt: NonNullable = (input) => + this.session(input.sessionId, input.fence).dialogs.respond( + input.itemId, + null, + input.commit, + input.answer + ) + answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (input) => + this.session(input.sessionId, input.fence).dialogs.respond( + input.itemId, + input.response, + input.commit + ) + setOption: StructuredAgentSessionAdapter['setOption'] = (input) => { + const session = this.session(input.sessionId, input.fence) + return applyPiRpcSessionOption(session.connection, session.selected, input.key, input.value) + } + readOptions: NonNullable = (input) => + readPiRpcSessionOptions(this.session(input.sessionId, input.fence).connection) + readCommands = (id: string) => this.sessions.get(id)?.commands + readOptionRestoreFailures(id: string): readonly string[] { + return this.sessions.get(id)?.skipped ?? [] + } + holdsDispatch(id: string): boolean { + return this.sessions.get(id)?.turns.holdsDispatch ?? false + } + + async closeSession(id: string, requested = true): Promise { + const session = this.sessions.get(id) + if (!session) { + return true + } + const result = await session.close(requested) + if (result.root !== 'exited') { + return false + } + if (result.tree !== 'exited') { + throw new AgentSessionAcquisitionRootExitObservedError(new Error('Pi root exit observed')) + } + return true + } + releaseAcquisition(input: { sessionId: string }): Promise { + return this.closeSession(input.sessionId) + } + forceCloseSession(id: string): Promise { + this.sessions.get(id)?.fail(new Error('Pi event sink failed')) + return this.closeSession(id, false) + } + disposeSession(id: string): Promise { + return this.closeSession(id) + } + acknowledgeSessionRelease(id: string): void { + const session = this.sessions.get(id) + if (!session?.connection.closed || session.connection.rootVerdict !== 'exited') { + return + } + this.sessions.delete(id) + const retirement = session.retire() + this.retiring.add(retirement) + void retirement.then(() => this.retiring.delete(retirement)) + } + async closeAll(): Promise { + await Promise.all([...this.sessions.keys()].map((id) => this.closeSession(id))) + } + async drainObservedExits(): Promise { + await Promise.all([ + ...this.retiring, + ...[...this.sessions.values()].map((session) => session.drainObservedExit()) + ]) + } + private session(id: string, fence: number): PiRpcSession { + const session = this.sessions.get(id) + if (!session || session.input.fence !== fence || session.connection.closed) { + throw new Error('Pi session is not live under this fence') + } + return session + } +} diff --git a/src/main/pi/rpc-session.ts b/src/main/pi/rpc-session.ts new file mode 100644 index 00000000000..7796ae3a102 --- /dev/null +++ b/src/main/pi/rpc-session.ts @@ -0,0 +1,273 @@ +import type { + AgentSessionOptionsResult, + AgentSessionSlashCommand +} from '../../shared/agent-session-wire' +import type { + StructuredAgentSessionAcquireInput, + StructuredAgentSessionLifecycleEvent, + AgentSessionDispatchOutcome +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { providerTimelineSink } from '../native-chat/agent-session-timeline/provider-timeline-plan' +import { JsonlRpcTimelineLane } from '../jsonl-rpc/timeline-lane' +import { + JsonlRpcAgentConnection, + type JsonlRpcAgentConnectionOptions +} from '../jsonl-rpc/agent-connection' +import type { ProviderProcessLaunch } from '../provider-process/provider-process-launch' +import { PiRpcTurns } from './rpc-turns' +import { PiRpcDialogCallbacks } from './rpc-dialog-callbacks' +import { piRpcStateSchema } from './rpc-protocol' +import { applyPiRpcSessionOption, readPiRpcCommands, readPiRpcSessionOptions } from './rpc-options' + +export type PiRpcConnection = Pick< + JsonlRpcAgentConnection, + | 'request' + | 'send' + | 'close' + | 'pauseReading' + | 'resumeReading' + | 'pid' + | 'closed' + | 'rootVerdict' + | 'processless' + | 'lastCloseResult' + | 'onExit' +> +export type PiRpcSessionDeps = { + openConnection?: ( + launch: ProviderProcessLaunch, + handlers: JsonlRpcAgentConnectionOptions + ) => PiRpcConnection + onLifecycle: (event: StructuredAgentSessionLifecycleEvent) => void + onSettled: (input: { + sessionId: string + clientMessageId: string + fence: number + outcome: AgentSessionDispatchOutcome + }) => void + onIdle: (input: { sessionId: string }) => void + logger: StructuredAgentSessionLogger +} + +/** One child owns its transport, dialect, dialogs and timeline, including shutdown retries. */ +export class PiRpcSession { + readonly connection: PiRpcConnection + readonly lane: JsonlRpcTimelineLane + readonly turns: PiRpcTurns + readonly dialogs: PiRpcDialogCallbacks + readonly selected = new Map() + readonly skipped: string[] = [] + commands?: AgentSessionSlashCommand[] + options?: AgentSessionOptionsResult + requestedClose = false + private publishedExit = false + private failedCause?: Error + private exitDelivery: Promise = Promise.resolve() + private releaseAfterExit?: () => void + + constructor( + readonly input: StructuredAgentSessionAcquireInput, + readonly generation: string, + launch: ProviderProcessLaunch, + private readonly deps: PiRpcSessionDeps + ) { + const sink = input.events && providerTimelineSink(input.events) + if (!sink) { + throw new Error('Pi structured chat requires the shared timeline sink') + } + this.lane = new JsonlRpcTimelineLane({ + sink, + sessionId: input.identity.sessionId, + agent: 'pi', + generation, + namespace: input.identity.sessionId, + pauseReading: () => this.connection?.pauseReading(), + resumeReading: () => this.connection?.resumeReading(), + onFailed: (reason) => this.fail(new Error(reason)), + onInputAccepted: (clientMessageId) => + this.settle(clientMessageId, { + state: 'accepted', + providerIdentity: { provider: 'orca', clientMessageId } + }) + }) + this.turns = new PiRpcTurns({ + lane: this.lane, + generation, + send: (frame) => this.connection.send(frame), + request: (command, params, options) => this.connection.request(command, params, options), + settled: (id, outcome) => this.settle(id, outcome), + idle: () => { + this.dialogs.cancelAll() + deps.onIdle({ sessionId: input.identity.sessionId }) + }, + failed: (error) => this.fail(error), + diagnostic: (error) => + deps.logger.warn('Pi context usage could not be read', { + scope: 'pi-context-usage', + sessionId: input.identity.sessionId, + error + }) + }) + this.dialogs = new PiRpcDialogCallbacks( + this.lane, + (frame) => this.connection.send(frame), + (error) => this.fail(error) + ) + this.connection = ( + deps.openConnection ?? ((spec, handlers) => new JsonlRpcAgentConnection(spec, handlers)) + )(launch, { + onRecord: (frame) => + frame.type === 'extension_ui_request' + ? this.dialogs.receive(frame) + : this.turns.receive(frame), + onClose: (error) => this.fail(error), + onDiagnostic: (message) => + deps.logger.warn(message, { scope: 'pi-rpc', sessionId: input.identity.sessionId }), + onExit: (error) => { + this.exitDelivery = this.exit(error) + .catch((cause: unknown) => + deps.logger.error('Pi exit publication failed', { + scope: 'pi-exit', + sessionId: input.identity.sessionId, + error: cause + }) + ) + .finally(() => this.releaseAfterExit?.()) + } + }) + } + + async start(): Promise { + const state = piRpcStateSchema.parse(await this.connection.request('get_state')) + this.lane.apply(this.turns.context.setModel(state.model ?? undefined, Date.now())) + for (const [key, value] of Object.entries(this.input.options ?? {})) { + if (!['model', 'effort'].includes(key)) { + this.skipped.push(key) + continue + } + try { + await applyPiRpcSessionOption(this.connection, this.selected, key, value) + } catch (error) { + this.skipped.push(key) + this.deps.logger.warn('Pi rejected a saved option', { + scope: 'pi-option-restore', + sessionId: this.input.identity.sessionId, + key, + error + }) + } + } + this.options = await readPiRpcSessionOptions(this.connection) + try { + this.commands = (await readPiRpcCommands(this.connection)).commands + } catch (error) { + this.deps.logger.warn('Pi commands could not be read', { + scope: 'pi-commands', + sessionId: this.input.identity.sessionId, + error + }) + } + return state.sessionFile + } + + async close(requested = true): ReturnType { + this.requestedClose ||= requested + this.dialogs.cancelAll() + return this.connection.close() + } + + fail(error: Error): void { + this.failedCause ??= error + this.deps.logger.error('Pi RPC session failed', { + scope: 'pi-rpc', + sessionId: this.input.identity.sessionId, + error + }) + void this.close(false) + .then((result) => { + if (result.root !== 'exited') { + this.deps.logger.warn('Pi process exit is unverifiable', { + scope: 'pi-stop', + sessionId: this.input.identity.sessionId + }) + } + }) + .catch((cause: unknown) => + this.deps.logger.error('Pi process cleanup failed', { + scope: 'pi-stop', + sessionId: this.input.identity.sessionId, + error: cause + }) + ) + } + + drainObservedExit(): Promise { + return this.exitDelivery + } + + retire(): Promise { + return new Promise((resolve) => { + this.releaseAfterExit = () => { + this.releaseAfterExit = undefined + void this.lane.drained().then(() => { + this.lane.dispose() + resolve() + }) + } + if (this.publishedExit) { + void this.exitDelivery.then(() => this.releaseAfterExit?.()) + } + }) + } + + private async exit(error: Error): Promise { + if (this.publishedExit) { + return + } + this.publishedExit = true + this.dialogs.cancelAll() + this.turns.end() + this.lane.finalize() + this.lane.apply([ + { type: 'session.ended', verdict: { state: 'interrupted', completedAt: Date.now() } } + ]) + this.lane.flush() + let timer: ReturnType | undefined + try { + await Promise.race([ + this.lane.drained(), + new Promise((resolve) => { + timer = setTimeout(() => { + this.deps.logger.warn('Pi final timeline did not drain before exit publication', { + scope: 'pi-exit-drain', + sessionId: this.input.identity.sessionId + }) + resolve() + }, 2_000) + timer.unref() + }) + ]) + } finally { + clearTimeout(timer) + } + this.deps.onLifecycle({ + type: 'ended', + sessionId: this.input.identity.sessionId, + fence: this.input.fence, + acquisitionGeneration: this.generation, + observedAt: Date.now(), + cause: this.requestedClose ? 'requested-close' : 'unexpected-exit', + reason: (this.failedCause ?? error).message + }) + } + + private settle(clientMessageId: string, outcome: AgentSessionDispatchOutcome): void { + this.deps.onSettled({ + sessionId: this.input.identity.sessionId, + clientMessageId, + fence: this.input.fence, + outcome + }) + } +} diff --git a/src/main/pi/rpc-turn-races.test.ts b/src/main/pi/rpc-turn-races.test.ts new file mode 100644 index 00000000000..9a17d1d6e17 --- /dev/null +++ b/src/main/pi/rpc-turn-races.test.ts @@ -0,0 +1,245 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { JsonlRpcTimelineLane } from '../jsonl-rpc/timeline-lane' +import { + closeProviderTimelineRigs, + openProviderTimelineRig +} from '../native-chat/agent-session-timeline/provider-timeline-assembler-test-support' +import { PiRpcTurns } from './rpc-turns' +import { PiRpcPromptDelivery } from './rpc-prompt-delivery' + +const state = (overrides: Record = {}) => ({ + sessionFile: '/host/session.jsonl', + isStreaming: false, + isCompacting: false, + pendingMessageCount: 0, + ...overrides +}) +const pending: (() => void)[] = [] +afterEach(async () => { + pending.splice(0).forEach((end) => end()) + await closeProviderTimelineRigs() + vi.useRealTimers() +}) + +async function setup(request = vi.fn(async (_command: string): Promise => state())) { + const rig = await openProviderTimelineRig({ agent: 'pi', sessionId: 'session-timeline' }) + const accepted = vi.fn(), + failed = vi.fn(), + idle = vi.fn(), + settled = vi.fn() + const lane = new JsonlRpcTimelineLane({ + sink: rig.sink, + sessionId: 'session-timeline', + agent: 'pi', + generation: 'generation-1', + namespace: 'session-timeline', + pauseReading: vi.fn(), + resumeReading: vi.fn(), + onInputAccepted: accepted, + onFailed: failed + }) + const send = vi.fn(async (_frame: Record) => {}) + const turns = new PiRpcTurns({ + lane, + generation: 'generation-1', + send, + request, + settled, + failed, + idle + }) + pending.push(() => { + turns.end() + lane.dispose() + }) + const start = async () => { + await turns.submit('send-1', 100, { type: 'prompt', message: 'hello' }) + turns.receive({ type: 'agent_start' }) + } + const flush = async () => { + await Promise.resolve() + await Promise.resolve() + lane.flush() + } + return { rig, lane, turns, request, accepted, failed, idle, settled, start, flush, send } +} + +describe('Pi turn settlement races', () => { + it('chooses steering after asynchronous dispatch admission finishes', async () => { + const h = await setup() + await h.turns.submit('first', 1, { type: 'prompt', message: 'first' }) + const gate = Promise.withResolvers() + const second = h.turns.submit( + 'second', + 2, + { type: 'prompt', message: 'second', streamingBehavior: 'followUp' }, + () => gate.promise + ) + h.turns.receive({ type: 'agent_start' }) + gate.resolve() + await second + expect(h.send.mock.calls[1]?.[0]).toMatchObject({ + message: 'second', + streamingBehavior: 'steer' + }) + }) + it('waits for agent_settled and a host idle probe after agent_end', async () => { + const h = await setup() + await h.start() + h.turns.receive({ type: 'agent_end' }) + await h.flush() + expect(h.request).not.toHaveBeenCalled() + expect(h.idle).not.toHaveBeenCalled() + expect(await h.rig.turns()).toHaveLength(1) + expect((await h.rig.turns())[0]?.state).toBe('running') + h.turns.receive({ type: 'agent_settled' }) + await h.flush() + expect(h.request).toHaveBeenCalledWith('get_state') + expect((await h.rig.turns())[0]?.outcome).toBe('success') + expect(h.idle).toHaveBeenCalledTimes(1) + }) + + it('invalidates an old idle probe when detached compaction starts after settlement', async () => { + let release: ((value: unknown) => void) | undefined + const request = vi.fn( + (_command: string) => + new Promise((resolve) => { + release = resolve + }) + ) + const h = await setup(request) + await h.start() + h.turns.receive({ type: 'agent_settled' }) + await Promise.resolve() + expect(request).toHaveBeenCalledTimes(1) + h.turns.receive({ type: 'auto_compaction_start' }) + release?.(state()) + await h.flush() + expect(h.idle).not.toHaveBeenCalled() + expect((await h.rig.turns())[0]?.state).toBe('running') + h.turns.receive({ type: 'auto_compaction_end' }) + await Promise.resolve() + expect(request.mock.calls.filter(([command]) => command === 'get_state')).toHaveLength(2) + release?.(state()) + await h.flush() + expect((await h.rig.turns())[0]?.outcome).toBe('success') + }) + + it('keeps a busy generation open and rejects a probe made stale by new activity', async () => { + let release: ((value: unknown) => void) | undefined + const request = vi.fn( + (_command: string) => + new Promise((resolve) => { + release = resolve + }) + ) + const h = await setup(request) + await h.start() + h.turns.receive({ type: 'agent_settled' }) + await Promise.resolve() + release?.(state({ isStreaming: true })) + await h.flush() + expect(h.idle).not.toHaveBeenCalled() + h.turns.receive({ type: 'agent_settled' }) + await Promise.resolve() + h.turns.receive({ type: 'message_update', message: { role: 'assistant', content: [] } }) + release?.(state()) + await h.flush() + expect(h.idle).not.toHaveBeenCalled() + h.turns.receive({ type: 'agent_settled' }) + await Promise.resolve() + release?.(state()) + await h.flush() + expect((await h.rig.turns())[0]?.outcome).toBe('success') + }) + + it('settles a provider-handled local command without agent events', async () => { + const h = await setup() + await h.turns.submit('local-command', 100, { type: 'prompt', message: '/help' }) + h.turns.receive({ + type: 'response', + command: 'prompt', + success: true, + data: { disposition: 'handled', agentInvoked: false } + }) + await h.flush() + expect(h.accepted).toHaveBeenCalledWith('local-command') + expect((await h.rig.turns())[0]?.outcome).toBe('success') + expect(h.idle).toHaveBeenCalledTimes(1) + }) + + it('keeps exhausted auto retry as failure, while successful retry clears it', async () => { + for (const succeeded of [false, true]) { + const h = await setup() + await h.start() + h.turns.receive({ + type: 'message_end', + message: { + role: 'assistant', + content: [], + stopReason: 'error', + errorMessage: 'provider failed' + } + }) + h.turns.receive({ type: 'auto_retry_start' }) + h.turns.receive({ type: 'auto_retry_end', success: succeeded }) + if (succeeded) { + h.turns.receive({ + type: 'message_end', + message: { role: 'assistant', content: [], stopReason: 'stop' } + }) + } + h.turns.receive({ type: 'agent_settled' }) + await h.flush() + expect((await h.rig.turns())[0]?.outcome).toBe(succeeded ? 'success' : 'failure') + } + }) +}) + +describe('Pi prompt acknowledgement retries', () => { + it('retries only the exact transient auth prefix eight times at 250 ms', async () => { + vi.useFakeTimers() + const send = vi.fn(async () => {}) + const settled = vi.fn() + const delivery = new PiRpcPromptDelivery({ + send, + settled, + accepted: vi.fn(), + commandOnly: vi.fn(), + rejectedAfterAcceptance: vi.fn(), + failed: vi.fn() + }) + await delivery.submit('send-1', 100, { type: 'prompt', message: 'hello' }) + for (let attempt = 0; attempt < 8; attempt++) { + delivery.reply({ + type: 'response', + command: 'prompt', + success: false, + error: 'No API key found for provider' + }) + await vi.advanceTimersByTimeAsync(249) + expect(send).toHaveBeenCalledTimes(attempt + 1) + await vi.advanceTimersByTimeAsync(1) + expect(send).toHaveBeenCalledTimes(attempt + 2) + } + delivery.reply({ + type: 'response', + command: 'prompt', + success: false, + error: 'No API key found for provider' + }) + await vi.advanceTimersByTimeAsync(250) + expect(send).toHaveBeenCalledTimes(9) + expect(settled).toHaveBeenCalledWith('send-1', expect.objectContaining({ state: 'rejected' })) + await delivery.submit('send-2', 101, { type: 'prompt', message: 'again' }) + delivery.reply({ + type: 'response', + command: 'prompt', + success: false, + error: 'No API key found elsewhere' + }) + await vi.advanceTimersByTimeAsync(250) + expect(send).toHaveBeenCalledTimes(10) + delivery.end() + }) +}) diff --git a/src/main/pi/rpc-turns.ts b/src/main/pi/rpc-turns.ts new file mode 100644 index 00000000000..3bb0ff959d2 --- /dev/null +++ b/src/main/pi/rpc-turns.ts @@ -0,0 +1,306 @@ +import { agentSessionFailureFact, providerDiagnostic } from '../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../shared/agent-session-failure-words' +import type { + AgentSessionDispatchOutcome, + StructuredAgentSessionCommandRun +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { ProviderTimelineEvent } from '../native-chat/agent-session-timeline/provider-timeline-event' +import type { JsonlRpcRecord } from '../jsonl-rpc/peer' +import type { JsonlRpcTimelineLane } from '../jsonl-rpc/timeline-lane' +import { PiRpcContextUsage } from './rpc-context-usage' +import { PiRpcMessages } from './rpc-messages' +import { PiRpcPromptDelivery } from './rpc-prompt-delivery' +import { PiRpcIdleCheck } from './rpc-idle-check' +import { piRpcRetryActivity } from './rpc-retry-activity' +import { piRpcMessageSchema } from './rpc-protocol' + +export type PiRpcTurnDeps = { + lane: JsonlRpcTimelineLane + generation: string + send: (frame: JsonlRpcRecord) => Promise + request: ( + command: string, + params?: Record, + options?: { timeoutMs?: number | null } + ) => Promise + settled: (id: string, outcome: AgentSessionDispatchOutcome) => void + idle: () => void + failed: (error: Error) => void + diagnostic?: (error: unknown) => void +} + +/** Prompt replies can be idless and arrive after dialogs; they have no elapsed-time deadline. */ +export class PiRpcTurns { + readonly context = new PiRpcContextUsage() + private readonly messages: PiRpcMessages + private readonly delivery: PiRpcPromptDelivery + private readonly idleCheck: PiRpcIdleCheck + private revision = 0 + private turn = 0 + private active?: string + private settling = false + private compacting = false + private stopped = false + private ended = false + private failure?: string + private lastStopReason?: string + + constructor(private readonly deps: PiRpcTurnDeps) { + this.messages = new PiRpcMessages(deps.generation, this.context) + this.idleCheck = new PiRpcIdleCheck({ + request: () => deps.request('get_state'), + current: (revision) => + !this.ended && this.settling && !this.compacting && revision === this.revision, + settled: (state) => { + this.deps.lane.apply(this.context.setModel(state.model ?? undefined, Date.now())) + this.finish() + }, + failed: deps.failed + }) + this.delivery = new PiRpcPromptDelivery({ + send: (frame) => + deps.send({ ...frame, streamingBehavior: this.working ? 'steer' : 'followUp' }), + settled: deps.settled, + failed: deps.failed, + accepted: (id, at) => { + this.open(at) + this.deps.lane.apply([{ type: 'input.accepted', clientMessageId: id, requestedAt: at }]) + }, + commandOnly: () => { + this.skipInitialUserEcho = false + this.settling = true + this.probe() + }, + rejectedAfterAcceptance: (error) => { + this.failure = error + }, + beforeWrite: () => this.touch(), + refused: () => { + if (this.active) { + this.settling = true + this.probe() + } + } + }) + } + + get holdsDispatch(): boolean { + return this.delivery.holdsDispatch + } + get working(): boolean { + return this.active !== undefined + } + + async submit( + id: string, + at: number, + frame: JsonlRpcRecord, + before?: () => Promise + ): Promise { + if (this.ended) { + throw new Error('Pi session ended before dispatch') + } + return this.delivery.submit(id, at, frame, before) + } + + receive(frame: JsonlRpcRecord): void { + if (this.ended) { + return + } + const at = Date.now() + switch (frame.type) { + case 'response': + if (frame.command === 'prompt') { + this.delivery.reply(frame) + } + return + case 'agent_start': + if (!this.active && !this.delivery.holdsDispatch) { + throw new Error('Pi started an unsolicited agent run') + } + this.touch() + this.open(at) + this.skipInitialUserEcho = this.delivery.consumeNext() + return + case 'message_start': + case 'message_update': + case 'message_end': { + this.touch() + const message = piRpcMessageSchema.safeParse(frame.message) + if (message.success && message.data.role === 'user' && frame.type === 'message_end') { + // agent_start already accepted the first prompt; user echoes consume queued inputs. + if (this.skipInitialUserEcho) { + this.skipInitialUserEcho = false + } else { + this.delivery.consumeNext() + } + } + if (message.success && message.data.role === 'assistant' && frame.type === 'message_end') { + this.lastStopReason = message.data.stopReason + this.failure = + message.data.stopReason === 'error' + ? (message.data.errorMessage ?? 'Pi request failed') + : undefined + } + this.deps.lane.apply(this.messages.message(frame, at)) + return + } + case 'tool_execution_start': + case 'tool_execution_update': + case 'tool_execution_end': + this.touch() + this.deps.lane.apply(this.messages.tool(frame)) + return + case 'turn_end': + this.refreshUsage() + break + case 'agent_end': + // This can precede automatic retry or detached compaction. + break + case 'agent_settled': + this.settling = true + this.probe() + return + case 'compaction_start': + case 'auto_compaction_start': + this.revision++ + this.compacting = true + this.deps.lane.apply(this.context.compacting()) + return + case 'compaction_end': + case 'auto_compaction_end': + this.compacting = false + this.context.compacted(frame.result) + if (frame.willRetry === true) { + this.failure = undefined + this.lastStopReason = undefined + } + this.refreshUsage() + if (this.settling) { + this.probe() + } + return + case 'auto_retry_start': + this.touch() + this.deps.lane.apply([piRpcRetryActivity(frame)]) + return + case 'auto_retry_end': + this.deps.lane.apply([piRpcRetryActivity(frame)]) + if (frame.success === true) { + this.failure = undefined + this.lastStopReason = undefined + } else { + this.failure = + typeof frame.finalError === 'string' ? frame.finalError : 'Pi auto-retry failed' + } + break + } + } + + stop(): void { + this.stopped = true + } + + beginCommand(command: StructuredAgentSessionCommandRun): void { + if (this.active) { + throw new Error('Pi is still working') + } + this.deps.lane.beginCommand(command) + this.active = command.turnId + this.failure = undefined + this.lastStopReason = undefined + this.stopped = false + this.revision++ + } + + commandCompleted(): void { + this.settling = true + this.probe() + } + + commandRejected(): void { + if (this.active) { + this.deps.lane.forgetCommand(this.active) + } + this.active = undefined + this.settling = false + this.revision++ + } + + end(): void { + this.ended = true + this.revision++ + this.idleCheck.dispose() + this.delivery.end() + } + + private skipInitialUserEcho = false + private open(at: number): void { + if (this.active) { + return + } + this.active = `run:${this.deps.generation}:${++this.turn}` + this.failure = undefined + this.lastStopReason = undefined + this.stopped = false + this.skipInitialUserEcho = true + this.deps.lane.apply([{ type: 'turn.open', turn: this.active, at }]) + } + + private probe(): void { + this.idleCheck.schedule(this.revision) + } + + private finish(): void { + if (!this.active) { + this.settling = false + return + } + const outcome = this.stopped + ? 'cancellation' + : this.failure || this.lastStopReason === 'error' + ? 'failure' + : 'success' + const events: ProviderTimelineEvent[] = [] + if (outcome === 'failure' && this.failure) { + const fact = agentSessionFailureFact('providerRejected', { + detail: providerDiagnostic(this.failure, 'person') + }) + events.push({ + type: 'item.close', + item: `error:${this.active}`, + body: { + kind: 'status', + tone: 'error', + ...agentSessionFailureWords(fact, { agentName: 'Pi', surface: 'row' }) + } + }) + } + events.push({ + type: 'turn.end', + at: Date.now(), + state: this.stopped ? 'interrupted' : 'completed', + outcome + }) + this.deps.lane.apply(events) + this.active = undefined + this.settling = false + this.messages.reset() + this.deps.idle() + } + + private refreshUsage(): void { + void this.deps + .request('get_session_stats') + .then((value) => { + if (!this.ended) { + this.deps.lane.apply(this.context.stats(value, Date.now())) + } + }) + .catch((error: unknown) => this.deps.diagnostic?.(error)) + } + private touch(): void { + this.revision++ + this.settling = false + } +} diff --git a/src/main/provider-process/provider-acquisition-starts.test.ts b/src/main/provider-process/provider-acquisition-starts.test.ts new file mode 100644 index 00000000000..b4700448fe0 --- /dev/null +++ b/src/main/provider-process/provider-acquisition-starts.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from 'vitest' +import { ProviderAcquisitionStarts } from './provider-acquisition-starts' +import type { ProviderProcessCloseResult } from './provider-process-close' + +function connection(result: boolean | ProviderProcessCloseResult = false) { + const exits: (() => void)[] = [] + return { + close: vi.fn(async (): Promise => result), + onExit: (listener: () => void) => { + exits.push(listener) + }, + exit: () => { + for (const listener of exits.splice(0)) { + listener() + } + } + } +} + +describe('provider acquisition ownership', () => { + it('stops a connection created after the host already aborted its start', async () => { + const starts = new ProviderAcquisitionStarts>() + const controller = new AbortController() + const attempt = starts.begin(controller.signal) + controller.abort() + const child = connection(true) + starts.track(attempt, child) + expect(child.close).toHaveBeenCalledOnce() + starts.end(attempt) + }) + + it('detaches startup cancellation when ownership transfers to the session', () => { + const starts = new ProviderAcquisitionStarts>() + const controller = new AbortController() + const attempt = starts.begin(controller.signal) + const child = connection(true) + starts.track(attempt, child) + starts.end(attempt) + controller.abort() + expect(child.close).not.toHaveBeenCalled() + }) + + it('retains a failed start after refused cleanup and retries that same child', async () => { + const starts = new ProviderAcquisitionStarts>() + const child = connection() + child.close + .mockResolvedValueOnce(false) + .mockRejectedValueOnce(new Error('unreachable')) + .mockResolvedValueOnce(true) + starts.retainFailed('session', child) + expect(await starts.stopFailed('session')).toBe(false) + expect(await starts.stopFailed('session')).toBe(false) + expect(starts.failedSessionIds()).toEqual(['session']) + expect(await starts.stopFailed('session')).toBe(true) + expect(starts.failedSessionIds()).toEqual([]) + expect(child.close).toHaveBeenCalledTimes(3) + }) + + it('releases ownership on root proof while descendant cleanup remains reported by the connection', async () => { + const starts = new ProviderAcquisitionStarts>() + const child = connection({ root: 'unverifiable', tree: 'unverifiable' }) + child.close + .mockResolvedValueOnce({ root: 'unverifiable', tree: 'exited' }) + .mockResolvedValueOnce({ root: 'exited', tree: 'unverifiable' }) + starts.retainFailed('session', child) + expect(await starts.stopFailed('session')).toBe(false) + expect(await starts.stopFailed('session')).toBe(true) + expect(starts.failedSessionIds()).toEqual([]) + }) + + it('does not let a previous child exit erase a newer failed owner', () => { + const starts = new ProviderAcquisitionStarts>() + const old = connection(), + current = connection() + starts.retainFailed('session', old) + starts.retainFailed('session', current) + old.exit() + expect(starts.failedSessionIds()).toEqual(['session']) + current.exit() + expect(starts.failedSessionIds()).toEqual([]) + }) +}) diff --git a/src/main/acp/acp-structured-starts.ts b/src/main/provider-process/provider-acquisition-starts.ts similarity index 69% rename from src/main/acp/acp-structured-starts.ts rename to src/main/provider-process/provider-acquisition-starts.ts index b87911174ca..44857e783ee 100644 --- a/src/main/acp/acp-structured-starts.ts +++ b/src/main/provider-process/provider-acquisition-starts.ts @@ -3,22 +3,27 @@ // process's exit is proven, so the next start or quit retries that same connection's close instead // of answering for a process it no longer knows (and never spawns a second one meanwhile). -import type { AcpStructuredConnection } from './acp-structured-connection' +import type { ProviderProcessCloseResult } from './provider-process-close' -export type AcpStartAttempt = { +export type ProviderAcquisitionConnection = { + close(): Promise + onExit(listener: () => void): void +} + +export type ProviderStartAttempt = { /** The host's: the start's one canceller. */ readonly signal: AbortSignal - connection: AcpStructuredConnection | null + connection: Connection | null /** What the signal's abort does while the start runs; detached once it ends. */ readonly stopConnection: () => void } -export class AcpStructuredStarts { - private readonly failed = new Map() +export class ProviderAcquisitionStarts { + private readonly failed = new Map() /** Registered before anything awaits, so an abort from here on stops this start. */ - begin(signal: AbortSignal | undefined): AcpStartAttempt { - const attempt: AcpStartAttempt = { + begin(signal: AbortSignal | undefined): ProviderStartAttempt { + const attempt: ProviderStartAttempt = { signal: signal ?? new AbortController().signal, connection: null, stopConnection: () => void attempt.connection?.close().catch(() => false) @@ -28,7 +33,7 @@ export class AcpStructuredStarts { } /** The start has its connection; one already aborted goes as soon as it exists. */ - track(attempt: AcpStartAttempt, connection: AcpStructuredConnection): void { + track(attempt: ProviderStartAttempt, connection: Connection): void { attempt.connection = connection if (attempt.signal.aborted) { void connection.close().catch(() => false) @@ -37,12 +42,12 @@ export class AcpStructuredStarts { /** A connection the start handed over is the session's: an abort after this goes through its * stop, which knows the close was asked for, not this listener, which would read as a crash. */ - end(attempt: AcpStartAttempt): void { + end(attempt: ProviderStartAttempt): void { attempt.signal.removeEventListener('abort', attempt.stopConnection) } /** A failed start whose process is not proven gone keeps its connection until its exit is. */ - retainFailed(sessionId: string, connection: AcpStructuredConnection): void { + retainFailed(sessionId: string, connection: Connection): void { this.failed.set(sessionId, connection) connection.onExit(() => { if (this.failed.get(sessionId) === connection) { @@ -57,7 +62,8 @@ export class AcpStructuredStarts { if (!connection) { return true } - const proven = await connection.close().catch(() => false) + const result = await connection.close().catch(() => false) + const proven = typeof result === 'boolean' ? result : result.root === 'exited' if (proven && this.failed.get(sessionId) === connection) { this.failed.delete(sessionId) } diff --git a/src/main/provider-process/provider-stdio-error-boundary.ts b/src/main/provider-process/provider-stdio-error-boundary.ts new file mode 100644 index 00000000000..4290a0b1622 --- /dev/null +++ b/src/main/provider-process/provider-stdio-error-boundary.ts @@ -0,0 +1,16 @@ +import type { Readable, Writable } from 'node:stream' + +function ignoreLateError(): void {} + +export function detachProviderStreamErrorHandler( + stream: Readable | Writable, + handler: (error: Error) => void +): void { + stream.removeListener('error', handler) + if (stream.closed) { + return + } + // Node may emit the write error after its callback has already closed the peer. + stream.on('error', ignoreLateError) + stream.once('close', () => stream.removeListener('error', ignoreLateError)) +} diff --git a/src/main/provider-process/provider-stdio-write-queue.test.ts b/src/main/provider-process/provider-stdio-write-queue.test.ts new file mode 100644 index 00000000000..7acf8c0fa82 --- /dev/null +++ b/src/main/provider-process/provider-stdio-write-queue.test.ts @@ -0,0 +1,39 @@ +import { Writable } from 'node:stream' +import { describe, expect, it, vi } from 'vitest' +import { ProviderStdioWriteQueue } from './provider-stdio-write-queue' + +// An agent that stopped reading its stdin: the first line is handed over and never completes. +function stalledQueue() { + const output = new Writable({ write() {} }) + const onFailure = vi.fn() + const queue = new ProviderStdioWriteQueue(output, 1024, onFailure, { + capacity: () => new Error('capacity'), + closed: () => new Error('closed') + }) + return { queue, onFailure } +} + +describe('ProviderStdioWriteQueue aborts', () => { + it('drops a line still waiting in the queue and rejects only its request', async () => { + const { queue, onFailure } = stalledQueue() + void queue.write('active\n') + const waiting = new AbortController() + const dropped = queue.write('waiting\n', waiting.signal) + waiting.abort(new Error('timed out')) + await expect(dropped).rejects.toThrow('timed out') + // Nothing of it reached the agent, so the connection stays up for the next request. + expect(onFailure).not.toHaveBeenCalled() + void queue.write('next\n') + expect(onFailure).not.toHaveBeenCalled() + }) + + it('closes the connection when the line being written times out, since it cannot be taken back', () => { + const { queue, onFailure } = stalledQueue() + const active = new AbortController() + void queue.write('active\n', active.signal) + active.abort(new Error('timed out')) + // The stream owns the line, possibly partly on the pipe; leaving it would hold every later + // write, a Stop included, behind a reader that stopped. + expect(onFailure).toHaveBeenCalledWith(new Error('timed out')) + }) +}) diff --git a/src/main/provider-process/provider-stdio-write-queue.ts b/src/main/provider-process/provider-stdio-write-queue.ts new file mode 100644 index 00000000000..378caf1560f --- /dev/null +++ b/src/main/provider-process/provider-stdio-write-queue.ts @@ -0,0 +1,127 @@ +import type { Writable } from 'node:stream' + +type Write = { + line: string + resolve: () => void + reject: (error: Error) => void + detachAbort?: () => void +} + +export class ProviderStdioWriteQueue { + private readonly queue: Write[] = [] + private bytes = 0 + private active?: Write + private terminalError?: Error + private detachDrain?: () => void + + constructor( + private readonly output: Writable, + private readonly maxBytes: number, + private readonly onFailure: (error: Error) => void, + private readonly errors: { capacity: () => Error; closed: () => Error } + ) {} + + write(line: string, signal?: AbortSignal): Promise { + if (this.terminalError) { + return Promise.reject(this.terminalError) + } + if (signal?.aborted) { + return Promise.reject(signal.reason) + } + const bytes = Buffer.byteLength(line) + if (this.bytes + bytes > this.maxBytes) { + return Promise.reject(this.errors.capacity()) + } + this.bytes += bytes + return new Promise((resolve, reject) => { + const write: Write = { line, resolve, reject } + const abort = (): void => { + // Why: a line already handed to the stream can't be taken back, and later writes wait on it. + if (this.active === write) { + this.onFailure( + signal?.reason instanceof Error ? signal.reason : new Error('Agent write aborted') + ) + return + } + const index = this.queue.indexOf(write) + if (index === -1) { + return + } + this.queue.splice(index, 1) + this.bytes -= bytes + write.detachAbort?.() + reject(signal?.reason) + } + signal?.addEventListener('abort', abort, { once: true }) + write.detachAbort = () => signal?.removeEventListener('abort', abort) + this.queue.push(write) + this.flush() + }) + } + + close(error: Error): void { + if (this.terminalError) { + return + } + this.terminalError = error + this.detachDrain?.() + this.active?.detachAbort?.() + this.active?.reject(error) + this.active = undefined + for (const write of this.queue.splice(0)) { + write.detachAbort?.() + write.reject(error) + } + this.bytes = 0 + } + + private flush(): void { + if (this.active || this.terminalError) { + return + } + const write = this.queue.shift() + if (!write) { + return + } + this.active = write + if (this.output.destroyed || !this.output.writable) { + this.onFailure(this.errors.closed()) + return + } + let completed = false + let drained = false + let returned = false + const finish = (): void => { + if (!returned || !completed || !drained || this.terminalError) { + return + } + this.detachDrain?.() + write.detachAbort?.() + this.active = undefined + this.bytes -= Buffer.byteLength(write.line) + write.resolve() + this.flush() + } + const onDrain = (): void => { + drained = true + finish() + } + this.output.once('drain', onDrain) + this.detachDrain = () => this.output.removeListener('drain', onDrain) + try { + const accepted = this.output.write(write.line, (error) => { + if (error) { + this.onFailure(error) + return + } + completed = true + finish() + }) + drained ||= accepted + returned = true + finish() + } catch (error) { + this.onFailure(error instanceof Error ? error : new Error(String(error))) + } + } +} diff --git a/src/main/runtime/rpc/methods/session-tab-registered-agent-projection.test.ts b/src/main/runtime/rpc/methods/session-tab-registered-agent-projection.test.ts index bc446197ca0..e607e46aa86 100644 --- a/src/main/runtime/rpc/methods/session-tab-registered-agent-projection.test.ts +++ b/src/main/runtime/rpc/methods/session-tab-registered-agent-projection.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' @@ -70,6 +71,24 @@ describe('a tab of an agent beyond Claude and Codex', () => { expect(projectSessionTabAgentStatus(payload, 'runtime', REGISTERED_AGENTS_CLIENT)).toBe(payload) }) + it('holds a Pi tab for clients that understand registered agents but cannot show Pi dialogs', () => { + const payload = { + ...snapshot(), + tabs: [...snapshot().tabs, chatTab('pi', false)] + } + const old = projectSessionTabAgentStatus(payload, 'runtime', REGISTERED_AGENTS_CLIENT) + expect(old.tabs.map((tab) => tab.id)).not.toContain('agent-session:pi-session') + expect( + projectSessionTabAgentStatus(payload, 'runtime', [ + ...REGISTERED_AGENTS_CLIENT, + PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY + ]) + ).toBe(payload) + expect( + projectSessionTabAgentStatus(payload, 'mobile', REGISTERED_AGENTS_CLIENT).tabs.at(-1) + ).toMatchObject({ title: STRUCTURED_CHAT_UPDATE_REQUIRED_TAB_TITLE }) + }) + it('stays listed on a phone under the title that names the fix', () => { const projected = projectSessionTabAgentStatus(snapshot(), 'mobile', TODAYS_CLIENT) expect(projected.tabs.map((tab) => tab.title)).toEqual([ diff --git a/src/main/runtime/rpc/methods/structured-agent-session-agents.ts b/src/main/runtime/rpc/methods/structured-agent-session-agents.ts index b66fc6d5156..8faf017f493 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-agents.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-agents.ts @@ -10,6 +10,7 @@ import { STRUCTURED_AGENT_RUNTIME_REGISTRATIONS } from '../../structured-agent-r import { defineMethod } from '../core' import { requireStructuredCapability } from './structured-agent-session-gate' import { AgentsParams } from './structured-agent-session-schemas' +import { clientReadsStructuredSessionAgent } from './structured-agent-session-policy' export const STRUCTURED_AGENT_SESSION_AGENTS_METHODS = [ defineMethod({ @@ -20,7 +21,9 @@ export const STRUCTURED_AGENT_SESSION_AGENTS_METHODS = [ handler: async (_params, ctx): Promise => { requireStructuredCapability(ctx) return { - agents: STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map(({ definition }) => ({ + agents: STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.filter(({ definition }) => + clientReadsStructuredSessionAgent(ctx, definition.agent) + ).map(({ definition }) => ({ agent: definition.agent, capabilities: { ...definition.capabilities } })) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-conversation-outline.ts b/src/main/runtime/rpc/methods/structured-agent-session-conversation-outline.ts index 08ecc212ec1..a68a41ec1db 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-conversation-outline.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-conversation-outline.ts @@ -16,7 +16,9 @@ export const STRUCTURED_AGENT_SESSION_CONVERSATION_OUTLINE_METHODS = [ params: OptionsParams, handler: async (params, ctx) => readAgentSessionConversationOutline( - await (await requireInstalledStructuredHost(ctx)).journalSnapshot(params.sessionId) + await ( + await requireInstalledStructuredHost(ctx, params.sessionId) + ).journalSnapshot(params.sessionId) ) }) ] diff --git a/src/main/runtime/rpc/methods/structured-agent-session-gate.ts b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts index 754412ffea9..0b9b25c8005 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-gate.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts @@ -16,6 +16,7 @@ import type { RpcContext } from '../core' import { structuredAgentSessionHostRefusal } from '../../structured-agent-session-host-refusal' import { createSupportFollowsHostSetting, + clientReadsStructuredSessionAgent, isStructuredNativeChatEnabled, supportsStructuredAgentSessions } from './structured-agent-session-policy' @@ -36,12 +37,21 @@ export function requireStructuredCapability(ctx: RpcContext): void { } } +export function requireStructuredAgentAudience(ctx: RpcContext, agent: string): void { + requireStructuredCapability(ctx) + if (!clientReadsStructuredSessionAgent(ctx, agent)) { + throw agentSessionRefusalError('structured_agent_session_unsupported', { + reason: 'clientCapabilityMissing' + }) + } +} + /** * `agentSession.createSupport` alone also reads the host setting, for a client that leaves the * launch mode to the host; it gets the refusal it got before, which it reads as "open a terminal". */ -export function requireStructuredCreateSupportAdmission(ctx: RpcContext): void { - requireStructuredCapability(ctx) +export function requireStructuredCreateSupportAdmission(ctx: RpcContext, agent?: string): void { + requireStructuredAgentAudience(ctx, agent ?? '') if (createSupportFollowsHostSetting(ctx) && !isStructuredNativeChatEnabled(ctx.runtime)) { throw agentSessionRefusalError('structured_agent_session_unsupported', { reason: 'clientCapabilityMissing' @@ -49,11 +59,20 @@ export function requireStructuredCreateSupportAdmission(ctx: RpcContext): void { } } -export function requireStructuredHost(ctx: RpcContext): StructuredAgentSessionHost { - requireStructuredCapability(ctx) +export function requireStructuredHost(ctx: RpcContext, agent?: string): StructuredAgentSessionHost { + requireStructuredAgentAudience(ctx, agent ?? '') return requireHostOrRefusal() } +export function requireStructuredSessionHost( + ctx: RpcContext, + sessionId: string +): StructuredAgentSessionHost { + const host = requireStructuredHost(ctx) + requireStructuredAgentAudience(ctx, host.sessionAgent(sessionId) ?? '') + return host +} + /** * The gate for methods that stop or retire work the caller already owns: close, cancel, * unsubscribe and release. It asks only what no caller can do without (the wire capability and a @@ -84,7 +103,13 @@ function requireHostOrRefusal(): StructuredAgentSessionHost { /** Builds the host for a call that may be the first this process sees. Every session is addressed * by its durable record — a read opens a conversation at rest — so each call that reaches for one * may meet a host nothing has built yet. */ -export async function ensureStructuredHostInstalled(ctx: RpcContext): Promise { +export async function ensureStructuredHostInstalled( + ctx: RpcContext, + agent?: string +): Promise { + if (agent) { + requireStructuredAgentAudience(ctx, agent) + } // Gated first: a client that cannot read structured sessions must not be able // to make the host exist, which is an observable side effect of the surface. if (!supportsStructuredSessions(ctx)) { @@ -98,10 +123,11 @@ export async function ensureStructuredHostInstalled(ctx: RpcContext): Promise { await ensureStructuredHostInstalled(ctx) - return requireStructuredHost(ctx) + return sessionId ? requireStructuredSessionHost(ctx, sessionId) : requireStructuredHost(ctx) } /** Mirrors the existing agent-session host-authority derivation so one client diff --git a/src/main/runtime/rpc/methods/structured-agent-session-hold.ts b/src/main/runtime/rpc/methods/structured-agent-session-hold.ts index e1975f1c725..ef6e89f8ee8 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-hold.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-hold.ts @@ -16,8 +16,8 @@ export const STRUCTURED_AGENT_SESSION_HOLD_METHODS = [ defineMethod({ name: 'agentSession.hold', params: HoldParams, - handler: async (_params, ctx) => { - await requireInstalledStructuredHost(ctx) + handler: async (params, ctx) => { + await requireInstalledStructuredHost(ctx, params.sessionId) return { held: true as const } } }), diff --git a/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts b/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts index 98d94439b1f..97833114c2e 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts @@ -11,17 +11,34 @@ import { defineMethod } from '../core' import { requireInstalledStructuredHost, + requireStructuredAgentAudience, requireStructuredHost } from './structured-agent-session-gate' -import { ModelCatalogParams, OptionsParams } from './structured-agent-session-schemas' +import { + HandoffStatusParams, + ModelCatalogParams, + OptionsParams +} from './structured-agent-session-schemas' import { agentSessionPinnedLaunchDirectory } from '../../agent-session-record-launch-directory' export const STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS = [ + defineMethod({ + name: 'agentSession.handoffStatus', + params: HandoffStatusParams, + handler: async (params, ctx) => + (await requireInstalledStructuredHost(ctx, params.sessionId)).handoffStatus(params.sessionId) + }), + defineMethod({ + name: 'agentSession.commands', + params: OptionsParams, + handler: async (params, ctx) => + (await requireInstalledStructuredHost(ctx, params.sessionId)).readCommands(params.sessionId) + }), defineMethod({ name: 'agentSession.options', params: OptionsParams, handler: async (params, ctx) => - (await requireInstalledStructuredHost(ctx)).readOptions(params.sessionId) + (await requireInstalledStructuredHost(ctx, params.sessionId)).readOptions(params.sessionId) }), defineMethod({ name: 'agentSession.modelCatalog', @@ -29,10 +46,11 @@ export const STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS = [ // A structured chat's read names its session and builds the host, since it may come first; // terminal-backed chat's session-less read must not open the journal where none runs. handler: async ({ worktree, ...params }, ctx) => { + requireStructuredAgentAudience(ctx, params.agent) const host = params.sessionId === undefined ? requireStructuredHost(ctx) - : await requireInstalledStructuredHost(ctx) + : await requireInstalledStructuredHost(ctx, params.sessionId) const catalog = host.deps.modelCatalog if (!catalog) { return { origin: 'unknown' as const } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-pi-dialog-capability.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-pi-dialog-capability.test.ts new file mode 100644 index 00000000000..9b290a449b2 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-pi-dialog-capability.test.ts @@ -0,0 +1,386 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + AGENT_SESSION_TURN_ITEM_CAPABILITY, + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY, + SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' +import { AGENT_LAUNCH_RUNTIME_CAPABILITY } from '../../../../shared/agent-launch-runtime-capability' +import { remoteRuntimeClientCapabilities } from '../../../../shared/remote-runtime-client-capabilities' +import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope' +import { STRUCTURED_AGENT_RUNTIME_REGISTRATIONS } from '../../structured-agent-runtime-registrations' +import { STRUCTURED_AGENT_SESSION_AGENTS_METHODS } from './structured-agent-session-agents' +import { DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES } from '../../../ipc/desktop-renderer-runtime-capabilities' +import type { + AgentSessionStatusSummary, + AgentSessionTurnCompletion +} from '../../../../shared/agent-session-wire' +import type { AgentSessionPromptAttention } from '../../../../shared/agent-session-turn-completion-wire' +import type { StructuredAgentSessionStatusSubscriber } from '../../../native-chat/agent-session-wire/structured-agent-session-status-feed' +import type { StructuredAgentSessionTurnCompletionSubscriber } from '../../../native-chat/agent-session-wire/structured-agent-session-turn-completion-feed' +import { + call, + clearStructuredHostStub, + dispatcher, + envelope, + hostCalls, + installStructuredHostStub, + runtimeCalls, + SESSION, + STRUCTURED_CLIENT +} from './structured-agent-session-rpc.test-fixture' +import { + CLEANUP_METHODS, + WORK_METHODS +} from './structured-agent-session-gate-classification.test-fixture' + +const OLD_CLIENT = { + ...STRUCTURED_CLIENT, + clientCapabilities: [ + ...STRUCTURED_CLIENT.clientCapabilities, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY + ] +} +const PI_CLIENT = { + ...OLD_CLIENT, + clientCapabilities: [...OLD_CLIENT.clientCapabilities, PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY] +} +const MOBILE_CLIENT = { + clientKind: 'mobile' as const, + // Mirrors the mobile transport list without importing its Expo project into this Node suite. + clientCapabilities: remoteRuntimeClientCapabilities([ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY, + CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY, + AGENT_SESSION_TURN_ITEM_CAPABILITY, + AGENT_LAUNCH_RUNTIME_CAPABILITY + ]) +} +const DESKTOP_CLIENT = { + clientKind: 'runtime' as const, + clientCapabilities: [...DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES] +} +const UNSUPPORTED = { message: expect.stringContaining('structured_agent_session_unsupported') } + +beforeEach(installStructuredHostStub) +afterEach(clearStructuredHostStub) + +describe('Pi dialog-shape client capability', () => { + it('filters only Pi from the registered agent list of an older client', async () => { + const registered = STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map( + ({ definition }) => definition.agent + ) + const listing = (agents: string[]) => ({ + ok: true, + result: { agents: agents.map((agent) => ({ agent })) } + }) + expect(registered).toContain('pi') + + expect( + await call('agentSession.agents', {}, OLD_CLIENT, {}, STRUCTURED_AGENT_SESSION_AGENTS_METHODS) + ).toMatchObject(listing(registered.filter((agent) => agent !== 'pi'))) + expect( + await call('agentSession.agents', {}, PI_CLIENT, {}, STRUCTURED_AGENT_SESSION_AGENTS_METHODS) + ).toMatchObject(listing(registered)) + }) + + it('refuses Pi create support before asking the runtime, while other agents keep their path', async () => { + expect( + await call( + 'agentSession.createSupport', + { worktree: 'id:workspace-1', agent: 'pi' }, + OLD_CLIENT + ) + ).toMatchObject({ ok: false, error: UNSUPPORTED }) + expect(runtimeCalls.getStructuredAgentSessionCreateSupport).not.toHaveBeenCalled() + + expect( + await call( + 'agentSession.createSupport', + { worktree: 'id:workspace-1', agent: 'grok' }, + OLD_CLIENT + ) + ).toMatchObject({ ok: true }) + expect( + await call( + 'agentSession.createSupport', + { worktree: 'id:workspace-1', agent: 'pi' }, + PI_CLIENT + ) + ).toMatchObject({ ok: true }) + }) + + it('refuses Pi create before resolution or attachment', async () => { + const params = { + envelope: envelope({ + expectedRuntimeFence: null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.create', + sessionId: SESSION, + fields: { worktree: 'id:workspace-1', agent: 'pi' } + }) + }), + worktree: 'id:workspace-1', + agent: 'pi' + } + expect(await call('agentSession.create', params, OLD_CLIENT)).toMatchObject({ + ok: false, + error: UNSUPPORTED + }) + expect(runtimeCalls.resolveStructuredAgentSessionCreateIntent).not.toHaveBeenCalled() + expect(hostCalls.attach).not.toHaveBeenCalled() + expect(await call('agentSession.create', params, PI_CLIENT)).toMatchObject({ ok: true }) + }) + + it('withholds Pi journal history from a client that cannot show its dialogs', async () => { + hostCalls.sessionAgent.mockReturnValue('pi') + expect( + await call('agentSession.history', { sessionId: SESSION, direction: 'tail' }, OLD_CLIENT) + ).toMatchObject({ ok: false, error: UNSUPPORTED }) + expect(hostCalls.history).not.toHaveBeenCalled() + expect( + await call('agentSession.history', { sessionId: SESSION, direction: 'tail' }, PI_CLIENT) + ).toMatchObject({ ok: true }) + }) + + it.each([ + ['old D3 desktop', OLD_CLIENT], + ['current mobile', MOBILE_CLIENT] + ] as const)( + 'refuses Pi session reads and mutations by ID for %s before host work', + async (_label, client) => { + hostCalls.sessionAgent.mockReturnValue('pi') + for (const { method, params } of WORK_METHODS) { + if ( + method === 'agentSession.createSupport' || + method === 'agentSession.create' || + method === 'agentSession.ensure' || + method === 'agentSession.subscribeStatus' || + method === 'agentSession.reveal' + ) { + continue + } + expect(await call(method, params, client), method).toMatchObject({ + ok: false, + error: UNSUPPORTED + }) + } + expect(hostCalls.send).not.toHaveBeenCalled() + expect(hostCalls.readOptions).not.toHaveBeenCalled() + expect(hostCalls.rewind).not.toHaveBeenCalled() + expect(hostCalls.respondToPrompt).not.toHaveBeenCalled() + expect(hostCalls.setOption).not.toHaveBeenCalled() + expect(hostCalls.history).not.toHaveBeenCalled() + expect(hostCalls.revealSession).not.toHaveBeenCalled() + expect(await call('agentSession.modelCatalog', { agent: 'pi' }, client)).toMatchObject({ + ok: false, + error: UNSUPPORTED + }) + hostCalls.sessionAgent.mockReturnValue('codex') + expect( + await call('agentSession.modelCatalog', { agent: 'pi', sessionId: SESSION }, client) + ).toMatchObject({ ok: false, error: UNSUPPORTED }) + hostCalls.sessionAgent.mockReturnValue('pi') + expect(await call('agentSession.reveal', { sessionId: SESSION }, client)).toMatchObject({ + ok: true, + result: { ok: false, refusal: { code: 'structured_agent_session_unsupported' } } + }) + for (const [method, params] of [ + ['agentSession.commands', { sessionId: SESSION }], + ['agentSession.conversationCommand', { envelope: envelope(), command: 'compact' }], + ['agentSession.modelCatalog', { agent: 'codex', sessionId: SESSION }] + ] as const) { + expect(await call(method, params, client), method).toMatchObject({ + ok: false, + error: UNSUPPORTED + }) + } + expect(hostCalls.revealSession).not.toHaveBeenCalled() + } + ) + + it.each([ + ['old D3 desktop', OLD_CLIENT], + ['current mobile', MOBILE_CLIENT] + ] as const)('keeps Pi cleanup callable for %s', async (_label, client) => { + hostCalls.sessionAgent.mockReturnValue('pi') + for (const { method, params } of CLEANUP_METHODS) { + expect(await call(method, params, client), method).toMatchObject({ ok: true }) + } + expect(hostCalls.cancel).toHaveBeenCalledOnce() + expect(hostCalls.close).toHaveBeenCalledOnce() + }) + + it('admits current desktop Pi send, options and commands, and preserves other agents', async () => { + hostCalls.sessionAgent.mockReturnValue('pi') + hostCalls.readCommands = vi.fn(async () => []) + for (const method of [ + 'agentSession.send', + 'agentSession.options', + 'agentSession.commands' + ] as const) { + const params = + method === 'agentSession.send' + ? WORK_METHODS.find((entry) => entry.method === method)?.params + : { sessionId: SESSION } + expect(await call(method, params, DESKTOP_CLIENT), method).toMatchObject({ ok: true }) + } + expect(hostCalls.send).toHaveBeenCalledOnce() + expect(hostCalls.readOptions).toHaveBeenCalledOnce() + expect(hostCalls.readCommands).toHaveBeenCalledOnce() + + hostCalls.sessionAgent.mockReturnValue('codex') + expect( + await call( + 'agentSession.send', + WORK_METHODS.find((entry) => entry.method === 'agentSession.send')?.params, + OLD_CLIENT + ) + ).toMatchObject({ ok: true }) + expect(await call('agentSession.options', { sessionId: SESSION }, MOBILE_CLIENT)).toMatchObject( + { ok: true } + ) + }) + + it('filters Pi status from the opening snapshot and live updates', async () => { + const codex: AgentSessionStatusSummary = { + sessionId: 'codex-session', + workspaceId: 'workspace-1', + agent: 'codex', + status: 'idle', + latestPrompt: 'hello', + updatedAt: 1 + } + const pi: AgentSessionStatusSummary = { ...codex, sessionId: SESSION, agent: 'pi' } + hostCalls.subscribeStatus.mockImplementation( + (subscriber: StructuredAgentSessionStatusSubscriber) => { + subscriber.emit({ type: 'snapshot', sessions: [codex, pi] }) + subscriber.emit({ type: 'status', session: pi }) + subscriber.emit({ type: 'status', session: codex }) + subscriber.emit({ type: 'end' }) + return () => undefined + } + ) + for (const [client, seesPi] of [ + [OLD_CLIENT, false], + [MOBILE_CLIENT, false], + [DESKTOP_CLIENT, true] + ] as const) { + const replies: unknown[] = [] + await dispatcher().dispatchStreaming( + { + id: 'status-test', + authToken: 'token', + method: 'agentSession.subscribeStatus', + params: null + }, + (raw) => replies.push(JSON.parse(raw)), + client + ) + const serialized = JSON.stringify(replies) + expect(serialized).toContain('codex-session') + expect(serialized.includes(SESSION)).toBe(seesPi) + expect(replies).toHaveLength(seesPi ? 4 : 3) + } + }) + + it('filters Pi turn completions from the global live stream', async () => { + hostCalls.sessionAgent.mockImplementation((sessionId: string) => + sessionId === SESSION ? 'pi' : 'codex' + ) + hostCalls.subscribeTurnCompletions = vi.fn( + (subscriber: StructuredAgentSessionTurnCompletionSubscriber) => { + const completion: AgentSessionTurnCompletion = { + scope: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' as const + }, + sessionId: SESSION, + turnId: 'turn-1', + outcome: 'success' as const, + completedAt: 1 + } + subscriber.emit({ type: 'completion', completion }) + subscriber.emit({ + type: 'completion', + completion: { ...completion, sessionId: 'codex-session' } + }) + subscriber.emit({ type: 'end' }) + return () => undefined + } + ) + for (const [client, seesPi] of [ + [OLD_CLIENT, false], + [DESKTOP_CLIENT, true] + ] as const) { + const replies: unknown[] = [] + await dispatcher().dispatchStreaming( + { + id: 'completion-test', + authToken: 'token', + method: 'agentSession.subscribeTurnCompletions', + params: null + }, + (raw) => replies.push(JSON.parse(raw)), + client + ) + const serialized = JSON.stringify(replies) + expect(serialized).toContain('codex-session') + expect(serialized.includes(SESSION)).toBe(seesPi) + expect(replies).toHaveLength(seesPi ? 3 : 2) + } + }) + + it('filters Pi prompt alerts from a client that asked for prompts', async () => { + hostCalls.sessionAgent.mockImplementation((sessionId: string) => + sessionId === SESSION ? 'pi' : 'codex' + ) + hostCalls.subscribeTurnCompletions = vi.fn( + (subscriber: StructuredAgentSessionTurnCompletionSubscriber) => { + const prompt: AgentSessionPromptAttention = { + scope: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' as const + }, + sessionId: SESSION, + promptId: 'prompt-1', + raisedAt: 1 + } + subscriber.emit({ type: 'prompt', prompt }) + subscriber.emit({ type: 'prompt', prompt: { ...prompt, sessionId: 'codex-session' } }) + subscriber.emit({ type: 'end' }) + return () => undefined + } + ) + for (const [client, seesPi] of [ + [OLD_CLIENT, false], + [DESKTOP_CLIENT, true] + ] as const) { + const replies: unknown[] = [] + await dispatcher().dispatchStreaming( + { + id: 'prompt-test', + authToken: 'token', + method: 'agentSession.subscribeTurnCompletions', + params: { includePrompts: true } + }, + (raw) => replies.push(JSON.parse(raw)), + client + ) + const serialized = JSON.stringify(replies) + expect(serialized).toContain('codex-session') + expect(serialized.includes(SESSION)).toBe(seesPi) + expect(replies).toHaveLength(seesPi ? 3 : 2) + } + }) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-policy.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-policy.test.ts index 662cc948b7d..a000ced431a 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-policy.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-policy.test.ts @@ -1,15 +1,43 @@ import { describe, expect, it } from 'vitest' import { + PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { createSupportFollowsHostSetting, + structuredAgentsReadBy, + clientRendersStructuredAgent, supportsStructuredAgentSessions } from './structured-agent-session-policy' const CAPABLE = [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] +describe('Pi audience in a registered-agents client', () => { + const registered = [...CAPABLE, STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY] + + it('keeps Pi out of tab and restart audiences until the dialog shape is advertised', () => { + expect(clientRendersStructuredAgent(registered, 'pi')).toBe(false) + expect(clientRendersStructuredAgent(registered, 'grok')).toBe(true) + const audience = structuredAgentsReadBy( + { clientKind: 'runtime', clientCapabilities: registered }, + ['codex', 'grok', 'pi'] + ) + expect(audience?.('pi')).toBe(false) + expect(audience?.('grok')).toBe(true) + expect( + structuredAgentsReadBy( + { + clientKind: 'runtime', + clientCapabilities: [...registered, PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY] + }, + ['codex', 'grok', 'pi'] + ) + ).toBeUndefined() + }) +}) + describe('supportsStructuredAgentSessions', () => { it.each(['runtime', 'mobile'] as const)( 'admits a %s client that advertises the capability', diff --git a/src/main/runtime/rpc/methods/structured-agent-session-policy.ts b/src/main/runtime/rpc/methods/structured-agent-session-policy.ts index 0892f35d797..4295dee842f 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-policy.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-policy.ts @@ -1,5 +1,6 @@ import { CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, @@ -24,9 +25,7 @@ export function supportsStructuredAgentSessions( ) } -/** Whether a remote client renders `agent`'s chat: the one rule for every surface that withholds - * an agent's rows (tabs, restart offers). Codex needs structured support; Claude also its own - * capability; any other agent a client that renders the host's registered agents. */ +/** One audience rule for tabs and restart offers. Pi also needs dialog-shape support. */ export function clientRendersStructuredAgent( clientCapabilities: readonly RuntimeCapability[] | undefined, agent: string @@ -37,6 +36,9 @@ export function clientRendersStructuredAgent( if (agent === 'codex') { return true } + if (agent === 'pi' && !clientCapabilities.includes(PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY)) { + return false + } return clientCapabilities.includes( agent === 'claude' ? CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY @@ -44,6 +46,18 @@ export function clientRendersStructuredAgent( ) } +/** Preserve the existing session audience for every agent except Pi. */ +export function clientReadsStructuredSessionAgent( + context: Pick, + agent: string +): boolean { + return ( + agent !== 'pi' || + context.clientKind === undefined || + clientRendersStructuredAgent(context.clientCapabilities, agent) + ) +} + /** The agents this client reads rows of, among those registered or saved here; undefined when it * reads every one, so an action for it is exactly the unscoped one (one fence for every offer). */ export function structuredAgentsReadBy( diff --git a/src/main/runtime/rpc/methods/structured-agent-session-queued-methods.ts b/src/main/runtime/rpc/methods/structured-agent-session-queued-methods.ts index 259ee2dbe14..c98dfa4c787 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-queued-methods.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-queued-methods.ts @@ -4,7 +4,7 @@ import { defineMethod } from '../core' import { - requireStructuredHost as requireHost, + requireStructuredSessionHost as requireSessionHost, structuredCallerFor as callerFor } from './structured-agent-session-gate' import { @@ -16,16 +16,22 @@ export const STRUCTURED_AGENT_SESSION_QUEUED_METHODS = [ defineMethod({ name: 'agentSession.queuedMessageSend', params: QueuedMessageActionParams, - handler: async (params, ctx) => requireHost(ctx).queuedMessageSend(callerFor(ctx), params) + handler: async (params, ctx) => + requireSessionHost(ctx, params.envelope.sessionId).queuedMessageSend(callerFor(ctx), params) }), defineMethod({ name: 'agentSession.queuedMessageDelete', params: QueuedMessageActionParams, - handler: async (params, ctx) => requireHost(ctx).queuedMessageDelete(callerFor(ctx), params) + handler: async (params, ctx) => + requireSessionHost(ctx, params.envelope.sessionId).queuedMessageDelete(callerFor(ctx), params) }), defineMethod({ name: 'agentSession.queuedMessagesResume', params: QueuedMessagesResumeParams, - handler: async (params, ctx) => requireHost(ctx).queuedMessagesResume(callerFor(ctx), params) + handler: async (params, ctx) => + requireSessionHost(ctx, params.envelope.sessionId).queuedMessagesResume( + callerFor(ctx), + params + ) }) ] diff --git a/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts b/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts index 10b5610e353..118cfb418f5 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts @@ -20,6 +20,7 @@ import { defineMethod } from '../core' import { ensureStructuredHostInstalled, requireStructuredCapability, + requireStructuredAgentAudience, requireStructuredHost } from './structured-agent-session-gate' import { OptionsParams } from './structured-agent-session-schemas' @@ -33,7 +34,9 @@ export const STRUCTURED_AGENT_SESSION_REVEAL_METHODS = [ await ensureStructuredHostInstalled(ctx) let revealed: StructuredAgentSessionReveal try { - revealed = await requireStructuredHost(ctx).revealSession(params.sessionId) + const host = requireStructuredHost(ctx) + requireStructuredAgentAudience(ctx, host.sessionAgent(params.sessionId) ?? '') + revealed = await host.revealSession(params.sessionId) } catch (error) { // The host raises its refusal as the code itself; anything else is a genuine fault and // must not be laundered into a tidy "no such chat". diff --git a/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts b/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts index 019c0d63453..7f6460bdc55 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts @@ -6,7 +6,7 @@ import { agentSessionSendSubmission } from '../../../../shared/agent-session-wir import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import { STRUCTURED_AGENT_SESSION_START_WAIT_MS } from '../../../native-chat/agent-session-wire/structured-agent-session-send-settlement' import type { RpcContext } from '../core' -import { requireStructuredHost, structuredCallerFor } from './structured-agent-session-gate' +import { requireStructuredSessionHost, structuredCallerFor } from './structured-agent-session-gate' /** * A send answers once the host accepts it. A client that predates that answer cannot show a @@ -19,7 +19,7 @@ export async function sendStructuredAgentSessionForClient( params: Parameters[1], context: RpcContext ) { - const host = requireStructuredHost(context) + const host = requireStructuredSessionHost(context, params.envelope.sessionId) // A client's own send: a restart or a close keeps it as a card if it never reached the agent. const result = await host.send(structuredCallerFor(context), { ...params, userSend: true }) const capabilities = context.clientCapabilities ?? [] diff --git a/src/main/runtime/rpc/methods/structured-agent-session-status-stream.ts b/src/main/runtime/rpc/methods/structured-agent-session-status-stream.ts index c93401e0e22..a4a2de57959 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-status-stream.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-status-stream.ts @@ -6,6 +6,7 @@ import { defineStreamingMethod, type RpcContext } from '../core' import { requireStructuredHost as requireHost } from './structured-agent-session-gate' import { structuredAgentSessionStatusSubscriptionId } from './structured-agent-session-subscription-id' +import { clientReadsStructuredSessionAgent } from './structured-agent-session-policy' /** Ties a stream to both ends that can close it — the runtime's subscription registry and the * transport abort — so either one runs `onClose` exactly once. */ @@ -53,7 +54,24 @@ export const STRUCTURED_AGENT_SESSION_STATUS_METHODS = [ if (stream.isClosed()) { return } - dispose = host.subscribeStatus({ id: subscriptionId, emit }) + dispose = host.subscribeStatus({ + id: subscriptionId, + emit: (event) => { + if (event.type === 'snapshot') { + emit({ + ...event, + sessions: event.sessions.filter((session) => + clientReadsStructuredSessionAgent(ctx, session.agent) + ) + }) + } else if ( + event.type !== 'status' || + clientReadsStructuredSessionAgent(ctx, event.session.agent) + ) { + emit(event) + } + } + }) if (stream.isClosed()) { dispose() } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-thread-goal.ts b/src/main/runtime/rpc/methods/structured-agent-session-thread-goal.ts index b28a51500d2..87ba26fe37b 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-thread-goal.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-thread-goal.ts @@ -5,7 +5,7 @@ import { defineMethod } from '../core' import { - requireStructuredHost as requireHost, + requireStructuredSessionHost as requireSessionHost, structuredCallerFor as callerFor } from './structured-agent-session-gate' import { ThreadGoalParams } from './structured-agent-session-schemas' @@ -14,6 +14,7 @@ export const STRUCTURED_AGENT_SESSION_THREAD_GOAL_METHODS = [ defineMethod({ name: 'agentSession.threadGoal', params: ThreadGoalParams, - handler: async (params, ctx) => requireHost(ctx).changeThreadGoal(callerFor(ctx), params) + handler: async (params, ctx) => + requireSessionHost(ctx, params.envelope.sessionId).changeThreadGoal(callerFor(ctx), params) }) ] diff --git a/src/main/runtime/rpc/methods/structured-agent-session-turn-completion-stream.ts b/src/main/runtime/rpc/methods/structured-agent-session-turn-completion-stream.ts index 24f03ac07ee..a5442097f46 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-turn-completion-stream.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-turn-completion-stream.ts @@ -17,6 +17,7 @@ import { } from './structured-agent-session-schemas' import { structuredAgentSessionTurnCompletionSubscriptionId } from './structured-agent-session-subscription-id' import { bindStructuredAgentSessionStream } from './structured-agent-session-status-stream' +import { clientReadsStructuredSessionAgent } from './structured-agent-session-policy' export const STRUCTURED_AGENT_SESSION_TURN_COMPLETION_METHODS = [ // Retire only deliveries whose journal cause the client's accepted read covered: the session and @@ -46,7 +47,21 @@ export const STRUCTURED_AGENT_SESSION_TURN_COMPLETION_METHODS = [ } dispose = host.subscribeTurnCompletions({ id: subscriptionId, - emit, + // A client that cannot read an agent's rows is not told that agent's edges either. + emit: (event) => { + const sessionId = + event.type === 'completion' + ? event.completion.sessionId + : event.type === 'prompt' + ? event.prompt.sessionId + : undefined + if ( + sessionId === undefined || + clientReadsStructuredSessionAgent(ctx, host.sessionAgent(sessionId) ?? '') + ) { + emit(event) + } + }, includePrompts: params.includePrompts === true }) if (stream.isClosed()) { diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index d2da7b0f92c..8536cf27ec0 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -21,9 +21,11 @@ import { ensureStructuredHostInstalled as ensureHostInstalled, requireInstalledStructuredHost as requireInstalledHost, requireStructuredCapability, + requireStructuredAgentAudience, requireStructuredCleanupHost, requireStructuredCreateSupportAdmission, requireStructuredHost as requireHost, + requireStructuredSessionHost as requireSessionHost, structuredCallerFor as callerFor } from './structured-agent-session-gate' import type { AgentSessionAttachParams } from '../../../native-chat/agent-session-wire/structured-agent-session-attach' @@ -56,7 +58,6 @@ import { CreateParams, CreateSupportParams, HistoryParams, - HandoffStatusParams, OptionsParams, RespondParams, RespondToQuestionParams, @@ -75,8 +76,8 @@ import { sendStructuredAgentSessionForClient } from './structured-agent-session- * cannot forge that answer. */ async function resolveClientSuppliedAttach(params: z.infer, ctx: RpcContext) { - await ensureHostInstalled(ctx) - const host = requireHost(ctx) + await ensureHostInstalled(ctx, params.agent) + const host = requireHost(ctx, params.agent) if (!host.supportsCreate(params.location, params.agent)) { throw agentSessionRefusalError('structured_agent_session_unsupported', { reason: 'hostUnsupported' @@ -106,7 +107,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ handler: async (params, ctx) => { requireStructuredCapability(ctx) await ensureHostInstalled(ctx) - return requireHost(ctx).rewind(callerFor(ctx), params) + return requireSessionHost(ctx, params.envelope.sessionId).rewind(callerFor(ctx), params) } }), defineMethod({ @@ -115,7 +116,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ handler: async (params, ctx) => { requireStructuredCapability(ctx) await ensureHostInstalled(ctx) - const host = requireHost(ctx) + const host = requireSessionHost(ctx, params.envelope.sessionId) await host.revealSession(params.envelope.sessionId) const result = await host.conversationCommand(callerFor(ctx), params) if (result.ok && result.value.command === 'clear' && result.value.replacementSessionId) { @@ -134,7 +135,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ name: 'agentSession.createSupport', params: CreateSupportParams, handler: async (params, ctx) => { - requireStructuredCreateSupportAdmission(ctx) + requireStructuredCreateSupportAdmission(ctx, params.agent) const support = await ctx.runtime.getStructuredAgentSessionCreateSupport( params.worktree, params.agent @@ -150,7 +151,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ name: 'agentSession.create', params: CreateParams, handler: async (params, ctx) => { - requireStructuredCapability(ctx) + requireStructuredAgentAudience(ctx, params.agent) if (params.envelope.expectedRuntimeFence !== null) { throw agentSessionRefusalError('agent_session_operation_invalid', { reason: 'requestMalformed' @@ -231,35 +232,31 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ name: 'agentSession.respondToApproval', params: RespondParams, handler: async (params, ctx) => - requireHost(ctx).respondToPrompt(callerFor(ctx), { ...params, kind: 'approval' }) + requireSessionHost(ctx, params.envelope.sessionId).respondToPrompt(callerFor(ctx), { + ...params, + kind: 'approval' + }) }), defineMethod({ name: 'agentSession.respondToQuestion', params: RespondToQuestionParams, handler: async (params, ctx) => - requireHost(ctx).respondToPrompt(callerFor(ctx), { ...params, kind: 'question' }) + requireSessionHost(ctx, params.envelope.sessionId).respondToPrompt(callerFor(ctx), { + ...params, + kind: 'question' + }) }), defineMethod({ name: 'agentSession.setOption', params: SetOptionParams, - handler: async (params, ctx) => requireHost(ctx).setOption(callerFor(ctx), params) - }), - defineMethod({ - name: 'agentSession.handoffStatus', - params: HandoffStatusParams, handler: async (params, ctx) => - (await requireInstalledHost(ctx)).handoffStatus(params.sessionId) - }), - defineMethod({ - name: 'agentSession.commands', - params: OptionsParams, - handler: async (params, ctx) => (await requireInstalledHost(ctx)).readCommands(params.sessionId) + requireSessionHost(ctx, params.envelope.sessionId).setOption(callerFor(ctx), params) }), defineMethod({ name: 'agentSession.history', params: HistoryParams, handler: async (params, ctx) => { - const host = await requireInstalledHost(ctx) + const host = await requireInstalledHost(ctx, params.sessionId) return projectTurnItemHistory( projectBackgroundTaskHistory(await host.history(params), ctx), ctx, @@ -271,7 +268,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ name: 'agentSession.subscribe', params: SubscribeParams, handler: async (params, ctx, emit) => { - const host = await requireInstalledHost(ctx) + const host = await requireInstalledHost(ctx, params.sessionId) const subscriptionId = subscriptionIdFor(ctx, params.sessionId) // A stream reads; it never keeps an agent alive or starts one. let dispose = (): void => {} diff --git a/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts b/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts index 0c3604fa733..ffc1920eba0 100644 --- a/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts +++ b/src/main/runtime/structured-agent-runtime-registrations-acp.test.ts @@ -9,7 +9,7 @@ describe('ACP agents in the runtime registrations', () => { it('registers Grok beside Claude and Codex with its declared capabilities', () => { expect( STRUCTURED_AGENT_RUNTIME_REGISTRATIONS.map(({ definition }) => definition.agent) - ).toEqual(['codex', 'claude', 'grok', 'opencode']) + ).toEqual(['pi', 'codex', 'claude', 'grok', 'opencode']) expect(structuredAgentRuntimeRegistration('grok')?.definition).toMatchObject({ handleTransport: 'acp', accountHomeVariable: 'GROK_HOME', diff --git a/src/main/runtime/structured-agent-runtime-registrations.ts b/src/main/runtime/structured-agent-runtime-registrations.ts index a3bb1543f55..b65eb8ea0f5 100644 --- a/src/main/runtime/structured-agent-runtime-registrations.ts +++ b/src/main/runtime/structured-agent-runtime-registrations.ts @@ -50,6 +50,7 @@ import { resolveAcpLaunchCommand } from '../acp/acp-structured-launch-resolution' import { AcpStructuredSessionAdapter } from '../acp/acp-structured-session-adapter' +import { PI_RPC_RUNTIME_REGISTRATION } from '../pi/rpc-runtime-registration' /** What an agent's adapter is built from: the open store and the runtime around it. */ export type StructuredAgentAdapterContext = { @@ -266,6 +267,7 @@ async function resolveCodexAccountHomePath( export const STRUCTURED_AGENT_RUNTIME_REGISTRATIONS: readonly StructuredAgentRuntimeRegistration[] = [ + PI_RPC_RUNTIME_REGISTRATION, { definition: CODEX_STRUCTURED_AGENT, createAdapter: createCodexAdapter, diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 8fcc2637d46..b5e24dbcdcd 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -10,6 +10,7 @@ // A process whose journal will not open installs none and answers every // structured request with the refusal that says why. +import type { PiRpcSessionDeps } from '../pi/rpc-session' import type { PermissionMode } from '@anthropic-ai/claude-agent-sdk' import { existsSync } from 'node:fs' import type { AgentSessionRecord } from '../../shared/agent-session-record' @@ -100,6 +101,7 @@ export type StructuredAgentSessionRuntimeDeps = { resolveWorkspacePath: (workspaceId: string) => Promise resolveCodexCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string resolveClaudeCommand?: () => string + openPiConnection?: PiRpcSessionDeps['openConnection'] /** Which version-gated flags a Claude CLI takes; absent never passes one. */ claudeCliFlags?: ClaudeCliFlagSupport /** Gives each chat a visuals folder and the skill that teaches it, and sweeps folders whose chat diff --git a/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx b/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx index dbb90df8b9f..6681b9ccd2e 100644 --- a/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx @@ -1,4 +1,5 @@ import { ImeInput } from '@/lib/ime-text-field' +import { Textarea } from '@/components/ui/textarea' import { useRef, useState, type RefObject } from 'react' import { Check, Pencil, X } from 'lucide-react' import { cn } from '@/lib/utils' @@ -7,6 +8,7 @@ import type { AskAnswerSelection, AskPrompt } from './native-chat-interactive-pr import { NativeChatPromptCollapseToggle } from './NativeChatPromptCollapse' import { useNativeChatPromptCardFocus } from './use-native-chat-prompt-card-focus' import { isEditableTarget } from '@/lib/editable-target' +import type { AgentJournalFreeTextInput } from '../../../../shared/agent-session-journal-types' export type NativeChatQuestionCardProps = { prompt: AskPrompt @@ -16,6 +18,7 @@ export type NativeChatQuestionCardProps = { /** Deliver the chosen answer (per-question option indices + free text). */ onAnswer: (selections: AskAnswerSelection[]) => void allowOther?: boolean | readonly boolean[] + freeTextInputs?: readonly (AgentJournalFreeTextInput | undefined)[] /** Dismiss the prompt (sends Escape to the agent). */ onCancel: () => void /** Fold the card to a strip and give the input back, writing nothing; Escape does too. */ @@ -45,6 +48,7 @@ export function NativeChatQuestionCard({ isCancelling = false, onAnswer, allowOther = true, + freeTextInputs, onCancel, onCollapse, shouldFocus = false, @@ -56,17 +60,24 @@ export function NativeChatQuestionCard({ // Keep option identity by index: labels are display text and are not guaranteed // unique, while Claude's selector commits the numbered row (STA-1860). const [selections, setSelections] = useState(() => prompt.questions.map(() => [])) - const [otherText, setOtherText] = useState(() => prompt.questions.map(() => '')) + const [otherText, setOtherText] = useState(() => + prompt.questions.map((_, qi) => freeTextInputs?.[qi]?.initialValue ?? '') + ) const total = prompt.questions.length const isLast = index === total - 1 const q = prompt.questions[index]! const questionAllowsOther = Array.isArray(allowOther) ? (allowOther[index] ?? false) : allowOther + const freeTextInput = freeTextInputs?.[index] + const acceptsEmpty = (qi: number): boolean => + freeTextInputs?.[qi]?.allowEmpty === true && prompt.questions[qi]?.options.length === 0 + const typedText = (qi: number, oth = otherText): string => + freeTextInputs?.[qi]?.allowEmpty ? (oth[qi] ?? '') : (oth[qi] ?? '').trim() // Picking an option replaces a chosen typed answer on single-select; the text stays in // the field, unsent, until the user types or clicks there again. const typedAnswerChosen = (qi: number, sel = selections, oth = otherText): boolean => - (sel[qi] ?? []).includes(TYPED_ANSWER) && (oth[qi] ?? '').trim().length > 0 + acceptsEmpty(qi) || ((sel[qi] ?? []).includes(TYPED_ANSWER) && typedText(qi, oth).length > 0) const chooseTypedAnswer = (qi: number): void => { setSelections((prev) => { @@ -99,21 +110,23 @@ export function NativeChatQuestionCard({ const picked = pickedOptions(qi, sel) .map((optionIndex) => question?.options[optionIndex]?.label ?? '') .filter((label) => label.length > 0) - const other = typedAnswerChosen(qi, sel, oth) ? (oth[qi] ?? '').trim() : '' + const other = typedAnswerChosen(qi, sel, oth) ? typedText(qi, oth) : '' return [...picked, ...(other ? [other] : [])].join(', ') } - const currentAnswered = answerFor(index).length > 0 + const currentAnswered = answerFor(index).length > 0 || acceptsEmpty(index) const currentTypedAnswerChosen = typedAnswerChosen(index) const submitAll = (sel: number[][], oth: string[]): void => { const resolved: AskAnswerSelection[] = prompt.questions.map((_, i) => { return { indices: pickedOptions(i, sel), - other: typedAnswerChosen(i, sel, oth) ? (oth[i] ?? '').trim() : '' + other: typedAnswerChosen(i, sel, oth) ? typedText(i, oth) : '' } }) - const anyAnswered = resolved.some((s) => s.indices.length > 0 || (s.other ?? '').length > 0) + const anyAnswered = resolved.some( + (s, i) => s.indices.length > 0 || (s.other ?? '').length > 0 || acceptsEmpty(i) + ) if (anyAnswered) { onAnswer(resolved) } @@ -159,7 +172,7 @@ export function NativeChatQuestionCard({ advanceOrSubmit(selections, otherText) return } - const anyAnswered = prompt.questions.some((_, i) => answerFor(i).length > 0) + const anyAnswered = prompt.questions.some((_, i) => answerFor(i).length > 0 || acceptsEmpty(i)) if (anyAnswered) { submitAll(selections, otherText) } else if (!fromKeyboard) { @@ -285,35 +298,47 @@ export function NativeChatQuestionCard({ AskUserQuestion tool result: it reaches the model but never the command parser, so `/compact` and friends are inert, while a skill name can still be acted on. */} - setOther(index, e.target.value)} - // Click, not focus: tabbing through the field toward Submit must not - // replace the option the user just picked. - onClick={() => { - if ((otherText[index] ?? '').trim().length > 0) { - chooseTypedAnswer(index) + {freeTextInput?.multiline ? ( +