[Bug]: Copilot ACP runs stay Working while a background (async) shell runs, because session/prompt is held#27301

opened11d agobyDdfgolfin

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/server

Steps to reproduce

Prerequisites: T3 Code desktop 0.1.3 with the GitHub Copilot CLI provider added from the ACP registry (github-copilot-cli; T3 installs Copilot CLI 1.0.105 under ~/.t3/tools/github-copilot-cli/1.0.105) and signed in. Any model works. I reproduced it with auto and gpt-6.1-sol.

  1. Start a new thread on the Copilot provider in any project. Full-access runtime mode avoids permission prompts.
  2. Send exactly:

    Use the bash tool with mode "async" (NOT detached) to run sleep 90. Do not wait for it and do not read its output. Then immediately reply with just the word ok. Do nothing else.

  3. Copilot replies ok within a few seconds. Watch the thread status.

Result (verified on 0.1.3): the thread stays Working for the full 90 s, until sleep exits. Then Copilot wakes up by itself, reads the shell output, writes a second message ("The 90-second sleep completed successfully."), and only then does the run settle. That second message replaces ok as the run's final result.

Control: run the same prompt, but ask for mode "async" with detach: true instead of "NOT detached". The run settles as soon as Copilot replies ok.

Confirming which case you hit: the model occasionally picks detach: true on its own, and then the bug won't trigger. Check the bash call arguments in Copilot's session log; they should read {"mode":"async","detach":false,...}:

