Skip to content
Open
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
38 changes: 38 additions & 0 deletions apps/server/scripts/acp-mock-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -470,6 +473,8 @@ const program = Effect.gen(function* () {
const agent = yield* EffectAcpAgent.AcpAgent;
const nativeCancelRequested = yield* Deferred.make<void>();
const nativeCancelRelease = yield* Deferred.make<void>();
const promptRequested = yield* Deferred.make<void>();
const loadSessionFinished = yield* Deferred.make<void>();
const publishAntigravityCommands = (targetSessionId: string) =>
agent.client.sessionUpdate({
sessionId: targetSessionId,
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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;
Expand Down
46 changes: 46 additions & 0 deletions apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
14 changes: 14 additions & 0 deletions packages/provider-acp/src/server/runtimeModel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
7 changes: 2 additions & 5 deletions packages/provider-acp/src/server/runtimeModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading