From 6287a8ec06c0573bbe7fe1439215492115143d89 Mon Sep 17 00:00:00 2001 From: Brad Hallett <53977268+bradhallett@users.noreply.github.com> Date: Sat, 22 Aug 2026 06:23:34 -0400 Subject: [PATCH 1/2] fix(provider-acp): declare manual compaction support for omp The provider-acp bridge already serves manual compaction by sending the agent's own /compact command as a session/prompt maintenance turn, but the acp-omp declaration kept the tier default supportsManualCompaction: false, so compactThreadContext answered 409 before the bridge was ever consulted. Override the capability per agent, matching acp-opencode. The public compaction suite gains the acp-omp dispatch case (200 with the standalone builtin /compact turn submitted for the acp provider), and the first-party capability table tracks the flip. --- .../public/public-thread-compaction.test.ts | 38 +++++++++++++++++++ .../first-party-provider-plugins.test.ts | 2 +- plugins/provider-acp/src/known-agents.ts | 5 +++ 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/apps/server/test/public/public-thread-compaction.test.ts b/apps/server/test/public/public-thread-compaction.test.ts index d2f2398c05..8b5f5b7e8c 100644 --- a/apps/server/test/public/public-thread-compaction.test.ts +++ b/apps/server/test/public/public-thread-compaction.test.ts @@ -141,6 +141,44 @@ describe("public thread compaction", () => { }); }); + it("routes ACP agent compaction onto the bridge's /compact turn", async () => { + await withTestHarness(async (harness) => { + const { host, session, thread } = seedCompactableThread(harness, { + providerId: "acp-omp", + providerThreadId: "provider-thread-acp", + }); + const responder = registerSuccessfulTurnResponder(harness, { + hostId: host.id, + sessionId: session.id, + }); + + const response = await harness.app.request( + `/api/v1/threads/${thread.id}/compact`, + { method: "POST" }, + ); + expect( + response.status, + JSON.stringify(await readJson(response.clone())), + ).toBe(200); + const turnSubmitRequests = responder.requests.filter( + ({ command }) => command.type === "turn.submit", + ); + expect(turnSubmitRequests).toHaveLength(1); + // The standalone builtin /compact mention rides the ordinary turn path + // to the provider-acp bridge, which runs it as the agent's own /compact + // maintenance prompt instead of model input. + expect(turnSubmitRequests[0]?.command).toMatchObject({ + type: "turn.submit", + threadId: thread.id, + input: createStandaloneBuiltinCompactCommandInput(), + resumeContext: { + providerId: "acp-omp", + providerThreadId: "provider-thread-acp", + }, + }); + }); + }); + it("queues sends and defers send-now while manual compaction is active", async () => { await withTestHarness(async (harness) => { const { host, session, thread } = seedCompactableThread(harness, { diff --git a/apps/server/test/services/plugins/first-party-provider-plugins.test.ts b/apps/server/test/services/plugins/first-party-provider-plugins.test.ts index a7e9e2f58c..f3c527894b 100644 --- a/apps/server/test/services/plugins/first-party-provider-plugins.test.ts +++ b/apps/server/test/services/plugins/first-party-provider-plugins.test.ts @@ -92,7 +92,7 @@ const FIRST_PARTY_PROVIDER_DECLARATIONS = [ supportsThreadArchive: false, supportsThreadRename: false, fork: "tip", - supportsManualCompaction: false, + supportsManualCompaction: true, supportsUsage: false, visibility: "installed", hasLogo: true, diff --git a/plugins/provider-acp/src/known-agents.ts b/plugins/provider-acp/src/known-agents.ts index bb7b5de35b..3c1e1ea414 100644 --- a/plugins/provider-acp/src/known-agents.ts +++ b/plugins/provider-acp/src/known-agents.ts @@ -157,6 +157,11 @@ export const KNOWN_ACP_AGENTS: readonly AcpAgentDefinition[] = [ signInCommand: "omp login", installUrl: "https://github.com/can1357/omp", visibility: "installed", + // omp runs its builtin `/compact` from ACP prompt text + // (executeAcpBuiltinSlashCommand) and advertises it in + // available_commands_update, so the bridge's /compact maintenance turn + // works end to end (#2290). + supportsManualCompaction: true, // Unverified; the ACP tier's value (see acp-opencode). fork: "tip", launch: { From f8620a8eacb622dff6cb9ee7e12c439f4fc342ff Mon Sep 17 00:00:00 2001 From: Brad Hallett <53977268+bradhallett@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:46:49 -0400 Subject: [PATCH 2/2] fix(provider-acp): report omp's failed /compact honestly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit omp resolves every consumed builtin slash command with end_turn, including a /compact that printed "Compaction failed: ..." as an ordinary agent message, so an end_turn compaction prompt is only a shrunk context when the agent did not spend the turn reporting a failure (#2290 review). While the compaction maintenance prompt is in flight the bridge now keeps the streamed agent message text. On end_turn it classifies the completed text: the two known no-op messages ("Nothing to compact (session too small)", "Already compacted" — the same strings pi prints, #1721) settle the compaction as skipped, which the translator reports as a compaction-skipped warning plus a clean turn boundary with no thread/compacted; any other "Compaction failed:" text fails the turn with the agent's reason. Detection only runs for the compaction prompt, never for ordinary agent traffic. --- .../public/public-thread-compaction.test.ts | 19 ++--- .../src/bridge-protocol.ts | 30 ++++++- .../src/bridge/bridge.test.ts | 60 ++++++++++++- .../provider-bridge-acp/src/bridge/bridge.ts | 85 +++++++++++++++++-- .../src/bridge/fake-acp-agent.mjs | 18 +++- .../src/delta-translation.test.ts | 48 ++++++++++- .../src/delta-translation.ts | 48 +++++++++-- 7 files changed, 271 insertions(+), 37 deletions(-) diff --git a/apps/server/test/public/public-thread-compaction.test.ts b/apps/server/test/public/public-thread-compaction.test.ts index 8b5f5b7e8c..07b16ba5d1 100644 --- a/apps/server/test/public/public-thread-compaction.test.ts +++ b/apps/server/test/public/public-thread-compaction.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it } from "vitest"; -import { - getLatestThreadSequence, - listQueuedThreadMessages, -} from "@bb/db"; +import { getLatestThreadSequence, listQueuedThreadMessages } from "@bb/db"; import { createStandaloneBuiltinCompactCommandInput, turnScope, @@ -280,12 +277,14 @@ describe("public thread compaction", () => { }), ).toBe(true); expect(listQueuedThreadMessages(harness.db, thread.id)).toHaveLength(0); - await expect.poll( - () => - responder.requests.filter( - ({ command }) => command.type === "turn.submit", - ).length, - ).toBe(2); + await expect + .poll( + () => + responder.requests.filter( + ({ command }) => command.type === "turn.submit", + ).length, + ) + .toBe(2); }); }); diff --git a/packages/provider-bridge-acp/src/bridge-protocol.ts b/packages/provider-bridge-acp/src/bridge-protocol.ts index 0a51a192b6..0eb4b8d7ab 100644 --- a/packages/provider-bridge-acp/src/bridge-protocol.ts +++ b/packages/provider-bridge-acp/src/bridge-protocol.ts @@ -6,8 +6,26 @@ * why they are schemas rather than ad-hoc objects. */ -import { acpNativeReasoningSchema as acpBridgeNativeReasoningSchema, acpPermissionCliSchema as acpBridgePermissionCliSchema, acpReasoningCliSchema as acpBridgeReasoningCliSchema } from "@bb/domain"; -import { initializeParamsSchema, providerInstallationRunParamsSchema, providerInstallationStatusParamsSchema, providerMaintenanceParamsSchema, modelListParamsSchema as canonicalModelListParamsSchema, skillsConfigureParamsSchema, threadDiscardParamsSchema as canonicalThreadDiscardParamsSchema, threadForkParamsSchema as canonicalThreadForkParamsSchema, threadResumeParamsSchema as canonicalThreadResumeParamsSchema, threadStartParamsSchema as canonicalThreadStartParamsSchema, threadStopParamsSchema as canonicalThreadStopParamsSchema, turnStartParamsSchema as canonicalTurnStartParamsSchema, turnSteerParamsSchema as canonicalTurnSteerParamsSchema } from "@bb/provider-bridge-protocol"; +import { + acpNativeReasoningSchema as acpBridgeNativeReasoningSchema, + acpPermissionCliSchema as acpBridgePermissionCliSchema, + acpReasoningCliSchema as acpBridgeReasoningCliSchema, +} from "@bb/domain"; +import { + initializeParamsSchema, + providerInstallationRunParamsSchema, + providerInstallationStatusParamsSchema, + providerMaintenanceParamsSchema, + modelListParamsSchema as canonicalModelListParamsSchema, + skillsConfigureParamsSchema, + threadDiscardParamsSchema as canonicalThreadDiscardParamsSchema, + threadForkParamsSchema as canonicalThreadForkParamsSchema, + threadResumeParamsSchema as canonicalThreadResumeParamsSchema, + threadStartParamsSchema as canonicalThreadStartParamsSchema, + threadStopParamsSchema as canonicalThreadStopParamsSchema, + turnStartParamsSchema as canonicalTurnStartParamsSchema, + turnSteerParamsSchema as canonicalTurnSteerParamsSchema, +} from "@bb/provider-bridge-protocol"; import { z } from "zod"; import { acpSessionUpdateSchema, acpStopReasonSchema } from "./wire.js"; @@ -159,6 +177,14 @@ export const acpCompactionCompletedNotificationParamsSchema = status: z.literal("interrupted"), }) .passthrough(), + z + .object({ + threadId: z.string().min(1), + status: z.literal("skipped"), + /** The agent's own reason the compaction was a no-op. */ + detail: z.string().min(1), + }) + .passthrough(), z .object({ threadId: z.string().min(1), diff --git a/packages/provider-bridge-acp/src/bridge/bridge.test.ts b/packages/provider-bridge-acp/src/bridge/bridge.test.ts index 5a999a3d3e..f7e297fd4d 100644 --- a/packages/provider-bridge-acp/src/bridge/bridge.test.ts +++ b/packages/provider-bridge-acp/src/bridge/bridge.test.ts @@ -13,7 +13,10 @@ import { fileURLToPath } from "node:url"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createStandaloneBuiltinCompactCommandInput } from "@bb/domain"; import type { DynamicTool, ReasoningLevel } from "@bb/domain"; -import { PROVIDER_BRIDGE_PROTOCOL_VERSION, THREAD_DELTA_NOTIFICATION_METHOD } from "@bb/provider-bridge-protocol"; +import { + PROVIDER_BRIDGE_PROTOCOL_VERSION, + THREAD_DELTA_NOTIFICATION_METHOD, +} from "@bb/provider-bridge-protocol"; import { assembleCapturedThreadEvents, captureBridgeJsonRpcOutput, @@ -355,9 +358,7 @@ function deltaKindsOf(message: BridgeJsonRpcOutputMessage): string[] { if (message.method !== THREAD_DELTA_NOTIFICATION_METHOD) { return []; } - const params = message.params as - | { deltas?: { kind?: string }[] } - | undefined; + const params = message.params as { deltas?: { kind?: string }[] } | undefined; return (params?.deltas ?? []).map((delta) => delta.kind ?? ""); } @@ -2085,6 +2086,57 @@ describe("acp bridge", () => { expect(threadEventsOfType("thread/compacted")).toEqual([]); }); + it("fails the compaction turn when the agent reports the failure in an end-turn message", async () => { + const { providerThreadId } = await startThread({ + envVars: { + FAKE_ACP_COMPACT_AGENT_MESSAGE: + "Compaction failed: summary model rejected the request", + }, + }); + + const turnId = sendTurnRequest("turn/start", providerThreadId, { + input: compactCommandInput(), + }); + expect((await waitForResponse(turnId)).error).toBeUndefined(); + + // omp answers end_turn even when its /compact handler failed and said so + // in an ordinary agent message; that text must fail the turn instead of + // the end_turn being read as a shrunk context (#2290). + const completed = await waitForTurnCompleted(); + expect(completed).toMatchObject({ + status: "failed", + error: { + message: "Compaction failed: summary model rejected the request", + }, + }); + expect(threadEventsOfType("thread/compacted")).toEqual([]); + }); + + it("completes a no-op compaction turn without reporting a compacted context", async () => { + const { providerThreadId } = await startThread({ + envVars: { + FAKE_ACP_COMPACT_AGENT_MESSAGE: + "Compaction failed: Nothing to compact (session too small)", + }, + }); + + const turnId = sendTurnRequest("turn/start", providerThreadId, { + input: compactCommandInput(), + }); + expect((await waitForResponse(turnId)).error).toBeUndefined(); + + // A small session has nothing to compact: the turn ends cleanly with the + // agent's reason surfaced as a warning, and no `thread/compacted`. + const completed = await waitForTurnCompleted(); + expect(completed).toMatchObject({ status: "completed" }); + expect(threadEventsOfType("thread/compacted")).toEqual([]); + expect(threadEventsOfType("provider/warning").at(-1)).toMatchObject({ + category: "compaction-skipped", + summary: "Context compaction skipped", + details: "Compaction failed: Nothing to compact (session too small)", + }); + }); + it("accepts turn input only after the prompt carrying it goes out", async () => { const { providerThreadId } = await startThread(); const turnId = sendTurnRequest("turn/start", providerThreadId, { diff --git a/packages/provider-bridge-acp/src/bridge/bridge.ts b/packages/provider-bridge-acp/src/bridge/bridge.ts index d8a4ee9cca..51d4df1ad8 100644 --- a/packages/provider-bridge-acp/src/bridge/bridge.ts +++ b/packages/provider-bridge-acp/src/bridge/bridge.ts @@ -9,12 +9,37 @@ * workspace write policy on client `fs/write_text_file` requests. */ -import { isStandaloneBuiltinCompactCommand, pendingInteractionResolutionSchema, reasoningEffortsForLevels } from "@bb/domain"; +import { + isStandaloneBuiltinCompactCommand, + pendingInteractionResolutionSchema, + reasoningEffortsForLevels, +} from "@bb/domain"; import type { AvailableModel, PromptInput, ReasoningLevel } from "@bb/domain"; import { acpLaunchSpecSchema, type AcpLaunchSpec } from "../launch-spec.js"; -import { BRIDGE_INBOUND_REQUEST_METHODS, BRIDGE_JSON_RPC_ERRORS, BRIDGE_NOTIFICATION_METHODS, PROVIDER_BRIDGE_PROTOCOL_VERSION, THREAD_DELTA_GRAMMAR_V3, THREAD_DELTA_NOTIFICATION_METHOD } from "@bb/provider-bridge-protocol"; -import type { InitializeResult, ThreadDelta } from "@bb/provider-bridge-protocol"; -import { BridgeRecoveryError, bridgeRequestEnvelopeSchema, createBridgeIo, createBridgeLineHandler, decodeBridgeJsonRpcResponse, decodeToolCallResponsePayload, experimental_defineProviderBridge, mimeTypeFromExtension, runBridgeRequest, withoutBridgeRuntimeEnv } from "@bb/provider-bridge-protocol/bridge-kit"; +import { + BRIDGE_INBOUND_REQUEST_METHODS, + BRIDGE_JSON_RPC_ERRORS, + BRIDGE_NOTIFICATION_METHODS, + PROVIDER_BRIDGE_PROTOCOL_VERSION, + THREAD_DELTA_GRAMMAR_V3, + THREAD_DELTA_NOTIFICATION_METHOD, +} from "@bb/provider-bridge-protocol"; +import type { + InitializeResult, + ThreadDelta, +} from "@bb/provider-bridge-protocol"; +import { + BridgeRecoveryError, + bridgeRequestEnvelopeSchema, + createBridgeIo, + createBridgeLineHandler, + decodeBridgeJsonRpcResponse, + decodeToolCallResponsePayload, + experimental_defineProviderBridge, + mimeTypeFromExtension, + runBridgeRequest, + withoutBridgeRuntimeEnv, +} from "@bb/provider-bridge-protocol/bridge-kit"; import type { BridgeJsonRpcResponse } from "@bb/provider-bridge-protocol/bridge-kit"; import { execFile } from "node:child_process"; import { randomBytes } from "node:crypto"; @@ -79,6 +104,8 @@ import { acpSessionForkResultSchema, acpSessionNewResultSchema, acpSessionNotificationParamsSchema, + acpAgentMessageChunkUpdateSchema, + extractAcpContentText, acpUsageUpdateSchema, type AcpConfigStateResult, type AcpSessionModels, @@ -171,6 +198,13 @@ interface AcpThreadSession { * the provider-local `"compaction"` maintenance prompt, or none. */ activePromptKind: "turn" | "compaction" | null; + /** + * Agent message text streamed during the compaction maintenance prompt. + * Some agents (omp) report a failed `/compact` as an ordinary agent + * message and still answer `end_turn`, so the prompt result alone cannot + * tell a shrunk context from a no-op. + */ + compactionAgentMessage: string; queuedInputs: AcpPendingTurnInput[]; /** True while a session/prompt request is outstanding. */ promptRequestPending: boolean; @@ -1828,6 +1862,7 @@ async function startAgentSession( }, pendingInstructions: params.instructions, activePromptKind: null, + compactionAgentMessage: "", queuedInputs: [], promptRequestPending: false, cancelRequested: false, @@ -2012,7 +2047,10 @@ async function startAgentSession( sendThreadDeltas(bbThreadId, [{ kind: "session.reset" }]); session.deferStartEmit = undefined; for (const deferred of deferredEmits) { - if (deferred.sessionId !== undefined && deferred.sessionId !== sessionId) { + if ( + deferred.sessionId !== undefined && + deferred.sessionId !== sessionId + ) { continue; } emitForSession(session, deferred.method, deferred.params); @@ -2288,11 +2326,37 @@ function runTurn( * every other stop reason or prompt rejection fails the turn with the agent's * own reason rather than being reported as a shrunk context. */ +/** + * omp reports a failed `/compact` as an ordinary agent message and still + * answers `end_turn`, so an `end_turn` compaction prompt is only a shrunk + * context when the agent did not spend the turn reporting a failure. The two + * no-op messages are the same strings pi prints (#1721): the compaction + * completed cleanly but had nothing to do, while any other "Compaction + * failed:" text is a real failure the thread must surface. + */ +const ACP_COMPACTION_NOOP_MESSAGES: Record = { + "Compaction failed: Nothing to compact (session too small)": true, + "Compaction failed: Already compacted": true, +}; + +function compactionOutcomeForEndTurn( + agentMessage: string, +): Record { + const text = agentMessage.trim(); + if (!text.startsWith("Compaction failed:")) { + return { status: "completed" }; + } + return ACP_COMPACTION_NOOP_MESSAGES[text] === true + ? { status: "skipped", detail: text } + : { status: "failed", error: text }; +} + function startCompaction( session: AcpThreadSession, pending: AcpPendingTurnInput, ): void { session.activePromptKind = "compaction"; + session.compactionAgentMessage = ""; emitForSession(session, ACP_COMPACTION_STARTED_METHOD, { threadId: session.bbThreadId, }); @@ -2315,7 +2379,7 @@ function startCompaction( .then((result) => { finish( result.stopReason === "end_turn" - ? { status: "completed" } + ? compactionOutcomeForEndTurn(session.compactionAgentMessage) : result.stopReason === "cancelled" ? { status: "interrupted" } : { @@ -2441,6 +2505,15 @@ function handleAgentNotification( if (parsed.data.sessionId !== session.providerThreadId) { return; } + if (session.activePromptKind === "compaction") { + const chunk = acpAgentMessageChunkUpdateSchema.safeParse( + parsed.data.update, + ); + if (chunk.success) { + session.compactionAgentMessage += + extractAcpContentText(chunk.data.content) ?? ""; + } + } emitForSession(session, ACP_UPDATE_METHOD, update); } diff --git a/packages/provider-bridge-acp/src/bridge/fake-acp-agent.mjs b/packages/provider-bridge-acp/src/bridge/fake-acp-agent.mjs index b348536846..08ee7cd66f 100755 --- a/packages/provider-bridge-acp/src/bridge/fake-acp-agent.mjs +++ b/packages/provider-bridge-acp/src/bridge/fake-acp-agent.mjs @@ -85,7 +85,9 @@ const authMethods = (process.env.FAKE_ACP_AUTH_METHODS ?? "") const authOptional = process.env.FAKE_ACP_AUTH_OPTIONAL === "1"; const sessionNewError = process.env.FAKE_ACP_SESSION_NEW_ERROR; const exitOnSessionNew = process.env.FAKE_ACP_EXIT_ON_SESSION_NEW; -const sessionNewDelayMs = Number(process.env.FAKE_ACP_SESSION_NEW_DELAY_MS ?? "0"); +const sessionNewDelayMs = Number( + process.env.FAKE_ACP_SESSION_NEW_DELAY_MS ?? "0", +); const updatesWithSessionResponse = process.env.FAKE_ACP_UPDATES_WITH_SESSION_RESPONSE === "1"; const ignoreCancel = process.env.FAKE_ACP_IGNORE_CANCEL === "1"; @@ -233,7 +235,11 @@ function configState() { } function requireAuthenticated(message) { - if (authMethods.length === 0 || authOptional || authenticatedMethod !== null) { + if ( + authMethods.length === 0 || + authOptional || + authenticatedMethod !== null + ) { return true; } // ACP's reserved auth-required error: code -32000 with this message. @@ -328,7 +334,13 @@ async function handlePrompt(message) { } if (text === "/compact") { - // OpenCode treats this exact prompt as a provider-local control. + // OpenCode treats this exact prompt as a provider-local control. omp + // instead runs the command and reports a failure as an ordinary agent + // message while still answering end_turn (get-bb/bb#2290). + const compactMessage = process.env.FAKE_ACP_COMPACT_AGENT_MESSAGE; + if (compactMessage !== undefined) { + notifyUpdate(messageChunk(compactMessage)); + } } else if (text.includes("request-external-directory-permission")) { // opencode's external_directory permission: the running edit tool asks // with the generic kind "other", a bare directory title, and diff --git a/packages/provider-bridge-acp/src/delta-translation.test.ts b/packages/provider-bridge-acp/src/delta-translation.test.ts index 0d6174fe96..075734f877 100644 --- a/packages/provider-bridge-acp/src/delta-translation.test.ts +++ b/packages/provider-bridge-acp/src/delta-translation.test.ts @@ -335,6 +335,34 @@ describe("acp delta translation (moved from the legacy adapter suite)", () => { ]); }); + it("translates a skipped maintenance prompt into a warning and a clean turn end", () => { + const harness = createHarness(); + harness.translate(compactionStartedEvent()); + const turnId = harness.openTurnId(); + + expect( + harness.translate( + compactionCompletedEvent({ + status: "skipped", + detail: "Compaction failed: Nothing to compact (session too small)", + }), + ), + ).toEqual([ + expect.objectContaining({ + type: "provider/warning", + scope: turnScope(turnId), + category: "compaction-skipped", + summary: "Context compaction skipped", + details: "Compaction failed: Nothing to compact (session too small)", + }), + expect.objectContaining({ + type: "turn/completed", + scope: turnScope(turnId), + status: "completed", + }), + ]); + }); + it("completes streamed items before ending a compaction turn", () => { const harness = createHarness(); harness.translate(compactionStartedEvent()); @@ -1575,7 +1603,9 @@ describe("acp delta translation (raw payloads and real results)", () => { sessionUpdate: "tool_call_update", toolCallId: "call-stream", status: "in_progress", - content: [{ type: "content", content: { type: "text", text: "one\n" } }], + content: [ + { type: "content", content: { type: "text", text: "one\n" } }, + ], }), ); expect(first).toEqual([ @@ -1609,7 +1639,9 @@ describe("acp delta translation (raw payloads and real results)", () => { sessionUpdate: "tool_call_update", toolCallId: "call-stream", status: "in_progress", - content: [{ type: "content", content: { type: "text", text: "two\n" } }], + content: [ + { type: "content", content: { type: "text", text: "two\n" } }, + ], }), ); expect(third).toEqual([ @@ -1853,7 +1885,11 @@ describe("acp delta translation (raw payloads and real results)", () => { content: [ { type: "content", content: { type: "text", text: "README.md\n" } }, ], - rawOutput: { type: "Bash", exit_code: 0, output_for_prompt: "exit: 0\n" }, + rawOutput: { + type: "Bash", + exit_code: 0, + output_for_prompt: "exit: 0\n", + }, }), ); expect(closed).toHaveLength(1); @@ -1883,7 +1919,11 @@ describe("acp delta translation (raw payloads and real results)", () => { }), ); expect(opened[0]).toMatchObject({ - item: { type: "toolCall", tool: "fetch", presentation: { title: "Web Fetch" } }, + item: { + type: "toolCall", + tool: "fetch", + presentation: { title: "Web Fetch" }, + }, }); const openedId = opened[0]?.type === "item/started" ? opened[0].item.id : ""; diff --git a/packages/provider-bridge-acp/src/delta-translation.ts b/packages/provider-bridge-acp/src/delta-translation.ts index 64fed5f363..5c78dfb42e 100644 --- a/packages/provider-bridge-acp/src/delta-translation.ts +++ b/packages/provider-bridge-acp/src/delta-translation.ts @@ -17,10 +17,27 @@ import { providerRawEventSchema } from "@bb/domain"; import type { ProviderRawEvent } from "@bb/domain"; -import { COMPACTION_PRESENTATION, errorEnvelopeSchema, jsonRpcEnvelopeSchema, planStepsPresentation, presentationTitle } from "@bb/provider-bridge-protocol/bridge-kit"; -import type { JsonRpcMessage, ProviderRuntimeEvent } from "@bb/provider-bridge-protocol/bridge-kit"; -import type { ThreadEventItemStatus, ThreadEventPlanStep, ThreadEventTurnStatus } from "@bb/domain"; -import type { DeltaItemShape, DeltaNoTurnFallback, ThreadDelta } from "@bb/provider-bridge-protocol"; +import { + COMPACTION_PRESENTATION, + errorEnvelopeSchema, + jsonRpcEnvelopeSchema, + planStepsPresentation, + presentationTitle, +} from "@bb/provider-bridge-protocol/bridge-kit"; +import type { + JsonRpcMessage, + ProviderRuntimeEvent, +} from "@bb/provider-bridge-protocol/bridge-kit"; +import type { + ThreadEventItemStatus, + ThreadEventPlanStep, + ThreadEventTurnStatus, +} from "@bb/domain"; +import type { + DeltaItemShape, + DeltaNoTurnFallback, + ThreadDelta, +} from "@bb/provider-bridge-protocol"; import { ACP_COMPACTION_COMPLETED_METHOD, ACP_COMPACTION_STARTED_METHOD, @@ -969,16 +986,31 @@ export function createAcpDeltaTranslator( return []; } const status = params.data.status; + // A skipped compaction is a clean no-op (nothing to shrink), so its + // turn ends completed with the agent's reason surfaced as a warning — + // but like a failed or interrupted one it must never report + // `thread/compacted`, which only a genuine compaction earns. + const turnStatus: ThreadEventTurnStatus = + status === "skipped" ? "completed" : status; return [ - ...flushOpenTurnWork(context, status), - // Only a completed maintenance prompt actually shrank the context; a - // failed or interrupted one must never report `thread/compacted`. + ...flushOpenTurnWork(context, itemStatusForTurnStatus(turnStatus)), ...(status === "completed" ? ([{ kind: "context.compacted" }] as ThreadDelta[]) : []), + ...(status === "skipped" + ? ([ + { + kind: "provider.warning", + category: "compaction-skipped", + summary: "Context compaction skipped", + details: params.data.detail, + vouchedTurn: true, + }, + ] as ThreadDelta[]) + : []), { kind: "turn.boundary", - status, + status: turnStatus, ...(status === "failed" ? { error: { message: params.data.error } } : {}),