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.
[Bug]: Copilot ACP runs stay Working while a background (async) shell runs, because session/prompt is held#27301
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.
- Start a new thread on the Copilot provider in any project. Full-access runtime mode avoids permission prompts.
- 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. - Copilot replies
okwithin 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,...}:
| 1 | # Newest Copilot session (the thread's ACP session): |
| 2 | S=$(ls -td ~/.copilot/session-state/*/ | head -1) |
| 3 | jq -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: |
| 5 | jq -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: |
| 7 | grep -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:
| 1 | 00:58:11.734Z T3 -> session/prompt started |
| 2 | 00:58:15.175Z Copilot: bash {"command":"sleep 90","mode":"async","detach":false} |
| 3 | 00:58:16.004Z Copilot: assistant.message "ok" |
| 4 | 00:58:16.008Z Copilot: session.usage_checkpoint <- foreground work done (+4.3 s) |
| 5 | 00:59:46.551Z Copilot: system.notification "Shell command ... has completed" |
| 6 | 00:59:46.551Z Copilot: read_bash, then a new internal turn |
| 7 | 00:59:51.769Z Copilot: assistant.message "The 90-second sleep completed successfully." |
| 8 | 00: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 thesession/promptresponse 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:
| 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.
| 1 | import { spawn } from 'node:child_process'; |
| 2 | import { createInterface } from 'node:readline'; |
| 3 | const [cmd, cwd] = process.argv.slice(2); |
| 4 | const t0 = Date.now(); const ts = () => ((Date.now() - t0) / 1000).toFixed(2).padStart(6); |
| 5 | const child = spawn('node', [cmd, '--acp'], { cwd, stdio: ['pipe', 'pipe', 'ignore'] }); |
| 6 | let id = 0; const pending = new Map(); |
| 7 | const 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'); }); |
| 8 | createInterface({ 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 | }); |
| 19 | await 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'] } } } }); |
| 21 | const { result: { sessionId } } = await send('session/new', { cwd, mcpServers: [] }); |
| 22 | send('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))); |
| 24 | setTimeout(() => { child.kill(); process.exit(0); }, 50000); |
Observed:
| 1 | non-detached: 3.94 session.usage_checkpoint -> 28.27 system.notification (shell done) -> 29.42 PROMPT RESPONSE end_turn |
| 2 | detach: 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:
- Add
assistant.turn_start,assistant.idleandsession.idleto theeventslist incopilotClientCapabilitiesMeta. - In
copilotRouting.normalizeSessionUpdate, for events on the root session withoutparams.agentId:- map
assistant.idleto a synthesizedstate_update { state: "idle", stopReason: "end_turn" }, so the run settles while attached background work keeps running; - map
assistant.turn_startafter a settle tostate_update { state: "running" }, so a background wake becomes a continuation; - use
session.idle(or the eventualsession/promptresponse) to mark the attached background work as finished.
- map
- 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-pendingsession/promptresponse 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.
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:
| Run | assistant.idle | session.idle | session/prompt |
|---|---|---|---|
| Async shell (not detached), never exits | 7.0s | never | still pending at 122s; cancelled only after session/cancel |
| No shell (control) | 3.1s | 3.1s | end_turn at 3.1s |
assistant.idle arrives as soon as foreground work finishes, while session/prompt is still held.
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.