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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/codex-login-denied-cancelled.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": patch
---

Report a denied OpenAI Codex sign-in as cancelled instead of asking for the redirect URL.
5 changes: 5 additions & 0 deletions .changeset/subagent-turn-prompts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": patch
---

Show the prompt that started a subagent turn in the transcript.
5 changes: 5 additions & 0 deletions .changeset/task-detach-action.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/vscode-duplicated-stream-events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"pythinker": patch
---

Fix duplicated streaming output when a session is opened twice at the same time.
5 changes: 5 additions & 0 deletions .changeset/wire-journal-repair-retry.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion apps/pythinker-code/dist-web/.web-bundle-manifest.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"sourceHash": "d84e17f04092f5fb9afa9f4d323b614d3945ec8e33abbbc92f5793ad2c30959e",
"sourceHash": "aa9ad464f74ddd042d5435d29ed27bac364c25274ede0eade5d39cc7593d0eb6",
"sourceFileCount": 404
}
42 changes: 39 additions & 3 deletions apps/vscode/src/runtime/pythinker-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export class PythinkerRuntime {
private readonly log: PythinkerRuntimeOptions["log"];
private readonly sessions = new Map<string, SessionRuntime>();
private readonly sessionByView = new Map<string, string>();
private readonly viewChains = new Map<string, Promise<void>>();
private readonly pendingPermissionByView = new Map<string, PermissionMode>();
private closed = false;

Expand Down Expand Up @@ -101,6 +102,10 @@ export class PythinkerRuntime {
}

async openSession(options: OpenSessionOptions): Promise<SessionRuntime> {
return this.serializeView(options.webviewId, () => this.openSessionInner(options));
}

private async openSessionInner(options: OpenSessionOptions): Promise<SessionRuntime> {
this.ensureOpen();
const current = this.getSessionForView(options.webviewId);
const requestedId = options.sessionId ?? current?.id;
Expand All @@ -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 =
Expand All @@ -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) => {
Expand All @@ -156,14 +161,24 @@ export class PythinkerRuntime {
webviewId: string,
session: Session,
yoloModeSetting = false,
): Promise<SessionRuntime> {
return this.serializeView(webviewId, () =>
this.attachResumedSessionInner(webviewId, session, yoloModeSetting),
);
}

private async attachResumedSessionInner(
webviewId: string,
session: Session,
yoloModeSetting: boolean,
): Promise<SessionRuntime> {
const existing = this.sessions.get(session.id);
if (existing !== undefined && this.sessionByView.get(webviewId) === session.id) {
existing.subscribe(webviewId);
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 {
Expand All @@ -184,6 +199,10 @@ export class PythinkerRuntime {
}

async detachView(webviewId: string): Promise<void> {
return this.serializeView(webviewId, () => this.detachViewInner(webviewId));
}

private async detachViewInner(webviewId: string): Promise<void> {
const id = this.sessionByView.get(webviewId);
if (id === undefined) return;
this.sessionByView.delete(webviewId);
Expand All @@ -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<T>(webviewId: string, work: () => Promise<T>): Promise<T> {
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<void> {
const runtime = this.sessions.get(id);
if (runtime === undefined) {
Expand Down
85 changes: 85 additions & 0 deletions apps/vscode/test/pythinker-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand Down
1 change: 1 addition & 0 deletions docs/reference/server-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions packages/agent-core-v2/docs/state-manifest.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down
1 change: 1 addition & 0 deletions packages/agent-core-v2/src/agent/loop/turnEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
12 changes: 7 additions & 5 deletions packages/agent-core-v2/src/agent/tools/agent/agentTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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',
Expand All @@ -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 <notification>. Recovery cases: a later <notification type="task.lost" | "task.failed" | "task.killed"> for this subagent — its conversation history is preserved across session restarts and resume will pick it up.`,
].join('\n');
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
};
}

Expand Down
4 changes: 2 additions & 2 deletions packages/agent-core-v2/src/agent/tools/os/bash/bash.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading