diff --git a/.changeset/codex-login-denied-cancelled.md b/.changeset/codex-login-denied-cancelled.md new file mode 100644 index 000000000..1cc20b812 --- /dev/null +++ b/.changeset/codex-login-denied-cancelled.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Report a denied OpenAI Codex sign-in as cancelled instead of asking for the redirect URL. diff --git a/.changeset/subagent-turn-prompts.md b/.changeset/subagent-turn-prompts.md new file mode 100644 index 000000000..8a93531dc --- /dev/null +++ b/.changeset/subagent-turn-prompts.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Show the prompt that started a subagent turn in the transcript. diff --git a/.changeset/task-detach-action.md b/.changeset/task-detach-action.md new file mode 100644 index 000000000..9bbaaa541 --- /dev/null +++ b/.changeset/task-detach-action.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": minor +--- + +Add a task detach action to the server API. Call `POST /api/v1/sessions/{session_id}/tasks/{task_id}:detach` to move a running foreground task to the background. diff --git a/.changeset/vscode-duplicated-stream-events.md b/.changeset/vscode-duplicated-stream-events.md new file mode 100644 index 000000000..7a08bf1c4 --- /dev/null +++ b/.changeset/vscode-duplicated-stream-events.md @@ -0,0 +1,5 @@ +--- +"pythinker": patch +--- + +Fix duplicated streaming output when a session is opened twice at the same time. diff --git a/.changeset/wire-journal-repair-retry.md b/.changeset/wire-journal-repair-retry.md new file mode 100644 index 000000000..870894a98 --- /dev/null +++ b/.changeset/wire-journal-repair-retry.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Retry a failed session journal repair before writing new records, so no message is appended behind a corrupted tail. diff --git a/apps/pythinker-code/dist-web/.web-bundle-manifest.json b/apps/pythinker-code/dist-web/.web-bundle-manifest.json index 234acb0c3..2f1bc773c 100644 --- a/apps/pythinker-code/dist-web/.web-bundle-manifest.json +++ b/apps/pythinker-code/dist-web/.web-bundle-manifest.json @@ -1,4 +1,4 @@ { - "sourceHash": "d84e17f04092f5fb9afa9f4d323b614d3945ec8e33abbbc92f5793ad2c30959e", + "sourceHash": "aa9ad464f74ddd042d5435d29ed27bac364c25274ede0eade5d39cc7593d0eb6", "sourceFileCount": 404 } diff --git a/apps/vscode/src/runtime/pythinker-runtime.ts b/apps/vscode/src/runtime/pythinker-runtime.ts index 013c97be7..fd325310c 100644 --- a/apps/vscode/src/runtime/pythinker-runtime.ts +++ b/apps/vscode/src/runtime/pythinker-runtime.ts @@ -48,6 +48,7 @@ export class PythinkerRuntime { private readonly log: PythinkerRuntimeOptions["log"]; private readonly sessions = new Map(); private readonly sessionByView = new Map(); + private readonly viewChains = new Map>(); private readonly pendingPermissionByView = new Map(); private closed = false; @@ -101,6 +102,10 @@ export class PythinkerRuntime { } async openSession(options: OpenSessionOptions): Promise { + return this.serializeView(options.webviewId, () => this.openSessionInner(options)); + } + + private async openSessionInner(options: OpenSessionOptions): Promise { this.ensureOpen(); const current = this.getSessionForView(options.webviewId); const requestedId = options.sessionId ?? current?.id; @@ -119,7 +124,7 @@ export class PythinkerRuntime { if (runtime !== undefined) { assertSessionWorkDir(runtime.session, options.workDir); await applySessionPermission(runtime.session, runtime.permissionMode); - await this.detachView(options.webviewId); + await this.detachViewInner(options.webviewId); } else { const seedMode = defaultPermissionMode(options.yoloMode); const session = @@ -135,7 +140,7 @@ export class PythinkerRuntime { try { assertSessionWorkDir(session, options.workDir); const mode = await restorePermissionMode(session, seedMode); - await this.detachView(options.webviewId); + await this.detachViewInner(options.webviewId); runtime = this.wrapSession(session, mode); } catch (error) { await session.close().catch((closeError: unknown) => { @@ -156,6 +161,16 @@ export class PythinkerRuntime { webviewId: string, session: Session, yoloModeSetting = false, + ): Promise { + return this.serializeView(webviewId, () => + this.attachResumedSessionInner(webviewId, session, yoloModeSetting), + ); + } + + private async attachResumedSessionInner( + webviewId: string, + session: Session, + yoloModeSetting: boolean, ): Promise { const existing = this.sessions.get(session.id); if (existing !== undefined && this.sessionByView.get(webviewId) === session.id) { @@ -163,7 +178,7 @@ export class PythinkerRuntime { await existing.announceStatus(webviewId); return existing; } - await this.detachView(webviewId); + await this.detachViewInner(webviewId); let runtime = existing ?? this.sessions.get(session.id); if (runtime === undefined) { try { @@ -184,6 +199,10 @@ export class PythinkerRuntime { } async detachView(webviewId: string): Promise { + return this.serializeView(webviewId, () => this.detachViewInner(webviewId)); + } + + private async detachViewInner(webviewId: string): Promise { const id = this.sessionByView.get(webviewId); if (id === undefined) return; this.sessionByView.delete(webviewId); @@ -196,6 +215,23 @@ export class PythinkerRuntime { } } + // A view attaches to at most one session, so opens/detaches for one view + // must never overlap: concurrent callers that both miss `this.sessions` + // would wrap the same SDK session twice and double every streamed event. + private serializeView(webviewId: string, work: () => Promise): Promise { + const prev = this.viewChains.get(webviewId) ?? Promise.resolve(); + const run = prev.then(work, work); + const next = run.then( + () => undefined, + () => undefined, + ); + this.viewChains.set(webviewId, next); + void next.finally(() => { + if (this.viewChains.get(webviewId) === next) this.viewChains.delete(webviewId); + }); + return run; + } + async closeSession(id: string): Promise { const runtime = this.sessions.get(id); if (runtime === undefined) { diff --git a/apps/vscode/test/pythinker-runtime.test.ts b/apps/vscode/test/pythinker-runtime.test.ts index acbb761b4..9c013ced2 100644 --- a/apps/vscode/test/pythinker-runtime.test.ts +++ b/apps/vscode/test/pythinker-runtime.test.ts @@ -382,6 +382,91 @@ describe("Pythinker runtime (owns shared SDK sessions for Webviews)", () => { expect(boundary.handlerInstallations).toEqual({ approval: 1, question: 1 }); }); + it("does not double-wrap the SDK session when two opens race for it", async () => { + const sdk = createFakeHarness(); + const broadcasts: { event: string; data: unknown; webviewId?: string }[] = []; + const runtime = new PythinkerRuntime({ + version: "0.6.0", + harness: sdk.harness, + broadcast: (event, data, webviewId) => { + broadcasts.push({ event, data, webviewId }); + }, + captureBaseline: () => undefined, + log: () => undefined, + }); + const boundary = sdk.addSession("saved-1", "/workspace"); + + const [first, second] = await Promise.all([ + runtime.openSession(openOptions({ sessionId: "saved-1" })), + runtime.openSession(openOptions({ sessionId: "saved-1" })), + ]); + + expect(second).toBe(first); + expect(boundary.subscriptionCount()).toBe(1); + + boundary.emit({ + type: "assistant.delta", + sessionId: "saved-1", + agentId: "main", + turnId: 1, + delta: "Hello", + }); + + const parts = broadcasts.filter( + ({ data }) => (data as { type?: string }).type === "ContentPart", + ); + expect(parts).toHaveLength(1); + }); + + it("coalesces two concurrent new-session opens for one view onto one session", async () => { + const { runtime, sdk } = createRuntime(); + + const [first, second] = await Promise.all([ + runtime.openSession(openOptions()), + runtime.openSession(openOptions()), + ]); + + expect(second).toBe(first); + expect(sdk.createInputs).toHaveLength(1); + expect(first.subscribers).toEqual(["view-1"]); + }); + + it("does not double-wrap the SDK session when two attaches race for it", async () => { + const sdk = createFakeHarness(); + const broadcasts: { event: string; data: unknown; webviewId?: string }[] = []; + const runtime = new PythinkerRuntime({ + version: "0.6.0", + harness: sdk.harness, + broadcast: (event, data, webviewId) => { + broadcasts.push({ event, data, webviewId }); + }, + captureBaseline: () => undefined, + log: () => undefined, + }); + const boundary = sdk.addSession("saved-1", "/workspace"); + + const [first, second] = await Promise.all([ + runtime.attachResumedSession("view-1", boundary.session), + runtime.attachResumedSession("view-1", boundary.session), + ]); + + expect(second).toBe(first); + expect(boundary.subscriptionCount()).toBe(1); + + boundary.emit({ + type: "assistant.delta", + sessionId: "saved-1", + agentId: "main", + turnId: 1, + delta: "Hello", + }); + + const parts = broadcasts.filter( + ({ data }) => (data as { type?: string }).type === "ContentPart", + ); + expect(parts).toHaveLength(1); + }); + it("preserves the resumed session's model instead of reapplying the configured default", async () => { const { runtime, sdk } = createRuntime(); const session = sdk.addSession("saved-1", "/workspace", { model: "old-model" }); diff --git a/docs/reference/server-api.md b/docs/reference/server-api.md index aba7eaca4..9bebdc4c1 100644 --- a/docs/reference/server-api.md +++ b/docs/reference/server-api.md @@ -177,6 +177,7 @@ Endpoints are grouped by resource below. A `:{action}` suffix in a path is the a | `GET /api/v1/sessions/{session_id}/tasks` | List background tasks | | `GET /api/v1/sessions/{session_id}/tasks/{task_id}` | Read a task (optional output preview) | | `POST /api/v1/sessions/{session_id}/tasks/{task_id}:cancel` | Cancel a task | +| `POST /api/v1/sessions/{session_id}/tasks/{task_id}:detach` | Move a foreground task to the background | ### Skills, tools, and MCP diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index 71b9be85f..0d7d65d3c 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -1353,6 +1353,7 @@ export interface AgentStateSnapshot { readonly command: string; readonly pid: number; readonly exitCode: number | null; + readonly parentToolCallId?: string; readonly taskId: string; readonly description: string; readonly status: /* AgentTaskStatus — packages/agent-core-v2/src/agent/task/types.ts */ 'completed' | 'failed' | 'running' | 'timed_out' | 'killed' | 'lost'; @@ -1400,6 +1401,7 @@ export interface AgentStateSnapshot { readonly command: string; readonly pid: number; readonly exitCode: number | null; + readonly parentToolCallId?: string; readonly taskId: string; readonly description: string; readonly status: /* AgentTaskStatus — packages/agent-core-v2/src/agent/task/types.ts */ 'completed' | 'failed' | 'running' | 'timed_out' | 'killed' | 'lost'; diff --git a/packages/agent-core-v2/src/agent/loop/turnEvents.ts b/packages/agent-core-v2/src/agent/loop/turnEvents.ts index aa6ab6630..eaca52b92 100644 --- a/packages/agent-core-v2/src/agent/loop/turnEvents.ts +++ b/packages/agent-core-v2/src/agent/loop/turnEvents.ts @@ -69,6 +69,7 @@ export function turnPromptAttachments( export function isDisplayablePromptOrigin(origin: PromptOrigin): boolean { if (origin.kind === 'user') return true; + if (origin.kind === 'system_trigger' && origin.name === 'subagent') return true; return ( (origin.kind === 'skill_activation' || origin.kind === 'plugin_command') && origin.trigger === 'user-slash' diff --git a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts index 714400af0..b68ad9fb7 100644 --- a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts +++ b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts @@ -467,14 +467,14 @@ export class SubagentTool implements ISubagentTool { if (runInBackground) { return { - output: formatBackgroundAgentResult(taskId, handle, args.description, allowBackground), + output: formatBackgroundAgentResult(taskId, handle, args.description, allowBackground, false), }; } const release = await this.tasks.waitForForegroundRelease(taskId); if (release === 'detached') { return { - output: formatBackgroundAgentResult(taskId, handle, args.description, allowBackground), + output: formatBackgroundAgentResult(taskId, handle, args.description, allowBackground, true), }; } return await this.formatForegroundResult(taskId, handle, timeoutMs); @@ -560,7 +560,11 @@ function formatBackgroundAgentResult( handle: SubagentHandle, description: string, allowBackground: boolean, + detachedByUser: boolean, ): string { + const nextStep = allowBackground + ? `next_step: The completion arrives automatically in a later turn — do NOT wait, poll, or call TaskOutput on it; continue with other work or hand back to the user. (If you have nothing to do until it finishes, run such tasks in the foreground next time.)` + : 'next_step: The completion arrives automatically in a later turn.'; return [ `task_id: ${taskId}`, 'status: running', @@ -570,9 +574,7 @@ function formatBackgroundAgentResult( '', `description: ${description}`, '', - allowBackground - ? `next_step: The completion arrives automatically in a later turn — do NOT wait, poll, or call TaskOutput on it; continue with other work or hand back to the user. (If you have nothing to do until it finishes, run such tasks in the foreground next time.)` - : 'next_step: The completion arrives automatically in a later turn.', + detachedByUser ? `note: The user moved this subagent to the background.\n${nextStep}` : nextStep, `resume_hint: To continue or recover this same subagent later, call Agent(resume="${handle.agentId}", prompt="..."). The parameter is agent_id ("${handle.agentId}"), NOT task_id ("${taskId}") or source_id from a later . Recovery cases: a later for this subagent — its conversation history is preserved across session restarts and resume will pick it up.`, ].join('\n'); } diff --git a/packages/agent-core-v2/src/agent/tools/ask-user-question/askUserQuestionTool.ts b/packages/agent-core-v2/src/agent/tools/ask-user-question/askUserQuestionTool.ts index 38375003c..e53a0b04b 100644 --- a/packages/agent-core-v2/src/agent/tools/ask-user-question/askUserQuestionTool.ts +++ b/packages/agent-core-v2/src/agent/tools/ask-user-question/askUserQuestionTool.ts @@ -146,7 +146,7 @@ export class AskUserQuestionTool implements IAskUserQuestionTool { 'next_step: Continue your current work; the answer will arrive automatically when the user responds.\n' + 'next_step: Use TaskOutput with this task_id for a non-blocking status/answer snapshot.\n' + 'next_step: Use TaskStop only if the question should be cancelled.\n' + - 'human_shell_hint: The pending question is also visible in /tasks.', + 'human_shell_hint: The pending question is also visible in the client UI.', }; } diff --git a/packages/agent-core-v2/src/agent/tools/os/bash/bash.md b/packages/agent-core-v2/src/agent/tools/os/bash/bash.md index 6b3ec9c4b..63599e3b8 100644 --- a/packages/agent-core-v2/src/agent/tools/os/bash/bash.md +++ b/packages/agent-core-v2/src/agent/tools/os/bash/bash.md @@ -13,11 +13,11 @@ The dedicated tools render in the per-tool permission UI and keep raw stdout out **Output:** The stdout and stderr will be combined and returned as a string. The output may be truncated if it is too long. If the command exits non-zero, the output ends with a `Command failed with exit code: N` line; a command killed by its timeout or interrupted by the user ends with its own message instead. -If `run_in_background=true`, the command will be started as a background task and this tool will return a task ID instead of waiting for command completion. When doing that, you must provide a short `description`. Background commands default to a ${DEFAULT_BACKGROUND_TIMEOUT_S}s timeout and `timeout` is capped at ${MAX_BACKGROUND_TIMEOUT_S}s; set `disable_timeout=true` only when the task should run without a timeout. You will be automatically notified when the task completes. After starting one, default to returning control to the user instead of immediately waiting on it. Use `TaskOutput` only for a non-blocking status/output snapshot — do not wait on a task you just launched, since its completion arrives automatically. Use `TaskStop` only if the task must be cancelled. If a human user wants to inspect background tasks themselves, point them to the `/tasks` command, which opens an interactive panel; it has no subcommands. +If `run_in_background=true`, the command will be started as a background task and this tool will return a task ID instead of waiting for command completion. When doing that, you must provide a short `description`. Background commands default to a ${DEFAULT_BACKGROUND_TIMEOUT_S}s timeout and `timeout` is capped at ${MAX_BACKGROUND_TIMEOUT_S}s; set `disable_timeout=true` only when the task should run without a timeout. You will be automatically notified when the task completes. After starting one, default to returning control to the user instead of immediately waiting on it. Use `TaskOutput` only for a non-blocking status/output snapshot — do not wait on a task you just launched, since its completion arrives automatically. Use `TaskStop` only if the task must be cancelled. If a human user wants to inspect background tasks themselves, point them to the background-task panel. **Guidelines for safety and security:** - Each shell tool call will be executed in a fresh shell environment. The shell variables, current working directory changes, and the shell history is not preserved between calls. To run a command in a particular directory, pass the `cwd` argument (or use absolute paths) rather than relying on a `cd` from an earlier call. -- The tool call will return after the command is finished. You shall not use this tool to execute an interactive command or a command that may run forever. For possibly long-running foreground commands, set the `timeout` argument in seconds. Foreground commands default to ${DEFAULT_TIMEOUT_S}s and allow up to ${MAX_TIMEOUT_S}s. When a foreground command hits its timeout it is moved to the background instead of being killed, and you will be automatically notified when it completes. +- The tool call will return after the command is finished. You shall not use this tool to execute an interactive command or a command that may run forever. For possibly long-running foreground commands, set the `timeout` argument in seconds. Foreground commands default to ${DEFAULT_TIMEOUT_S}s and allow up to ${MAX_TIMEOUT_S}s. When a foreground command hits its timeout it is moved to the background instead of being killed, and you will be automatically notified when it completes. The user can also move a running foreground command to the background at any time. - Avoid using `..` to access files or directories outside of the working directory. - Avoid modifying files outside of the working directory unless explicitly instructed to do so. - Never run commands that require superuser privileges unless explicitly instructed to do so. diff --git a/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts b/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts index 961ffd903..41090010c 100644 --- a/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts +++ b/packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts @@ -69,13 +69,17 @@ function renderBashDescription(shellName: string): string { function withoutBackgroundDescription(description: string): string { return description .replace( - /\r?\n\r?\nIf `run_in_background=true`,[\s\S]*?point them to the `\/tasks` command, which opens an interactive panel; it has no subcommands\./, + /\r?\n\r?\nIf `run_in_background=true`,[\s\S]*?point them to the background-task panel\./, '\n\nBackground execution is disabled for this agent. Do not set `run_in_background=true`.', ) .replace( ` For possibly long-running foreground commands, set the \`timeout\` argument in seconds. Foreground commands default to ${String(DEFAULT_TIMEOUT_S)}s and allow up to ${String(MAX_TIMEOUT_S)}s. When a foreground command hits its timeout it is moved to the background instead of being killed, and you will be automatically notified when it completes.`, ` For possibly long-running commands, set the \`timeout\` argument in seconds. The default is ${String(DEFAULT_TIMEOUT_S)}s; foreground commands allow up to ${String(MAX_TIMEOUT_S)}s; a foreground command that hits its timeout is killed.`, ) + .replace( + ' The user can also move a running foreground command to the background at any time.', + '', + ) .replace( /\r?\n- Prefer `run_in_background=true`[\s\S]*?conversation to continue before the command finishes\./, '\n- Do not set `run_in_background=true`; background task management tools are not available.', @@ -145,8 +149,8 @@ export class BashTool implements IBashTool { }, approvalRule: literalRulePattern(this.name, args.command), matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, args.command), - execute: ({ signal, onUpdate, onForegroundTaskStart }) => - this.execution(args, signal, onUpdate, onForegroundTaskStart), + execute: ({ signal, onUpdate, onForegroundTaskStart, toolCallId }) => + this.execution(args, signal, toolCallId, onUpdate, onForegroundTaskStart), }; } @@ -171,6 +175,7 @@ export class BashTool implements IBashTool { private async execution( args: BashInput, signal: AbortSignal, + toolCallId: string, onUpdate?: (update: ToolUpdate) => void, onForegroundTaskStart?: (taskId: string) => void, ): Promise { @@ -226,7 +231,7 @@ export class BashTool implements IBashTool { let taskId: string; try { taskId = this.tasks.registerTask( - new ProcessTask(proc, command, description, onProcessOutput, () => lease.dispose()), + new ProcessTask(proc, command, description, onProcessOutput, () => lease.dispose(), toolCallId), { detached: startsInBackground, timeoutMs, @@ -266,8 +271,8 @@ export class BashTool implements IBashTool { brief: `Backgrounded ${taskId} after timeout`, } : { - title: 'Task moved to background', - brief: `Backgrounded ${taskId}`, + title: 'Task moved to background by the user', + brief: `Backgrounded ${taskId} by the user`, }; return this.backgroundStartedResult( taskId, @@ -275,7 +280,7 @@ export class BashTool implements IBashTool { description, labels, builder, - 'foreground_detached', + release === 'timeout_detached' ? 'foreground_detached' : 'foreground_detached_by_user', ); } @@ -375,17 +380,19 @@ export class BashTool implements IBashTool { description: string, labels: { title: string; brief: string }, builder = new ToolOutputAccumulator(), - scenario: 'background_started' | 'foreground_detached' = 'background_started', + scenario: 'background_started' | 'foreground_detached' | 'foreground_detached_by_user' = 'background_started', ): ExecutableToolResult { const status = this.tasks.getTask(taskId)?.status ?? 'running'; + const detachedByUser = scenario === 'foreground_detached_by_user' ? 'detached_by_user: true\n' : ''; const metadata = `task_id: ${taskId}\n` + `pid: ${String(proc.pid)}\n` + `description: ${description}\n` + `status: ${status}\n` + + detachedByUser + `automatic_notification: true\n` + this.nextStepLines(scenario) + - 'human_shell_hint: Tell the human to run /tasks to open the interactive background-task panel.'; + 'human_shell_hint: The task is visible in the background-task panel.'; const foregroundResult = builder.ok(''); const foregroundOutput = foregroundResult.output.length > 0 ? foregroundResult.output : ''; @@ -403,14 +410,18 @@ export class BashTool implements IBashTool { } private nextStepLines( - scenario: 'background_started' | 'foreground_detached', + scenario: 'background_started' | 'foreground_detached' | 'foreground_detached_by_user', ): string { - if (scenario === 'foreground_detached') { + if (scenario === 'foreground_detached' || scenario === 'foreground_detached_by_user') { const avoid = this.allowBackground() ? 'do NOT wait, poll, or call TaskOutput on it' : 'do NOT wait or poll'; + const moved = + scenario === 'foreground_detached_by_user' + ? 'The user moved this task to the background.' + : 'The task now runs in the background.'; return ( - 'next_step: The task now runs in the background. You will be automatically notified ' + + `next_step: ${moved} You will be automatically notified ` + `when it completes — ${avoid}; continue with your current work.\n` ); } diff --git a/packages/agent-core-v2/src/agent/tools/os/bash/process-task.ts b/packages/agent-core-v2/src/agent/tools/os/bash/process-task.ts index bddf4c160..fe66797c7 100644 --- a/packages/agent-core-v2/src/agent/tools/os/bash/process-task.ts +++ b/packages/agent-core-v2/src/agent/tools/os/bash/process-task.ts @@ -16,6 +16,7 @@ export interface ProcessTaskInfo extends AgentTaskInfoBase { readonly command: string; readonly pid: number; readonly exitCode: number | null; + readonly parentToolCallId?: string; } declare module '#/agent/task/types' { @@ -44,6 +45,7 @@ export class ProcessTask implements AgentTask { readonly description: string, private readonly onOutput?: ProcessTaskOutputCallback, private release?: () => void, + readonly parentToolCallId?: string, ) {} async start(sink: AgentTaskSink): Promise { @@ -101,6 +103,7 @@ export class ProcessTask implements AgentTask { command: this.command, pid: this.proc.pid, exitCode: this.exitCode, + parentToolCallId: this.parentToolCallId, }; } diff --git a/packages/agent-core-v2/src/app/codexLogin/codexLoginService.ts b/packages/agent-core-v2/src/app/codexLogin/codexLoginService.ts index b98cc2795..c30b35ba3 100644 --- a/packages/agent-core-v2/src/app/codexLogin/codexLoginService.ts +++ b/packages/agent-core-v2/src/app/codexLogin/codexLoginService.ts @@ -118,6 +118,10 @@ export class CodexLoginFlow { void callback .waitForCode({ timeoutMs: LOGIN_TTL_MS }) .then((result) => { + if (result !== null && 'denied' in result) { + this.deny(attempt); + return; + } if (result === null) { if ( attempt.state === 'pending' && @@ -222,6 +226,13 @@ export class CodexLoginFlow { this.attempt = undefined; } + private deny(attempt: Attempt): void { + if (attempt.state !== 'pending' || attempt.committing === true) return; + attempt.state = 'cancelled'; + attempt.message = 'OpenAI Codex authorization was denied.'; + this.cleanup(attempt); + } + private expire(attempt: Attempt): void { if (attempt.state !== 'pending' || attempt.committing === true) return; attempt.state = 'failed'; diff --git a/packages/agent-core-v2/src/wire/repair.ts b/packages/agent-core-v2/src/wire/repair.ts index 801793bcf..19fe4e87f 100644 --- a/packages/agent-core-v2/src/wire/repair.ts +++ b/packages/agent-core-v2/src/wire/repair.ts @@ -23,7 +23,7 @@ export async function repairWireJournal( key: string, records: readonly unknown[], truncation: AppendLogTruncation, -): Promise { +): Promise<'repaired' | 'failed'> { const { appendLog, storage, log, telemetry } = services; let backupCreated = false; let outcome: 'repaired' | 'failed' = 'repaired'; @@ -60,6 +60,7 @@ export async function repairWireJournal( dropped_count: droppedCount, backup_created: backupCreated, }); + return outcome; } function countJournalLines(data: Uint8Array): number { diff --git a/packages/agent-core-v2/src/wire/wireService.ts b/packages/agent-core-v2/src/wire/wireService.ts index 7dbdf75b8..aeeffab26 100644 --- a/packages/agent-core-v2/src/wire/wireService.ts +++ b/packages/agent-core-v2/src/wire/wireService.ts @@ -39,6 +39,10 @@ export class WireService extends Service implements IWireService { private readonly wireScope: string; private persistQueue: Promise | undefined; + private pendingRepair: + | { readonly records: WireRecord[]; readonly truncation: AppendLogTruncation } + | undefined; + private persistError: Error | undefined; constructor( @IAgentScopeContext scopeContext: IAgentScopeContext, @@ -63,7 +67,11 @@ export class WireService extends Service implements IWireService { } appendRecord(record: WireRecord, dehydrate?: RecordDehydrator): void { - if (dehydrate === undefined && this.persistQueue === undefined) { + if ( + this.pendingRepair === undefined && + dehydrate === undefined && + this.persistQueue === undefined + ) { try { this.appendRecordLow(record); } catch (error) { @@ -77,6 +85,9 @@ export class WireService extends Service implements IWireService { ) as Promise; const queued = (this.persistQueue ?? Promise.resolve()) .then(async () => { + if (this.pendingRepair !== undefined) { + await this.repairPendingJournal(); + } const output = dehydrate === undefined ? record : await dehydrate(record, transform); this.appendRecordLow(output); }) @@ -165,7 +176,7 @@ export class WireService extends Service implements IWireService { records.push(record); } } - await repairWireJournal( + const outcome = await repairWireJournal( { appendLog: this.log, storage: this.storage, @@ -177,10 +188,38 @@ export class WireService extends Service implements IWireService { records, truncation, ); + this.pendingRepair = outcome === 'failed' ? { records, truncation } : undefined; + } + + private async repairPendingJournal(): Promise { + const pending = this.pendingRepair; + if (pending === undefined) return; + await this.repairJournal(pending.truncation, pending.records); + if (this.pendingRepair !== undefined) { + const error = new WireError( + WireErrors.codes.RECORDS_WRITE_FAILED, + 'Wire journal repair did not complete; record was not appended', + { + details: { + scope: this.wireScope, + key: AGENT_WIRE_RECORD_KEY, + lineNumber: pending.truncation.lineNumber, + }, + }, + ); + this.persistError = error; + throw error; + } } async flush(): Promise { await this.persistQueue; + if (this.pendingRepair !== undefined && this.persistError === undefined) { + await this.repairPendingJournal().catch(() => undefined); + } + const persistError = this.persistError; + this.persistError = undefined; + if (persistError !== undefined) throw persistError; await this.log.flush(); } diff --git a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts index 88c4296d3..833f22974 100644 --- a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts +++ b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts @@ -658,7 +658,7 @@ describe('FullCompaction', () => { event: 'compaction_finished', properties: expect.objectContaining({ source: 'manual', - tokens_before: 17_935, + tokens_before: 17_943, retry_count: 1, trace_id: 'trace-compact-1', }), @@ -1125,7 +1125,7 @@ describe('FullCompaction', () => { properties: expect.objectContaining({ agent_id: 'main', source: 'manual', - tokens_before: 17_935, + tokens_before: 17_943, duration_ms: expect.any(Number), round: 1, retry_count: 0, @@ -1350,7 +1350,7 @@ describe('FullCompaction', () => { event: 'compaction_failed', properties: expect.objectContaining({ source: 'manual', - tokens_before: 17_935, + tokens_before: 17_943, duration_ms: expect.any(Number), retry_count: 4, error_type: 'APIConnectionError', diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index 41ad96dae..5d77e5f54 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -142,8 +142,8 @@ describe('Agent loop', () => { [emit] turn.step.started { "time": "