Skip to content
Merged
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
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
27 changes: 21 additions & 6 deletions apps/server/src/provider/acp/AcpSessionRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ import {
type AcpToolCallState,
} from "./AcpRuntimeModel.ts";

const MAX_SHOWN_TOOL_CALL_IDS = 256;

interface AcpToolCallTrackedState {
readonly state: AcpToolCallState;
readonly lastEmittedDetailLength: number | undefined;
Expand Down Expand Up @@ -334,6 +336,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>());
// Recently shown tool calls. 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 +530,7 @@ export const make = (
modeStateRef,
configOptionsRef,
toolCallsRef,
shownToolCallIds,
assistantSegmentRef,
assistantItemRuntimeId,
params: notification,
Expand Down Expand Up @@ -1178,6 +1184,7 @@ const handleSessionUpdate = ({
modeStateRef,
configOptionsRef,
toolCallsRef,
shownToolCallIds,
assistantSegmentRef,
assistantItemRuntimeId,
params,
Expand All @@ -1186,6 +1193,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,11 +1210,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, active } = 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 +1232,22 @@ const handleSessionUpdate = ({
skippedSinceEmit: decision.skippedSinceEmit,
});
}
return [{ merged: nextToolCall, decision }, next] as const;
return [{ merged: nextToolCall, decision, active: tracked !== undefined }, next] as const;
});
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);
// Only recent calls get late updates; keep a long session bounded.
if (shownToolCallIds.size > MAX_SHOWN_TOOL_CALL_IDS) {
shownToolCallIds.delete(shownToolCallIds.values().next().value!);
}
// A call still running is already on screen, even if it aged out.
if (!active) yield* closeActiveAssistantSegment({ queue, assistantSegmentRef });
}
yield* Queue.offer(queue, {
_tag: "ToolCallUpdated",
toolCall: merged,
Expand Down
Loading