sh
1# Newest Copilot session (the thread's ACP session):
2S=$(ls -td ~/.copilot/session-state/*/ | head -1)
3jq -c 'select(.type=="tool.execution_start" and .data.toolName=="bash") | .data.arguments' "$S/events.jsonl"
4# When Copilot actually finished vs. when the background shell woke it:
5jq -r 'select(.type|test("assistant.message|session.usage_checkpoint|system.notification")) | [.timestamp,.type,(.data.content//""|tostring|.[0:60])] | @tsv' "$S/events.jsonl"
6# When T3 got the session/prompt response:
7grep -h 'session/prompt' ~/.t3/userdata/logs/provider/events.<threadId>.log | sed -E 's/^\[([^]]*)\].*"status":"([^"]*)".*/\1 \2/'

Expected behavior

When Copilot's foreground work finishes, the run settles. The background shell keeps running and is shown as background work, the way it already is for providers that report agent work state (followAgentWorkState, currently Claude ACP on protocol v2). When the shell later exits and Copilot wakes up to read it, that work shows up as a continuation.

Actual behavior

The run stays Working until the background process exits. For a dev server, which is the common real-world case ("start the app so I can look at it"), that is indefinite. The final assistant message lands in the thread only when the run closes.

Timeline of the in-T3 reproduction above:

100:58:11.734Z T3 -> session/prompt started
200:58:15.175Z Copilot: bash {"command":"sleep 90","mode":"async","detach":false}
300:58:16.004Z Copilot: assistant.message "ok"
400:58:16.008Z Copilot: session.usage_checkpoint <- foreground work done (+4.3 s)
500:59:46.551Z Copilot: system.notification "Shell command ... has completed"
600:59:46.551Z Copilot: read_bash, then a new internal turn
700:59:51.769Z Copilot: assistant.message "The 90-second sleep completed successfully."
800:59:51.774Z T3 <- session/prompt succeeded <- run settles (+100 s)

Cause: Copilot CLI keeps the session/prompt response pending while a non-detached (attached) async shell started in that prompt is still running. Copilot confirmed this is the intended contract (https://github.com/github/copilot-cli/issues/5293, confirmed on 1.0.106-6). Copilot negotiates ACP protocol v1, so it never sends state_update { state: "idle" }, and T3 currently uses the session/prompt response as its only completion signal.

A documented signal exists. Copilot's maintainers point to the opt-in event extension T3 already uses for subagent routing (copilotClientCapabilitiesMeta, delivered as github.com/copilot/sessionEvent notifications with the event type in params.type). Subscribe to these events at initialize:

  • assistant.turn_start: foreground activity is starting or resuming.
  • assistant.idle: foreground work has settled. Attached background work may remain, and the session/prompt response may still be pending.
  • session.idle: the whole session is quiescent, including attached work.

Ignore events carrying params.agentId (subagents) when deciding the parent run's state. Copilot explicitly says not to use assistant.turn_end (it ends a single model turn) or session.usage_checkpoint (optional accounting data) for this. An earlier version of this issue suggested session.usage_checkpoint; please disregard that. assistant.idle is also not a task-completion guarantee: a background notification or Autopilot can start more work afterwards, which is what assistant.turn_start signals.

I confirmed that Copilot 1.0.105, the version T3 installs, forwards assistant.idle and session.idle when subscribed. I could not re-check their ordering around an attached shell on 1.0.105 because my Copilot quota ran out; Copilot's maintainer confirmed that ordering on 1.0.106-6. These events are not written to ~/.copilot/session-state/*/events.jsonl, so they only show up on the ACP wire.

The thread unsticks early only when:

  • the user sends another message (T3 sends session/cancel, the held prompt resolves, and the background process survives), or
  • the background process is killed or exits.

Real-world occurrence: Copilot started a local dev stack (frontend, backend, database, browser) with a non-detached async shell. Three consecutive runs stayed Working long after Copilot had finished: about 1.5 min, about 4 min, and about 10 min. The third ended 0.5 s after I killed the dev stack:

shell
1[00:26:05.882Z] session/prompt started # Copilot finished at 00:28:03.637Z
2[00:29:29.750Z] session/prompt succeeded # only when the next user message triggered session/cancel
3[00:29:29.792Z] session/prompt started # Copilot finished at 00:30:15.615Z
4[00:34:24.331Z] session/prompt succeeded # again only on the next user message
5[00:34:24.404Z] session/prompt started # Copilot answered at 00:34:26.771Z (2 s)
6[00:44:17.762Z] session/prompt succeeded # 0.5 s after the dev-stack shell was killed

Later in the same thread, Copilot relaunched the stack with detach: true, and those runs settled immediately. That fits the cause above.

Standalone ACP reproduction without T3 (Node, ~1 premium request)

Run with node acp_probe.mjs ~/.t3/tools/github-copilot-cli/1.0.105/npm/bin/copilot /tmp/some-empty-dir. To get the control case, change the prompt to ask for detach: true.

js
1import { spawn } from 'node:child_process';
2import { createInterface } from 'node:readline';
3const [cmd, cwd] = process.argv.slice(2);
4const t0 = Date.now(); const ts = () => ((Date.now() - t0) / 1000).toFixed(2).padStart(6);
5const child = spawn('node', [cmd, '--acp'], { cwd, stdio: ['pipe', 'pipe', 'ignore'] });
6let id = 0; const pending = new Map();
7const send = (method, params) => new Promise((res) => { const i = ++id; pending.set(i, res); child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: i, method, params }) + '\n'); });
8createInterface({ input: child.stdout }).on('line', (line) => {
9 let m; try { m = JSON.parse(line); } catch { return; }
10 if (m.id !== undefined && pending.has(m.id) && !m.method) { const r = pending.get(m.id); pending.delete(m.id); return r(m); }
11 if (m.method === 'session/request_permission') {
12 const opt = m.params.options.find((o) => o.kind?.startsWith('allow')) ?? m.params.options[0];
13 child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: m.id, result: { outcome: { outcome: 'selected', optionId: opt.optionId } } }) + '\n');
14 return console.log(ts(), 'permission ->', opt.optionId);
15 }
16 if (m.method === 'session/update') { const u = m.params.update; if (u.sessionUpdate.endsWith('_chunk')) return; return console.log(ts(), 'update', u.sessionUpdate, u.title ?? u.status ?? ''); }
17 console.log(ts(), 'notify', m.method, m.params?.type ?? '', JSON.stringify(m.params?.data ?? {}).slice(0, 140));
18});
19await send('initialize', { protocolVersion: 1, clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false,
20 _meta: { 'github.com/copilot': { events: ['assistant.turn_start', 'assistant.idle', 'session.idle', 'assistant.turn_end', 'session.usage_checkpoint', 'system.notification', 'tool.execution_start', 'tool.execution_complete'] } } } });
21const { result: { sessionId } } = await send('session/new', { cwd, mcpServers: [] });
22send('session/prompt', { sessionId, prompt: [{ type: 'text', text: 'Use the bash tool with mode "async" (NOT detached) to run `sleep 25`, do not wait or read its output, then immediately reply with just the word ok. Do nothing else.' }] })
23 .then((r) => console.log(ts(), 'PROMPT RESPONSE', JSON.stringify(r.result ?? r.error)));
24setTimeout(() => { child.kill(); process.exit(0); }, 50000);

