Skip to content

Commit 3f75a0e

Browse files
fix(acp): stop showing ACP v2 prompt echoes as a second user message (#24908)
Co-authored-by: maria-rcks <[email protected]>
1 parent 8ae3dfc commit 3f75a0e

2 files changed

Lines changed: 120 additions & 0 deletions

File tree

‎apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRe
4141
import * as AcpRegistryRuntimeCoordinator from "@t3tools/provider-acp-registry/server/AcpRegistryRuntimeCoordinator";
4242
import { ACP_SESSION_MODE_OPTION_ID } from "@t3tools/provider-acp/server/sessionConfig";
4343
import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
44+
import { t3AcpPromptWithInstructions } from "@t3tools/provider-core/server/orchestrationInstructions";
45+
import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions";
4446
import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
4547
import {
4648
decodeAcpReplayTranscript,
@@ -1043,6 +1045,98 @@ describe("AcpRegistryAdapterV2", () => {
10431045
}).pipe(Effect.provide(layerTest), Effect.scoped),
10441046
);
10451047

1048+
it.effect.each(["before", "after"] as const)(
1049+
"reconciles a full prompt echo %s acknowledgment",
1050+
(ordering) =>
1051+
Effect.gen(function* () {
1052+
const runtimeInstructions = {
1053+
type: "text",
1054+
text: buildRuntimeInstructions({ harness: "acpRegistry", model: "default" }),
1055+
};
1056+
const promptBlocks = (ordinal: number) => [
1057+
{
1058+
type: "text",
1059+
text:
1060+
ordinal === 1
1061+
? t3AcpPromptWithInstructions({
1062+
prompt: "test prompt",
1063+
state: { interactionMode: "default", hasT3Mcp: false, hasDeviceTools: false },
1064+
})
1065+
: "test prompt",
1066+
},
1067+
runtimeInstructions,
1068+
];
1069+
const echo = (messageId: string, content: ReadonlyArray<unknown>) =>
1070+
claudeFrames.update(CLAUDE_ROOT_SESSION, {
1071+
sessionUpdate: "user_message",
1072+
messageId,
1073+
content,
1074+
});
1075+
const promptText = (ordinal: number) =>
1076+
promptBlocks(ordinal)
1077+
.map((block) => block.text)
1078+
.join("\n");
1079+
const frames = (ordinal: number) => {
1080+
const [request, , acknowledgment] = claudeFrames.prompt(ordinal);
1081+
const matching = echo(`echo-${ordinal}`, promptBlocks(ordinal));
1082+
return [
1083+
request,
1084+
echo("different-blocks", [
1085+
{
1086+
type: "text",
1087+
text: promptText(ordinal),
1088+
},
1089+
]),
1090+
...(ordering === "before" ? [matching, acknowledgment] : [acknowledgment, matching]),
1091+
claudeFrames.update(CLAUDE_ROOT_SESSION, {
1092+
sessionUpdate: "user_message_chunk",
1093+
messageId: `echo-${ordinal}`,
1094+
content: { type: "text", text: "updated echo" },
1095+
}),
1096+
echo(`echo-${ordinal}`, [{ type: "text", text: "updated echo" }]),
1097+
echo("second-id", promptBlocks(ordinal)),
1098+
echo("unmatched", [{ type: "text", text: "Continue the review." }]),
1099+
claudeFrames.idle("end_turn"),
1100+
];
1101+
};
1102+
const session = yield* openClaudeReplaySession(
1103+
[
1104+
...claudeFrames.open,
1105+
...frames(1),
1106+
...frames(2),
1107+
claudeFrames.closeRequest,
1108+
claudeFrames.closed,
1109+
],
1110+
undefined,
1111+
false,
1112+
);
1113+
for (const ordinal of [1, 2]) {
1114+
yield* session.runtime.startTurn(session.turnInput(ordinal));
1115+
const events = yield* session.until((event) => event.type === "turn.terminal");
1116+
const messages = events.flatMap((event) =>
1117+
event.type === "message.updated" && event.message.role === "user"
1118+
? [event.message]
1119+
: [],
1120+
);
1121+
const items = events.flatMap((event) =>
1122+
event.type === "turn_item.updated" && event.turnItem.type === "user_message"
1123+
? [event.turnItem]
1124+
: [],
1125+
);
1126+
const expected = new Set([promptText(ordinal), "Continue the review."]);
1127+
assert.deepEqual(new Set(messages.map((message) => message.text)), expected);
1128+
assert.equal(new Set(messages.map((message) => message.id)).size, 3);
1129+
assert.equal(new Set(items.map((item) => item.id)).size, 3);
1130+
for (const id of ["different-blocks", "second-id", "unmatched"]) {
1131+
assert.isTrue(
1132+
items.some((item) => item.nativeItemRef?.nativeId?.endsWith(`:user:message:${id}`)),
1133+
`${id} remains visible`,
1134+
);
1135+
}
1136+
}
1137+
}).pipe(Effect.provide(layerTest), Effect.scoped),
1138+
);
1139+
10461140
it.effect("settles Claude's accepted foreground reply while retaining child output", () =>
10471141
Effect.gen(function* () {
10481142
const gate = makeProviderReplayGate(["accepted", "child-output", "wake"]);

‎packages/provider-acp/src/server/adapter.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import * as DateTime from "effect/DateTime";
4545
import * as Deferred from "effect/Deferred";
4646
import type * as Duration from "effect/Duration";
4747
import * as Effect from "effect/Effect";
48+
import * as Equal from "effect/Equal";
4849
import * as Exit from "effect/Exit";
4950
import * as FileSystem from "effect/FileSystem";
5051
import * as Fiber from "effect/Fiber";
@@ -1089,6 +1090,8 @@ interface ActiveAcpTurn {
10891090
readonly startedAt: DateTime.Utc;
10901091
readonly completed: Deferred.Deferred<void, never>;
10911092
readonly itemOrdinals: Map<string, number>;
1093+
readonly outgoingPrompt?: ReadonlyArray<EffectAcpSchema.ContentBlock> | undefined;
1094+
consumedPromptMessageId?: string;
10921095
readonly user: ActiveTextStream;
10931096
readonly assistant: ActiveTextStream;
10941097
readonly reasoning: ActiveTextStream;
@@ -4938,6 +4941,25 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (
49384941
) {
49394942
yield* emitProviderRetry(context, "completed");
49404943
}
4944+
if (
4945+
context.outgoingPrompt !== undefined &&
4946+
(update.sessionUpdate === "user_message" ||
4947+
update.sessionUpdate === "user_message_chunk")
4948+
) {
4949+
// ACP v2 prompt acknowledgments lack a messageId; correlate one full echo by content.
4950+
if (
4951+
context.consumedPromptMessageId === undefined &&
4952+
update.sessionUpdate === "user_message" &&
4953+
Equal.equals(update.content, context.outgoingPrompt)
4954+
) {
4955+
context.consumedPromptMessageId = update.messageId;
4956+
}
4957+
if (
4958+
context.consumedPromptMessageId !== undefined &&
4959+
context.consumedPromptMessageId === update.messageId
4960+
)
4961+
return;
4962+
}
49414963
switch (update.sessionUpdate) {
49424964
case "state_update": {
49434965
const toolCallId = `${context.nativeTurnId}:requires-action`;
@@ -7492,6 +7514,10 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (
74927514
startedAt,
74937515
completed,
74947516
itemOrdinals: new Map(),
7517+
outgoingPrompt:
7518+
(yield* Ref.get(activeSessionSetup))?.initializeResult.protocolVersion === 2
7519+
? promptParts?.prompt
7520+
: undefined,
74957521
user: { current: null, nextSegment: 0 },
74967522
assistant: { current: null, nextSegment: 0 },
74977523
reasoning: { current: null, nextSegment: 0 },

0 commit comments

Comments
 (0)