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
10 changes: 10 additions & 0 deletions products/desktop/docs/TROUBLESHOOTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ Restarting the session does not reduce the request size.
Start a new task with a short text summary of the work.
Do not copy the old images or full tool output into the new task.

## Completion sounds when resuming a task

Resuming an idle sandbox does not trigger a completion sound or notification.
For Pi sessions, completion notifications require an active turn and stay silent during restart setup.
After a cloud restart, the server must confirm the resent message before a completion can notify.
Returning to the task while the message is pending does not trigger a completion notification.
Delayed completion events from restart setup stay silent while the message is pending.
If you stop a pending message, a successful cancellation clears the running state even if the message never appears in the conversation.
A completed reply or a request for input still follows your notification settings.

## Codex asks for the same permissions again

Codex Auto keeps approvals for actions outside its allowed scope.
Expand Down
2 changes: 1 addition & 1 deletion products/desktop/packages/agent/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ codex arm only, and `guard.e2e.test.ts` always runs:
Codex-only (advertised codex capabilities; registered as skipped on the claude
arm so the gap is visible):
- **mode switch** → `current_mode_update`.
- **steering** — a mid-turn prompt folds into the running turn via `turn/steer`.
- **steering** — the host holds a question open while it submits a second prompt, which folds into the running turn via `turn/steer`.
- **list + fork** — `listSessions` finds the session; `forkSession` branches it.

The command/file approval `{decision}` round-trip is **not** covered here: codex
Expand Down
31 changes: 31 additions & 0 deletions products/desktop/packages/agent/e2e/driver.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,37 @@ vi.mock("../src/adapters/acp-connection", () => ({
}));

describe("live e2e host permissions", () => {
it("holds a question open until the host releases its answer", async () => {
let releaseAnswer: () => void = () => {};
const answer = new Promise<void>((resolve) => {
releaseAnswer = resolve;
});
const onQuestion = vi.fn(() => answer);
openConnection({ adapter: "claude", cwd: "/tmp", onQuestion });
const request = {
sessionId: "session",
toolCall: {
toolCallId: "question",
title: "Choose an approach",
kind: "other",
_meta: { codeToolKind: "question" },
},
options: [{ kind: "allow_once", optionId: "option_0", name: "A" }],
} as unknown as RequestPermissionRequest;
const answered = vi.fn();
const response = Promise.resolve(
host.client?.requestPermission(request),
).then(answered);

await vi.waitFor(() => expect(onQuestion).toHaveBeenCalledOnce());
expect(answered).not.toHaveBeenCalled();
releaseAnswer();
await response;
expect(answered).toHaveBeenCalledWith({
outcome: { outcome: "selected", optionId: "option_0" },
});
});

it.each([
{
kind: "switch_mode",
Expand Down
5 changes: 5 additions & 0 deletions products/desktop/packages/agent/e2e/driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export function openConnection(opts: {
cwd: string;
codexOptions?: Record<string, unknown>;
onStructuredOutput?: (output: Record<string, unknown>) => Promise<void>;
onQuestion?: () => Promise<void>;
}): E2EConnection {
const { adapter, cwd } = opts;
// Sweep before every codex spawn so one leaked process (holding the
Expand Down Expand Up @@ -119,6 +120,9 @@ export function openConnection(opts: {
codeToolKind: p?.toolCall?._meta?.codeToolKind,
},
});
if (p?.toolCall?._meta?.codeToolKind === "question") {
await opts.onQuestion?.();
}
// Accepting a plan starts extra model turns outside the test's scenario.
if (p?.toolCall?.kind === "switch_mode") {
return { outcome: { outcome: "cancelled" } };
Expand Down Expand Up @@ -206,6 +210,7 @@ export async function openSession(opts: {
cwd: string;
codexOptions?: Record<string, unknown>;
onStructuredOutput?: (output: Record<string, unknown>) => Promise<void>;
onQuestion?: () => Promise<void>;
meta: Record<string, unknown>;
}): Promise<OpenSession> {
const c = openConnection(opts);
Expand Down
44 changes: 32 additions & 12 deletions products/desktop/packages/agent/e2e/session-lifecycle.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,40 +382,59 @@ for (const adapter of ADAPTERS) {
itCodex(
"folds a mid-turn prompt into the running turn via steering",
async () => {
let questionReady: () => void = () => {};
const question = new Promise<void>((resolve) => {
questionReady = resolve;
});
let releaseAnswer: () => void = () => {};
const answer = new Promise<void>((resolve) => {
releaseAnswer = resolve;
});
const s = await openSession({
adapter,
cwd: repo,
codexOptions: codexOptions(),
meta: meta(),
onQuestion: async () => {
questionReady();
await answer;
},
});
try {
await s.conn.setSessionConfigOption({
sessionId: s.sessionId,
configId: "mode",
value: "plan",
});
const p1 = s.conn.prompt({
sessionId: s.sessionId,
prompt: [
{
type: "text",
text: "Count up from 1, one number per line, and keep going.",
text:
"Call request_user_input once to ask whether to use approach A or B. " +
"Wait for the answer, then acknowledge it in one sentence and stop. " +
"Do not call other tools or propose an implementation plan.",
},
],
});
await waitFor(
() =>
s.capture.updates("agent_message_chunk").length > 0
? true
: undefined,
20_000,
);
const p2 = s.conn.prompt({
await Promise.race([
question,
p1.then(() => {
throw new Error("The first turn ended before asking a question");
}),
]);
const r2 = await s.conn.prompt({
sessionId: s.sessionId,
prompt: [{ type: "text", text: "Now stop and say DONE." }],
});
const [r1] = await Promise.all([p1, p2]);
expect(r2).toMatchObject({ _meta: { steer: true } });
releaseAnswer();
const r1 = await p1;
expect(r1.stopReason).toBe("end_turn");
expect(
s.capture.updates("user_message_chunk").length,
).toBeGreaterThanOrEqual(2);
// The steer proof: folded into a SINGLE turn (one turn_complete). Two would
// mean the steer didn't take and p2 ran as its own turn.
const turnCompletes = s.capture.events.filter(
(e) =>
e.kind === "extNotification" &&
Expand All @@ -427,6 +446,7 @@ for (const adapter of ADAPTERS) {
"turn_complete); 2 means the steer didn't take",
).toBe(1);
} finally {
releaseAnswer();
await s.cleanup();
}
},
Expand Down
Loading
Loading