Observed:

1non-detached: 3.94 session.usage_checkpoint -> 28.27 system.notification (shell done) -> 29.42 PROMPT RESPONSE end_turn
2detach: true: 5.18 session.usage_checkpoint -> 5.18 PROMPT RESPONSE end_turn -> 29.87 system.notification (wake still delivered)

Impact

Major degradation or frequent failure

Version or commit

T3 Code (Beta) 0.1.3 desktop

Environment

macOS 27.0.1 (arm64), Node 24.13.0. Provider: ACP registry github-copilot-cli, Copilot CLI 1.0.105 (copilot --acp, negotiates ACP protocolVersion 1). Models: auto, gpt-6.1-sol, claude-sonnet-5.5.

Workaround

Send another message (the cancel resolves the held prompt without killing the background process), kill the background process, or ask Copilot to start long-running processes with detach: true.

Possible fix

For the Copilot flavor in makeAcpRegistryAdapterV2:

  1. Add assistant.turn_start, assistant.idle and session.idle to the events list in copilotClientCapabilitiesMeta.
  2. In copilotRouting.normalizeSessionUpdate, for events on the root session without params.agentId:
    • map assistant.idle to a synthesized state_update { state: "idle", stopReason: "end_turn" }, so the run settles while attached background work keeps running;
    • map assistant.turn_start after a settle to state_update { state: "running" }, so a background wake becomes a continuation;
    • use session.idle (or the eventual session/prompt response) to mark the attached background work as finished.
  3. Enable the existing agent-work-state path (followAgentWorkState, settleIdleWork, wake buffering and continuation runs) for Copilot even though it negotiates protocol v1, and absorb the still-pending session/prompt response when it eventually resolves.

A focused test can replay the recorded event sequence: assistant.turn_start → assistant.idle (prompt still pending) → assistant.turn_start (background wake) → assistant.idle → session.idle → session/prompt end_turn. It should check that the run settles at the first assistant.idle and that the wake lands as a continuation.

I'm happy to send a PR for this if maintainers agree with the direction.

Synced from GitHub

Update from upstream: Copilot CLI closed github/copilot-cli#5293. Holding session/prompt while an attached async shell runs is the intended contract. The documented foreground-completion signal is the opt-in assistant.idle session event, with assistant.turn_start for resume and session.idle for full quiescence; events carrying agentId should be ignored. They advise against session.usage_checkpoint and assistant.turn_end for this. I've updated the Cause, A documented signal exists and Possible fix sections above to match. I also confirmed that Copilot 1.0.105, the version T3 installs, forwards assistant.idle and session.idle when subscribed.

Synced from GitHub

Also reproduced the issue on T3 Code 0.1.6 (Windows 11) with Copilot CLI 1.0.105, including the event ordering around an attached shell that the issue couldn't re-check on 1.0.105.

Results from a standalone ACP client subscribed to assistant.idle and session.idle:

Runassistant.idlesession.idlesession/prompt
Async shell (not detached), never exits7.0sneverstill pending at 122s; cancelled only after session/cancel
No shell (control)3.1s3.1send_turn at 3.1s

assistant.idle arrives as soon as foreground work finishes, while session/prompt is still held.

Synced from GitHub

I prepared a focused fix for the foreground-idle and root-continuation behavior described here: reviewable branch diff, head 38907e5ddd91.

It normalizes Copilot's documented root events into T3's foreground/continuation handling, separates pending RPC ownership, and preserves Stop behavior according to the negotiated close capability. The implementation passed 148 focused tests and affected checks, independent Claude Opus 5.5 review found no blocking issues, and standalone Copilot 1.0.107 probes exercised held RPCs, shell liveness, and text/image prompt acceptance. Manual T3 app testing and live Stop were not performed; the PR description draft records the remaining limits.

Before opening a PR under CONTRIBUTING.md's substantial-bug rule, could a maintainer confirm the failure/intended behavior and whether this direction is within the scope you'd consider? This covers #27301 and #29211 Bug A. It leaves #29235's replay work and the separate SDK provider proposal alone.