From 882d5f0b2b0dc01f6a9c0333113d123a712ff73f Mon Sep 17 00:00:00 2001 From: ScottN-PV Date: Sun, 25 Oct 2026 11:20:37 -0400 Subject: [PATCH 1/3] fix(acp): reconcile live prompt echoes with the submitted message --- .../Adapters/AcpRegistryAdapterV2.test.ts | 88 +++++++++++++++++++ packages/provider-acp/src/server/adapter.ts | 26 ++++++ 2 files changed, 114 insertions(+) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index e086f8e2d1a7c..c2499c701cf6c 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -41,6 +41,8 @@ import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRe import * as AcpRegistryRuntimeCoordinator from "@t3tools/provider-acp-registry/server/AcpRegistryRuntimeCoordinator"; import { ACP_SESSION_MODE_OPTION_ID } from "@t3tools/provider-acp/server/sessionConfig"; import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; +import { t3AcpPromptWithInstructions } from "@t3tools/provider-core/server/orchestrationInstructions"; +import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { decodeAcpReplayTranscript, @@ -1043,6 +1045,92 @@ describe("AcpRegistryAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); + it.effect.each(["before", "after"] as const)( + "reconciles a full prompt echo %s acknowledgment", + (ordering) => + Effect.gen(function* () { + const runtimeInstructions = { + type: "text", + text: buildRuntimeInstructions({ harness: "acpRegistry", model: "default" }), + }; + const promptBlocks = (ordinal: number) => [ + { + type: "text", + text: + ordinal === 1 + ? t3AcpPromptWithInstructions({ + prompt: "test prompt", + state: { interactionMode: "default", hasT3Mcp: false, hasDeviceTools: false }, + }) + : "test prompt", + }, + runtimeInstructions, + ]; + const echo = (messageId: string, content: ReadonlyArray) => + claudeFrames.update(CLAUDE_ROOT_SESSION, { + sessionUpdate: "user_message", + messageId, + content, + }); + const promptText = (ordinal: number) => + promptBlocks(ordinal) + .map((block) => block.text) + .join("\n"); + const frames = (ordinal: number) => { + const [request, , acknowledgment] = claudeFrames.prompt(ordinal); + const matching = echo(`echo-${ordinal}`, promptBlocks(ordinal)); + return [ + request, + echo("different-blocks", [ + { + type: "text", + text: promptText(ordinal), + }, + ]), + ...(ordering === "before" ? [matching, acknowledgment] : [acknowledgment, matching]), + claudeFrames.update(CLAUDE_ROOT_SESSION, { + sessionUpdate: "user_message_chunk", + messageId: `echo-${ordinal}`, + content: { type: "text", text: "updated echo" }, + }), + echo(`echo-${ordinal}`, [{ type: "text", text: "updated echo" }]), + echo("second-id", promptBlocks(ordinal)), + echo("unmatched", [{ type: "text", text: "Continue the review." }]), + claudeFrames.idle("end_turn"), + ]; + }; + const session = yield* openClaudeReplaySession( + [ + ...claudeFrames.open, + ...frames(1), + ...frames(2), + claudeFrames.closeRequest, + claudeFrames.closed, + ], + undefined, + false, + ); + for (const ordinal of [1, 2]) { + yield* session.runtime.startTurn(session.turnInput(ordinal)); + const events = yield* session.until((event) => event.type === "turn.terminal"); + const messages = events.flatMap((event) => + event.type === "message.updated" && event.message.role === "user" + ? [event.message] + : [], + ); + const items = events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "user_message" + ? [event.turnItem] + : [], + ); + const expected = new Set([promptText(ordinal), "Continue the review."]); + assert.deepEqual(new Set(messages.map((message) => message.text)), expected); + assert.equal(new Set(messages.map((message) => message.id)).size, 3); + assert.equal(new Set(items.map((item) => item.id)).size, 3); + } + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + it.effect("settles Claude's accepted foreground reply while retaining child output", () => Effect.gen(function* () { const gate = makeProviderReplayGate(["accepted", "child-output", "wake"]); diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index 398eac2170e70..4e25b9d8a1267 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -44,6 +44,7 @@ import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import type * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Equal from "effect/Equal"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; @@ -1044,6 +1045,8 @@ interface ActiveAcpTurn { readonly startedAt: DateTime.Utc; readonly completed: Deferred.Deferred; readonly itemOrdinals: Map; + readonly outgoingPrompt?: ReadonlyArray | undefined; + consumedPromptMessageId?: string; readonly user: ActiveTextStream; readonly assistant: ActiveTextStream; readonly reasoning: ActiveTextStream; @@ -4890,6 +4893,25 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( ) { yield* emitProviderRetry(context, "completed"); } + if ( + context.outgoingPrompt !== undefined && + (update.sessionUpdate === "user_message" || + update.sessionUpdate === "user_message_chunk") + ) { + // ACP v2 prompt acknowledgments lack a messageId; correlate one full echo by content. + if ( + context.consumedPromptMessageId === undefined && + update.sessionUpdate === "user_message" && + Equal.equals(update.content, context.outgoingPrompt) + ) { + context.consumedPromptMessageId = update.messageId; + } + if ( + context.consumedPromptMessageId !== undefined && + context.consumedPromptMessageId === update.messageId + ) + return; + } switch (update.sessionUpdate) { case "state_update": { const toolCallId = `${context.nativeTurnId}:requires-action`; @@ -7433,6 +7455,10 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( startedAt, completed, itemOrdinals: new Map(), + outgoingPrompt: + (yield* Ref.get(activeSessionSetup))?.initializeResult.protocolVersion === 2 + ? promptParts?.prompt + : undefined, user: { current: null, nextSegment: 0 }, assistant: { current: null, nextSegment: 0 }, reasoning: { current: null, nextSegment: 0 }, From 8821467e8f50d00982685d2d14522578b93fe0f3 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 30 Oct 2026 04:20:57 +0000 Subject: [PATCH 2/3] test(acp): verify non-echo message identities stay visible --- .../orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index c2499c701cf6c..bea2378ff4388 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -1127,6 +1127,12 @@ describe("AcpRegistryAdapterV2", () => { assert.deepEqual(new Set(messages.map((message) => message.text)), expected); assert.equal(new Set(messages.map((message) => message.id)).size, 3); assert.equal(new Set(items.map((item) => item.id)).size, 3); + for (const id of ["different-blocks", "second-id", "unmatched"]) { + assert.isTrue( + items.some((item) => item.nativeItemRef?.nativeId.endsWith(`:user:message:${id}`)), + `${id} remains visible`, + ); + } } }).pipe(Effect.provide(layerTest), Effect.scoped), ); From b2c7d280ce3c5cac5d8957acb7504e4517a5c195 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 30 Oct 2026 04:28:31 +0000 Subject: [PATCH 3/3] test(acp): handle nullable native item references --- .../src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index bea2378ff4388..9c7ea03fe7510 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -1129,7 +1129,7 @@ describe("AcpRegistryAdapterV2", () => { assert.equal(new Set(items.map((item) => item.id)).size, 3); for (const id of ["different-blocks", "second-id", "unmatched"]) { assert.isTrue( - items.some((item) => item.nativeItemRef?.nativeId.endsWith(`:user:message:${id}`)), + items.some((item) => item.nativeItemRef?.nativeId?.endsWith(`:user:message:${id}`)), `${id} remains visible`, ); }