Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
40 changes: 40 additions & 0 deletions apps/server/scripts/acp-mock-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ const emitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS === "1";
const emitInterleavedAssistantToolCalls =
process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1";
const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1";
const emitBackgroundToolDuringAnswer =
process.env.T3_ACP_EMIT_BACKGROUND_TOOL_DURING_ANSWER === "1";
const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1";
const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION === "1";
const emitXAiExitPlanMode = process.env.T3_ACP_EMIT_XAI_EXIT_PLAN_MODE === "1";
Expand Down Expand Up @@ -941,6 +943,44 @@ const program = Effect.gen(function* () {
return yield* Effect.never;
}

if (emitBackgroundToolDuringAnswer) {
// A command backgrounded earlier reports progress and then finishes
// while the next answer is still streaming.
const toolCallId = "background-1";
const say = (text: string) =>
agent.client.sessionUpdate({
sessionId: requestedSessionId,
update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text } },
});
const progress = (status: "in_progress" | "completed", stdout: string) =>
agent.client.sessionUpdate({
sessionId: requestedSessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId,
status,
rawOutput: { stdout },
},
});
yield* agent.client.sessionUpdate({
sessionId: requestedSessionId,
update: {
sessionUpdate: "tool_call",
toolCallId,
title: "Terminal",
kind: "execute",
status: "in_progress",
rawInput: { command: "sleep 3 && echo done" },
},
});
yield* say("| a | b |\n|---|---|\n| 1 ");
yield* progress("in_progress", ".");
yield* say("| x |\n");
yield* progress("completed", "done");
yield* say("| 2 | y |");
return { stopReason: "end_turn" };
}

if (emitInterleavedAssistantToolCalls) {
const toolCallId = "tool-call-1";

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 @@ -824,6 +824,52 @@ describe("AcpSessionRuntime", () => {
),
);

it.effect("keeps one answer when an earlier tool reports progress mid-stream", () =>
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(Stream.take(runtime.getEvents(), 7)));
// The coalesced progress tick emits nothing and the completion lands
// between deltas; neither splits the markdown table across items.
expect(notes.map((note) => note._tag)).toEqual([
"ToolCallUpdated",
"AssistantItemStarted",
"ContentDelta",
"ContentDelta",
"ToolCallUpdated",
"ContentDelta",
"AssistantItemCompleted",
]);
const itemIds = new Set(
notes.flatMap((note) =>
note._tag === "ContentDelta" ||
note._tag === "AssistantItemStarted" ||
note._tag === "AssistantItemCompleted"
? [note.itemId]
: [],
),
);
expect(itemIds.size).toBe(1);
}).pipe(
Effect.provide(
AcpSessionRuntime.layer({
spawn: {
command: mockAgentCommand,
args: mockAgentArgs,
env: { T3_ACP_EMIT_BACKGROUND_TOOL_DURING_ANSWER: "1" },
},
cwd: process.cwd(),
clientInfo: { name: "t3-test", version: "0.0.0" },
authMethodId: "test",
}),
),
Effect.scoped,
Effect.provide(NodeServices.layer),
),
);

it.effect("emits status-only tool updates through completion", () =>
Effect.gen(function* () {
const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
Expand Down
13 changes: 7 additions & 6 deletions apps/server/src/provider/acp/AcpSessionRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1202,11 +1202,7 @@ const handleSessionUpdate = ({
}
for (const event of parsed.events) {
if (event._tag === "ToolCallUpdated") {
yield* closeActiveAssistantSegment({
queue,
assistantSegmentRef,
});
const { merged, decision } = yield* Ref.modify(toolCallsRef, (current) => {
const { merged, decision, isNew } = yield* Ref.modify(toolCallsRef, (current) => {
const tracked = current.get(event.toolCall.toolCallId);
const previous = tracked?.state;
const nextToolCall = mergeToolCallState(previous, event.toolCall);
Expand All @@ -1228,11 +1224,16 @@ const handleSessionUpdate = ({
skippedSinceEmit: decision.skippedSinceEmit,
});
}
return [{ merged: nextToolCall, decision }, next] as const;
return [{ merged: nextToolCall, decision, isNew: tracked === undefined }, next] as const;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
});
if (!decision.emit) {
continue;
}
// A new tool call is a boundary in the prose. Progress on a call that
// is already shown, such as a background command finishing, is not.
if (isNew) {
yield* closeActiveAssistantSegment({ queue, assistantSegmentRef });
}
yield* Queue.offer(queue, {
_tag: "ToolCallUpdated",
toolCall: merged,
Expand Down
Loading