Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
43 changes: 43 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,47 @@ 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 |\n");
// Agents can repeat a terminal update after the call finished.
yield* progress("completed", "done");
yield* say("| 3 | z |");
return { stopReason: "end_turn" };
}

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

Expand Down
48 changes: 48 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,54 @@ 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(), 9)));
// The coalesced progress tick emits nothing, and neither the completion
// nor a repeated one splits the markdown table across items.
expect(notes.map((note) => note._tag)).toEqual([
"ToolCallUpdated",
"AssistantItemStarted",
"ContentDelta",
"ContentDelta",
"ToolCallUpdated",
"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
16 changes: 12 additions & 4 deletions apps/server/src/provider/acp/AcpSessionRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,9 @@ export const make = (
const eventQueue = yield* Queue.unbounded<AcpSessionRuntimeEvent>();
const modeStateRef = yield* Ref.make<AcpSessionModeState | undefined>(undefined);
const toolCallsRef = yield* Ref.make(new Map<string, AcpToolCallTrackedState>());
// Tool calls already shown. A late update to a finished call is not a new
// boundary in the answer, although its progress state is gone.
const shownToolCallIds = new Set<string>();
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
const assistantItemRuntimeId = yield* crypto.randomUUIDv4.pipe(
Effect.mapError(
(cause) =>
Expand Down Expand Up @@ -525,6 +528,7 @@ export const make = (
modeStateRef,
configOptionsRef,
toolCallsRef,
shownToolCallIds,
assistantSegmentRef,
assistantItemRuntimeId,
params: notification,
Expand Down Expand Up @@ -1178,6 +1182,7 @@ const handleSessionUpdate = ({
modeStateRef,
configOptionsRef,
toolCallsRef,
shownToolCallIds,
assistantSegmentRef,
assistantItemRuntimeId,
params,
Expand All @@ -1186,6 +1191,7 @@ const handleSessionUpdate = ({
readonly modeStateRef: Ref.Ref<AcpSessionModeState | undefined>;
readonly configOptionsRef: Ref.Ref<ReadonlyArray<EffectAcpSchema.SessionConfigOption>>;
readonly toolCallsRef: Ref.Ref<Map<string, AcpToolCallTrackedState>>;
readonly shownToolCallIds: Set<string>;
readonly assistantSegmentRef: Ref.Ref<AcpAssistantSegmentState>;
readonly assistantItemRuntimeId: string;
readonly params: EffectAcpSchema.SessionNotification;
Expand All @@ -1202,10 +1208,6 @@ 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 tracked = current.get(event.toolCall.toolCallId);
const previous = tracked?.state;
Expand Down Expand Up @@ -1233,6 +1235,12 @@ const handleSessionUpdate = ({
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 (!shownToolCallIds.has(merged.toolCallId)) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
shownToolCallIds.add(merged.toolCallId);
yield* closeActiveAssistantSegment({ queue, assistantSegmentRef });
}
yield* Queue.offer(queue, {
_tag: "ToolCallUpdated",
toolCall: merged,
Expand Down
Loading