diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index de3fdb1a31404..a8039cd45bfa7 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -54,6 +54,9 @@ const failLoadSession = process.env.T3_ACP_FAIL_LOAD_SESSION === "1"; const failLoadSessionAfterConfigReplay = process.env.T3_ACP_FAIL_LOAD_SESSION_AFTER_CONFIG_REPLAY === "1"; const emitLoadReplay = process.env.T3_ACP_EMIT_LOAD_REPLAY === "1"; +// GitHub Copilot CLI sends session metadata first, goes quiet while it loads, then replays +// untagged history before answering session/load. +const emitSlowUntaggedLoadReplay = process.env.T3_ACP_EMIT_SLOW_UNTAGGED_LOAD_REPLAY === "1"; const failPrompt = process.env.T3_ACP_FAIL_PROMPT === "1"; const failPromptOrdinal = Number(process.env.T3_ACP_FAIL_PROMPT_ORDINAL); const compactionPromptOrdinals = new Set( @@ -470,6 +473,8 @@ const program = Effect.gen(function* () { const agent = yield* EffectAcpAgent.AcpAgent; const nativeCancelRequested = yield* Deferred.make(); const nativeCancelRelease = yield* Deferred.make(); + const promptRequested = yield* Deferred.make(); + const loadSessionFinished = yield* Deferred.make(); const publishAntigravityCommands = (targetSessionId: string) => agent.client.sessionUpdate({ sessionId: targetSessionId, @@ -668,6 +673,35 @@ const program = Effect.gen(function* () { if (emitLoadReplay) { emitLoadReplayNotifications(requestedSessionId); } + if (emitSlowUntaggedLoadReplay) { + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "available_commands_update", + availableCommands: [{ name: "compact", description: "Compact history" }], + }, + }); + yield* Deferred.await(promptRequested).pipe(Effect.timeoutOption("1 second")); + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: "history-subagent-1", + title: "History subagent", + kind: "other", + status: "in_progress", + }, + }); + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "agent_message_chunk", + messageId: "history-agent-message", + content: { type: "text", text: "history from an earlier turn" }, + }, + }); + yield* Deferred.succeed(loadSessionFinished, undefined); + } yield* agent.client.sessionUpdate({ sessionId: requestedSessionId, update: { @@ -809,6 +843,10 @@ const program = Effect.gen(function* () { yield* agent.handlePrompt((request) => Effect.gen(function* () { const requestedSessionId = String(request.sessionId ?? sessionId); + if (emitSlowUntaggedLoadReplay) { + yield* Deferred.succeed(promptRequested, undefined); + yield* Deferred.await(loadSessionFinished); + } beginAcpMockPrompt(cancelledSessions, requestedSessionId); promptCount += 1; const idleError = process.env.T3_ACP_IDLE_ERROR; diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index 653fb1d9cbfbc..e979132dae8a2 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -1149,6 +1149,52 @@ describe("AcpSessionRuntime", () => { ), ); + // Live clock: the agent's quiet load phase is subprocess wall time racing the idle gap. + it.live("keeps untagged history replayed after a quiet session/load out of the next prompt", () => + Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; + yield* runtime.start(); + + yield* runtime.prompt({ + prompt: [{ type: "text", text: "hi" }], + }); + const notes = Array.from( + yield* Stream.runCollect( + runtime + .getEvents() + .pipe( + Stream.takeUntil( + (note) => note._tag === "ContentDelta" && note.text === "hello from mock", + ), + ), + ), + ); + expect(notes.flatMap((note) => (note._tag === "ContentDelta" ? [note.text] : []))).toEqual([ + "hello from mock", + ]); + expect(notes.map((note) => note._tag)).not.toContain("ToolCallUpdated"); + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + authMethodId: "test", + spawn: { + command: mockAgentCommand, + args: mockAgentArgs, + env: { + T3_ACP_EMIT_SLOW_UNTAGGED_LOAD_REPLAY: "1", + }, + }, + cwd: process.cwd(), + resumeSessionId: "mock-session-1", + sessionLoadReplayIdleGap: "200 millis", + clientInfo: { name: "t3-test", version: "0.0.0" }, + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + ), + ); + it.effect("rejects invalid config option values before sending session/set_config_option", () => { const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "acp-runtime-")); const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); diff --git a/packages/provider-acp/src/server/runtimeModel.test.ts b/packages/provider-acp/src/server/runtimeModel.test.ts index c35789ec47dc4..cd4970ea9e068 100644 --- a/packages/provider-acp/src/server/runtimeModel.test.ts +++ b/packages/provider-acp/src/server/runtimeModel.test.ts @@ -91,6 +91,20 @@ describe("AcpRuntimeModel", () => { ).toBe(true); }); + it("does not count untagged session metadata as load replay activity", () => { + const metadata = { + sessionId: "session-1", + update: { + sessionUpdate: "available_commands_update", + availableCommands: [{ name: "compact", description: "Compact history" }], + }, + } satisfies EffectAcpSchema.SessionNotification; + expect(sessionUpdateCountsAsLoadReplayActivity(metadata)).toBe(false); + expect( + sessionUpdateCountsAsLoadReplayActivity({ ...metadata, _meta: { isReplay: true } }), + ).toBe(true); + }); + it("ignores load-replay activity from other sessions while a load gate is active", () => { expect( sessionUpdateCountsAsLoadReplayActivity( diff --git a/packages/provider-acp/src/server/runtimeModel.ts b/packages/provider-acp/src/server/runtimeModel.ts index 49fd56cc19d6c..b7caf0ded5727 100644 --- a/packages/provider-acp/src/server/runtimeModel.ts +++ b/packages/provider-acp/src/server/runtimeModel.ts @@ -1260,14 +1260,11 @@ export function sessionUpdateCountsAsLoadReplayActivity( case "plan": case "plan_update": case "plan_removed": - case "available_commands_update": - case "current_mode_update": - case "config_option_update": - case "session_info_update": - case "usage_update": case "compaction_update": case "compaction_summary_chunk": return true; + // Copilot pushes session metadata, then loads for seconds before replaying + // untagged history, so metadata must not arm the replay-idle fallback. default: return false; }