From 6311010151c3aa6b29de5c982f7a3ea9596080ad Mon Sep 17 00:00:00 2001 From: Mateo Cerquetella <33433649+MateoCerquetella@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:13:37 -0300 Subject: [PATCH 1/2] Add same-thread context clearing --- .../promptbox/PromptBoxInternal.test.tsx | 67 +++++++----- .../promptbox/PromptBoxInternal.tsx | 4 +- .../command-output/thread-actions.test.ts | 12 +++ apps/cli/src/commands/thread/actions.ts | 15 +++ apps/server/src/routes/threads/actions.ts | 8 ++ .../skills/builtin-skills/bb-cli/SKILL.md | 3 + .../threads/provider-command-typeahead.ts | 27 +++-- .../services/threads/thread-context-clear.ts | 71 ++++++++++++ .../threads/thread-context-mutation-guard.ts | 63 +++++++++++ .../services/threads/thread-send-request.ts | 5 +- .../src/services/threads/thread-send.ts | 19 ++++ .../public/public-project-commands.test.ts | 18 ++++ .../public-thread-banner-actions.test.ts | 102 +++++++++++++++++- .../provider-command-typeahead.test.ts | 26 ++++- .../thread-context-mutation-guard.test.ts | 47 ++++++++ packages/db/src/data/events.ts | 17 ++- packages/db/test/data/events.test.ts | 60 +++++++++++ packages/domain/src/shared-types.ts | 37 +++++-- packages/domain/src/thread-events.ts | 2 + .../standalone-builtin-clear-command.test.ts | 50 +++++++++ packages/sdk/src/areas/threads.ts | 9 ++ packages/sdk/test/public-types.test.ts | 1 + packages/server-contract/src/public-api.ts | 6 ++ .../src/templates/bb-guide-threads.md | 3 + .../src/parse-operation-message.ts | 7 ++ .../test/parse-operation-message.test.ts | 20 ++++ 26 files changed, 644 insertions(+), 55 deletions(-) create mode 100644 apps/server/src/services/threads/thread-context-clear.ts create mode 100644 apps/server/src/services/threads/thread-context-mutation-guard.ts create mode 100644 apps/server/test/services/threads/thread-context-mutation-guard.test.ts create mode 100644 packages/domain/test/standalone-builtin-clear-command.test.ts diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx index 2562192eb3..3ae79f68f8 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx @@ -3715,6 +3715,14 @@ describe("PromptBoxInternal command typeahead submit", () => { description: "Compact context", argumentHint: null, }; + const clearSuggestion: ProviderCommandSuggestion = { + kind: "command", + name: "clear", + source: "command", + origin: "builtin", + description: "Start fresh context in this thread", + argumentHint: null, + }; const userSkillSuggestion: ProviderCommandSuggestion = { kind: "command", name: "review", @@ -3785,36 +3793,41 @@ describe("PromptBoxInternal command typeahead submit", () => { await waitFor(() => expect(screen.queryByText(name)).not.toBeNull()); } - it("submits when a built-in command is selected with Enter", async () => { - const { changes, onSubmit, promptBoxRef } = - renderCommandPromptBox(compactSuggestion); - await openCommandMenu(promptBoxRef, "/compact", "compact"); + it.each([ + { name: "compact", suggestion: compactSuggestion }, + { name: "clear", suggestion: clearSuggestion }, + ])( + "submits built-in /$name when selected with Enter", + async ({ name, suggestion }) => { + const { changes, onSubmit, promptBoxRef } = + renderCommandPromptBox(suggestion); + await openCommandMenu(promptBoxRef, `/${name}`, name); - await act(async () => { - fireEvent.keyDown(getPromptEditorElement(), { key: "Enter" }); - }); - await act(async () => {}); + await act(async () => { + fireEvent.keyDown(getPromptEditorElement(), { key: "Enter" }); + }); + await act(async () => {}); - expect(onSubmit).toHaveBeenCalledTimes(1); - // The command mention is applied (and therefore submitted), not left as - // bare text — Codex reads the mention to trigger compaction and Claude - // sends the `/compact` text as-is. - expect(latestChange(changes)?.mentions).toEqual([ - { - start: 0, - end: "/compact".length, - resource: { - kind: "command", - trigger: "/", - name: "compact", - source: "command", - origin: "builtin", - label: "compact", - argumentHint: null, + expect(onSubmit).toHaveBeenCalledTimes(1); + // The command mention is applied (and therefore submitted), not left as + // bare text. + expect(latestChange(changes)?.mentions).toEqual([ + { + start: 0, + end: `/${name}`.length, + resource: { + kind: "command", + trigger: "/", + name, + source: "command", + origin: "builtin", + label: name, + argumentHint: null, + }, }, - }, - ]); - }); + ]); + }, + ); it("does not submit when a non-built-in command is selected with Enter", async () => { const { changes, onSubmit, promptBoxRef } = diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.tsx index 3880fd3ba2..d17f617c99 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.tsx @@ -2794,7 +2794,7 @@ export function PromptBoxInternal({ [], ); - // A no-argument built-in command (currently only `/compact`) is a complete + // A no-argument built-in command (such as `/compact` or `/clear`) is complete // action the moment it is selected, so applying it with Enter should also // submit instead of leaving the pill parked for a second Enter. The submit is // deferred to this effect — keyed on the flag — so `onSubmit` runs after the @@ -2963,7 +2963,7 @@ export function PromptBoxInternal({ activeSuggestions[selectedIndex] ?? activeSuggestions[0]; if (selected) { applyTrigger(selected); - // Built-in commands (e.g. `/compact`) take no arguments, so picking + // Built-in commands (e.g. `/compact` and `/clear`) take no arguments, so picking // one with Enter both inserts the pill and submits. Tab still only // inserts, and mention suggestions are unaffected. if ( diff --git a/apps/cli/src/__tests__/command-output/thread-actions.test.ts b/apps/cli/src/__tests__/command-output/thread-actions.test.ts index 8f5c09ea3d..e525a6cc2c 100644 --- a/apps/cli/src/__tests__/command-output/thread-actions.test.ts +++ b/apps/cli/src/__tests__/command-output/thread-actions.test.ts @@ -470,6 +470,18 @@ describe("bb thread action command output", () => { ); }); + it("bb thread clear invokes the context clear action", async () => { + const post = vi.fn(async () => ({ ok: true })); + stubServerApi({ "v1.threads.:id.context.clear.$post": post }); + + await runCommand(["thread", "clear", "thread-clear"], register); + + expect(post).toHaveBeenCalledWith({ param: { id: "thread-clear" } }); + expect(collectLogLines(vi.mocked(console.log))).toContain( + "Thread thread-clear context cleared", + ); + }); + it.each([ ["cancel-plan", "plan.cancel", "exited Plan mode"], ["clear-goal", "goal.clear", "cleared its Goal"], diff --git a/apps/cli/src/commands/thread/actions.ts b/apps/cli/src/commands/thread/actions.ts index 9c34435e02..0f757ed5ae 100644 --- a/apps/cli/src/commands/thread/actions.ts +++ b/apps/cli/src/commands/thread/actions.ts @@ -492,6 +492,21 @@ export function registerActionsCommands( }), ); + parent + .command("clear [id]") + .description("Clear model context for an idle or failed thread") + .option("--self", "Target the current thread (from BB_THREAD_ID)") + .option("--json", "Print machine-readable JSON output") + .action( + action(async (id: string | undefined, opts: ThreadActionOptions) => { + const threadId = requireThreadIdOrSelf(id, opts); + const sdk = createCliBbSdk(getUrl()); + await sdk.threads.clearContext({ threadId }); + if (outputJson(opts, { ok: true, threadId })) return; + console.log(`Thread ${threadId} context cleared`); + }), + ); + parent .command("cancel-plan [id]") .description("Ask the provider to exit the active Plan mode") diff --git a/apps/server/src/routes/threads/actions.ts b/apps/server/src/routes/threads/actions.ts index 57e1fb7d10..ca65e61b64 100644 --- a/apps/server/src/routes/threads/actions.ts +++ b/apps/server/src/routes/threads/actions.ts @@ -50,6 +50,7 @@ import { } from "../../services/threads/thread-send.js"; import { acceptThreadSendRequest } from "../../services/threads/thread-send-request.js"; import { editThreadMessage } from "../../services/threads/thread-edit-message.js"; +import { clearThreadContext } from "../../services/threads/thread-context-clear.js"; import { buildExecutionOptions, dispatchThreadUnarchiveCommand, @@ -370,6 +371,13 @@ export function registerThreadActionRoutes(app: Hono, deps: AppDeps): void { return context.json({ ok: true }); }); + post(routes.clearContext, async (context) => { + const thread = requirePublicThread(deps.db, context.req.param("id")); + const environment = await requireThreadCommandEnvironment(deps, { thread }); + await clearThreadContext(deps, { environment, thread }); + return context.json({ ok: true }); + }); + post(routes.cancelPlan, async (context) => { const thread = requirePublicThread(deps.db, context.req.param("id")); const activity = getThreadPromptBannerActivity(deps, thread); diff --git a/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md b/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md index 484f0a7b80..b28cbb7b03 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md +++ b/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md @@ -519,6 +519,9 @@ For review or fix pipelines, get the environment ID from - `bb thread stop ` also releases an idle or stuck agent runtime. The command is idempotent and preserves thread history. - Use `bb thread compact ` to send the built-in `/compact` command to an idle or errored thread. Completion or failure appears in the timeline. Codex, Claude Code, Pi, and OpenCode ACP support it; Cursor ACP does not expose compatible compaction through ACP. +- Use `bb thread clear ` on an idle or failed thread to start fresh model + context while keeping its BB timeline, workspace, and sticky execution + settings. - Use `bb thread cancel-plan ` to exit an active Plan turn without optimistically clearing its banner. Use `bb thread clear-goal ` to clear a Codex thread's durable active Goal. Both wait for provider confirmation. diff --git a/apps/server/src/services/threads/provider-command-typeahead.ts b/apps/server/src/services/threads/provider-command-typeahead.ts index ddacb92a18..f944d413d1 100644 --- a/apps/server/src/services/threads/provider-command-typeahead.ts +++ b/apps/server/src/services/threads/provider-command-typeahead.ts @@ -7,15 +7,21 @@ import type { HostProviderCommand } from "@bb/host-daemon-contract"; import type { ProviderRegistration } from "../providers/provider-registry.js"; import type { ResolvedSkillCatalogEntry } from "../skills/injected-skills.js"; -const BUILT_IN_PROVIDER_COMMANDS: ProviderCommand[] = [ - { - name: "compact", - source: "command", - origin: "builtin", - description: "Compact context", - argumentHint: null, - }, -]; +const BUILT_IN_CLEAR_COMMAND: ProviderCommand = { + name: "clear", + source: "command", + origin: "builtin", + description: "Start fresh context in this thread", + argumentHint: null, +}; + +const BUILT_IN_COMPACT_COMMAND: ProviderCommand = { + name: "compact", + source: "command", + origin: "builtin", + description: "Compact context", + argumentHint: null, +}; function providerComposerHasSkillsAction( composerActions: readonly { kind: string }[], @@ -123,7 +129,8 @@ export function buildCommandListResponse( ): CommandListResponse { return { commands: dedupeBySourceAndName([ - ...(args.includeBuiltinCompact ? BUILT_IN_PROVIDER_COMMANDS : []), + BUILT_IN_CLEAR_COMMAND, + ...(args.includeBuiltinCompact ? [BUILT_IN_COMPACT_COMMAND] : []), ...args.skillCatalog.map(toSkillCommand), ...args.commands.map(toProviderCommand), ]).sort(compareCommands), diff --git a/apps/server/src/services/threads/thread-context-clear.ts b/apps/server/src/services/threads/thread-context-clear.ts new file mode 100644 index 0000000000..121862416e --- /dev/null +++ b/apps/server/src/services/threads/thread-context-clear.ts @@ -0,0 +1,71 @@ +import { createEventId, getThread } from "@bb/db"; +import { + THREAD_CONTEXT_CLEAR_OPERATION, + threadScope, + type Environment, + type Thread, +} from "@bb/domain"; +import { ApiError } from "../../errors.js"; +import type { LoggedPendingInteractionWorkSessionDeps } from "../../types.js"; +import { withThreadContextClearGuard } from "./thread-context-mutation-guard.js"; +import { appendThreadEvent } from "./thread-events.js"; +import { stopThreadForCurrentState } from "./thread-lifecycle.js"; + +export async function clearThreadContext( + deps: LoggedPendingInteractionWorkSessionDeps, + args: { + environment: Pick; + thread: Thread; + }, +): Promise { + return withThreadContextClearGuard(args.thread.id, async () => { + const thread = getThread(deps.db, args.thread.id); + if (!thread) { + throw new ApiError(404, "invalid_request", "Thread not found"); + } + if (thread.archivedAt !== null || thread.deletedAt !== null) { + throw new ApiError(409, "invalid_request", "Thread is not writable"); + } + if (thread.status !== "idle" && thread.status !== "error") { + throw new ApiError( + 409, + "invalid_request", + "Context can only be cleared when the thread is idle or failed", + ); + } + if (deps.pendingInteractions.hasPendingThreadInteraction(thread.id)) { + throw new ApiError( + 409, + "awaiting_user_interaction", + "Resolve the pending interaction before clearing context", + ); + } + + await stopThreadForCurrentState(deps, thread, args.environment); + const releasedThread = getThread(deps.db, thread.id); + if ( + !releasedThread || + (releasedThread.status !== "idle" && releasedThread.status !== "error") + ) { + throw new ApiError( + 409, + "invalid_request", + "Thread became active while clearing context", + ); + } + + appendThreadEvent(deps, { + threadId: releasedThread.id, + environmentId: releasedThread.environmentId, + type: "system/operation", + scope: threadScope(), + data: { + operation: THREAD_CONTEXT_CLEAR_OPERATION, + operationId: createEventId(), + status: "completed", + message: + "New prompts won’t include messages above. Thread history and workspace are unchanged.", + }, + }); + }); +} diff --git a/apps/server/src/services/threads/thread-context-mutation-guard.ts b/apps/server/src/services/threads/thread-context-mutation-guard.ts new file mode 100644 index 0000000000..a7561ad2a7 --- /dev/null +++ b/apps/server/src/services/threads/thread-context-mutation-guard.ts @@ -0,0 +1,63 @@ +import { ApiError } from "../../errors.js"; + +interface ThreadContextMutationState { + clearing: boolean; + sends: number; +} + +const stateByThreadId = new Map(); + +function stateFor(threadId: string): ThreadContextMutationState { + const existing = stateByThreadId.get(threadId); + if (existing) return existing; + const created = { clearing: false, sends: 0 }; + stateByThreadId.set(threadId, created); + return created; +} + +function cleanup(threadId: string, state: ThreadContextMutationState): void { + if (!state.clearing && state.sends === 0) stateByThreadId.delete(threadId); +} + +export async function withThreadSendGuard( + threadId: string, + work: () => Promise, +): Promise { + const state = stateFor(threadId); + if (state.clearing) { + throw new ApiError( + 409, + "invalid_request", + "Thread context is being cleared", + ); + } + state.sends += 1; + try { + return await work(); + } finally { + state.sends -= 1; + cleanup(threadId, state); + } +} + +export async function withThreadContextClearGuard( + threadId: string, + work: () => Promise, +): Promise { + const state = stateFor(threadId); + if (state.clearing || state.sends > 0) { + cleanup(threadId, state); + throw new ApiError( + 409, + "invalid_request", + "Thread is processing another request", + ); + } + state.clearing = true; + try { + return await work(); + } finally { + state.clearing = false; + cleanup(threadId, state); + } +} diff --git a/apps/server/src/services/threads/thread-send-request.ts b/apps/server/src/services/threads/thread-send-request.ts index 4e1b233a5e..d765174709 100644 --- a/apps/server/src/services/threads/thread-send-request.ts +++ b/apps/server/src/services/threads/thread-send-request.ts @@ -8,7 +8,7 @@ import { listThreadIdsWithUndeliverableDeferredThreadMessages, type DeferredThreadMessageRow, } from "@bb/db"; -import type { Thread } from "@bb/domain"; +import { isStandaloneBuiltinClearCommand, type Thread } from "@bb/domain"; import type { SendMessageRequest, SendMessageResponse, @@ -66,7 +66,9 @@ export async function acceptThreadSendRequest( args: AcceptThreadSendRequestArgs, ): Promise { const { payload, thread } = args; + const isContextClear = isStandaloneBuiltinClearCommand(payload.input); const shouldQueue = + !isContextClear && thread.status === "active" && (payload.mode === "queue-if-active" || (payload.mode !== "start" && isManualCompactionActive(deps, thread))); @@ -78,6 +80,7 @@ export async function acceptThreadSendRequest( return { ok: true, delivery: "queued" }; } if ( + !isContextClear && payload.mode !== "start" && deps.pendingInteractions.hasPendingThreadInteraction(thread.id) ) { diff --git a/apps/server/src/services/threads/thread-send.ts b/apps/server/src/services/threads/thread-send.ts index dfea73fe9d..46f785983c 100644 --- a/apps/server/src/services/threads/thread-send.ts +++ b/apps/server/src/services/threads/thread-send.ts @@ -13,6 +13,7 @@ import type { ThreadTurnInitiator, TurnRequestTarget, } from "@bb/domain"; +import { isStandaloneBuiltinClearCommand } from "@bb/domain"; import type { SendMessageRequest } from "@bb/server-contract"; import { renderTemplate } from "@bb/templates"; import type { @@ -62,6 +63,8 @@ import { } from "../lib/lifecycle-api-errors.js"; import { validatePromptAttachmentReferences } from "../projects/attachments.js"; import { resolvePluginMentionContextInputs } from "../plugins/plugin-mentions.js"; +import { clearThreadContext } from "./thread-context-clear.js"; +import { withThreadSendGuard } from "./thread-context-mutation-guard.js"; import { prependDeferredFirstTurnContext, requireDeferredFirstTurnContextCurrent, @@ -396,6 +399,22 @@ function appendAndQueueSendThreadMessageInTransaction({ export async function sendThreadMessage( deps: LoggedPendingInteractionWorkSessionDeps, args: SendThreadMessageArgs, +): Promise { + if (isStandaloneBuiltinClearCommand(args.payload.input)) { + await clearThreadContext(deps, { + environment: args.environment, + thread: args.thread, + }); + return; + } + return withThreadSendGuard(args.thread.id, () => + sendThreadMessageWithoutContextClear(deps, args), + ); +} + +async function sendThreadMessageWithoutContextClear( + deps: LoggedPendingInteractionWorkSessionDeps, + args: SendThreadMessageArgs, ): Promise { const { environment, payload, thread } = args; ensureThreadIsWritable(thread); diff --git a/apps/server/test/public/public-project-commands.test.ts b/apps/server/test/public/public-project-commands.test.ts index b14af4158f..4dc9cbff33 100644 --- a/apps/server/test/public/public-project-commands.test.ts +++ b/apps/server/test/public/public-project-commands.test.ts @@ -326,6 +326,7 @@ describe("public project command typeahead route", () => { expect(response.status).toBe(200); const body = commandListResponseSchema.parse(await readJson(response)); expect(body.commands.map((command) => command.name)).toEqual([ + "clear", "vendor:review", ]); // The resolver is asked once, for this provider and workspace. @@ -420,6 +421,7 @@ describe("public project command typeahead route", () => { expect(response.status).toBe(200); const body = commandListResponseSchema.parse(await readJson(response)); expect(body.commands.map((command) => command.name)).toEqual([ + "clear", "after-the-wait", ]); // The resolver starts with the whole budget... @@ -586,6 +588,13 @@ describe("public project command typeahead route", () => { // project-origin entry over the user-origin one, while the cross-source // (command review) is retained as a distinct invocation. expect(body.commands).toEqual([ + { + name: "clear", + source: "command", + origin: "builtin", + description: "Start fresh context in this thread", + argumentHint: null, + }, { name: "compact", source: "command", @@ -668,6 +677,7 @@ describe("public project command typeahead route", () => { expect(response.status).toBe(200); const body = commandListResponseSchema.parse(await readJson(response)); expect(body.commands.map((command) => command.name)).toEqual([ + "clear", "compact", "prd", "skill-installer", @@ -712,6 +722,7 @@ describe("public project command typeahead route", () => { expect(response.status).toBe(200); const body = commandListResponseSchema.parse(await readJson(response)); expect(body.commands.map((command) => command.name)).toEqual([ + "clear", "compact", "stories", ]); @@ -756,6 +767,7 @@ describe("public project command typeahead route", () => { expect(response.status).toBe(200); const body = commandListResponseSchema.parse(await readJson(response)); expect(body.commands.map((command) => command.name)).toEqual([ + "clear", "compact", "alpha-review-notes", "ottonomous:review", @@ -820,6 +832,7 @@ describe("public project command typeahead route", () => { expect(response.status).toBe(200); const body = commandListResponseSchema.parse(await readJson(response)); expect(body.commands.map((command) => command.name)).toEqual([ + "clear", "compact", "bb-cli", ]); @@ -859,6 +872,7 @@ describe("public project command typeahead route", () => { expect(response.status).toBe(200); const body = commandListResponseSchema.parse(await readJson(response)); expect(body.commands.map((command) => command.name)).toEqual([ + "clear", "compact", "user-only", ]); @@ -908,6 +922,7 @@ describe("public project command typeahead route", () => { expect(response.status).toBe(200); const body = commandListResponseSchema.parse(await readJson(response)); expect(body.commands.map((command) => command.name)).toEqual([ + "clear", "compact", "user-only", ]); @@ -950,6 +965,7 @@ describe("public project command typeahead route", () => { expect(response.status).toBe(200); const body = commandListResponseSchema.parse(await readJson(response)); expect(body.commands.map((command) => command.name)).toEqual([ + "clear", "compact", "user-only", ]); @@ -984,6 +1000,7 @@ describe("public project command typeahead route", () => { expect(response.status).toBe(200); const body = commandListResponseSchema.parse(await readJson(response)); expect(body.commands.map((command) => command.name)).toEqual([ + "clear", "compact", "home-skill", ]); @@ -1051,6 +1068,7 @@ describe("public project command typeahead route", () => { await readJson(fullResponse), ); expect(full.commands.map((command) => command.name)).toEqual([ + "clear", "compact", "alpha", "bravo", diff --git a/apps/server/test/public/public-thread-banner-actions.test.ts b/apps/server/test/public/public-thread-banner-actions.test.ts index c8f0de3961..4757852f64 100644 --- a/apps/server/test/public/public-thread-banner-actions.test.ts +++ b/apps/server/test/public/public-thread-banner-actions.test.ts @@ -1,8 +1,9 @@ -import { getThread, listEvents } from "@bb/db"; +import { getLastStoredProviderThreadId, getThread, listEvents } from "@bb/db"; import { encodeClientTurnRequestIdNumber, threadScope, turnScope, + type PromptInput, } from "@bb/domain"; import { describe, expect, it } from "vitest"; import { registerHostRpcResponder } from "../helpers/host-rpc.js"; @@ -26,6 +27,30 @@ interface BannerFixture { threadId: string; } +function clearCommandInput(): PromptInput[] { + return [ + { + type: "text", + text: "/clear", + mentions: [ + { + start: 0, + end: 6, + resource: { + kind: "command", + trigger: "/", + name: "clear", + source: "command", + origin: "builtin", + label: "clear", + argumentHint: null, + }, + }, + ], + }, + ]; +} + function seedBannerFixture( harness: TestAppHarness, args: { status: "active" | "idle" }, @@ -163,6 +188,81 @@ async function readBannerActivity( } describe("public thread banner actions", () => { + it("routes standalone built-in /clear to a same-thread context boundary", async () => { + await withTestHarness(async (harness) => { + const fixture = seedBannerFixture(harness, { status: "idle" }); + seedThreadRuntimeState(harness.deps, { + environmentId: fixture.environmentId, + providerThreadId: "provider-thread-1", + threadId: fixture.threadId, + }); + const responder = registerHostRpcResponder(harness, { + hostId: fixture.hostId, + sessionId: fixture.sessionId, + handle: ({ command }) => { + expect(command).toMatchObject({ + type: "thread.stop", + threadId: fixture.threadId, + }); + return { ok: true, result: { providerCheckpointId: null } }; + }, + }); + + const response = await harness.app.request( + `/api/v1/threads/${fixture.threadId}/send`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + mode: "start", + input: clearCommandInput(), + }), + }, + ); + + expect(response.status).toBe(200); + expect(responder.requests).toHaveLength(1); + expect( + getLastStoredProviderThreadId(harness.db, fixture.threadId), + ).toBeNull(); + const events = listEvents(harness.db, { threadId: fixture.threadId }); + expect( + events.filter((event) => event.type === "client/turn/requested"), + ).toHaveLength(1); + expect( + events.filter((event) => event.type === "system/thread/interrupted"), + ).toHaveLength(0); + expect( + events.find((event) => event.type === "system/operation"), + ).toMatchObject({ + data: expect.stringContaining('"operation":"context_clear"'), + }); + }); + }); + + it("rejects /clear while a thread is active", async () => { + await withTestHarness(async (harness) => { + const fixture = seedBannerFixture(harness, { status: "active" }); + + const response = await harness.app.request( + `/api/v1/threads/${fixture.threadId}/send`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + mode: "queue-if-active", + input: clearCommandInput(), + }), + }, + ); + + expect(response.status).toBe(409); + expect( + listEvents(harness.db, { threadId: fixture.threadId }), + ).toHaveLength(0); + }); + }); + it("persists a successful Plan cancellation and returns zero after refetch", async () => { await withTestHarness(async (harness) => { const fixture = seedBannerFixture(harness, { status: "active" }); diff --git a/apps/server/test/services/threads/provider-command-typeahead.test.ts b/apps/server/test/services/threads/provider-command-typeahead.test.ts index 19df870826..d608c20818 100644 --- a/apps/server/test/services/threads/provider-command-typeahead.test.ts +++ b/apps/server/test/services/threads/provider-command-typeahead.test.ts @@ -16,9 +16,16 @@ function skill( } describe("buildCommandListResponse", () => { - it("keeps the built-in compact row when project commands collide", () => { + it("keeps canonical built-ins when project commands collide", () => { const response = buildCommandListResponse({ commands: [ + { + name: "clear", + source: "command", + origin: "project", + description: "Project clear command", + argumentHint: null, + }, { name: "compact", source: "command", @@ -32,6 +39,13 @@ describe("buildCommandListResponse", () => { }); expect(response.commands).toEqual([ + { + name: "clear", + source: "command", + origin: "builtin", + description: "Start fresh context in this thread", + argumentHint: null, + }, { name: "compact", source: "command", @@ -101,6 +115,14 @@ describe("buildCommandListResponse", () => { skillCatalog: [], }); - expect(response.commands).toEqual([]); + expect(response.commands).toEqual([ + { + name: "clear", + source: "command", + origin: "builtin", + description: "Start fresh context in this thread", + argumentHint: null, + }, + ]); }); }); diff --git a/apps/server/test/services/threads/thread-context-mutation-guard.test.ts b/apps/server/test/services/threads/thread-context-mutation-guard.test.ts new file mode 100644 index 0000000000..37241cb2d8 --- /dev/null +++ b/apps/server/test/services/threads/thread-context-mutation-guard.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { + withThreadContextClearGuard, + withThreadSendGuard, +} from "../../../src/services/threads/thread-context-mutation-guard.js"; + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve = () => {}; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +describe("thread context mutation guard", () => { + it("rejects sends while a context clear owns the thread", async () => { + const started = deferred(); + const release = deferred(); + const clear = withThreadContextClearGuard("thread-clear", async () => { + started.resolve(); + await release.promise; + }); + await started.promise; + + await expect( + withThreadSendGuard("thread-clear", async () => {}), + ).rejects.toMatchObject({ status: 409 }); + release.resolve(); + await clear; + }); + + it("rejects a context clear while a send owns the thread", async () => { + const started = deferred(); + const release = deferred(); + const send = withThreadSendGuard("thread-send", async () => { + started.resolve(); + await release.promise; + }); + await started.promise; + + await expect( + withThreadContextClearGuard("thread-send", async () => {}), + ).rejects.toMatchObject({ status: 409 }); + release.resolve(); + await send; + }); +}); diff --git a/packages/db/src/data/events.ts b/packages/db/src/data/events.ts index 717d823d47..f1773b6f6a 100644 --- a/packages/db/src/data/events.ts +++ b/packages/db/src/data/events.ts @@ -32,6 +32,7 @@ import { LOCAL_BASH_TASK_TYPE, LOCAL_SUBAGENT_TASK_TYPE, LOCAL_WORKFLOW_TASK_TYPE, + THREAD_CONTEXT_CLEAR_OPERATION, clientTurnRequestIdSchema, getThreadEventScopeTurnId, parseStoredThreadEvent, @@ -3284,7 +3285,14 @@ export function getLastStoredProviderThreadId( .from(events) .where( sql`${events.threadId} = ${threadId} - AND ${events.providerThreadId} IS NOT NULL`, + AND ${events.providerThreadId} IS NOT NULL + AND ${events.sequence} > COALESCE(( + SELECT MAX(context_clear.sequence) + FROM events AS context_clear + WHERE context_clear.thread_id = ${threadId} + AND context_clear.type = 'system/operation' + AND json_extract(context_clear.data, '$.operation') = ${THREAD_CONTEXT_CLEAR_OPERATION} + ), 0)`, ) .orderBy(sql`${events.sequence} DESC`) .limit(1) @@ -3375,6 +3383,13 @@ export function listThreadTurnInterruptionEventStates( FROM events AS latest WHERE latest.thread_id = ${events.threadId} AND latest.provider_thread_id IS NOT NULL + AND latest.sequence > COALESCE(( + SELECT MAX(context_clear.sequence) + FROM events AS context_clear + WHERE context_clear.thread_id = ${events.threadId} + AND context_clear.type = 'system/operation' + AND json_extract(context_clear.data, '$.operation') = ${THREAD_CONTEXT_CLEAR_OPERATION} + ), 0) )`, ), ) diff --git a/packages/db/test/data/events.test.ts b/packages/db/test/data/events.test.ts index 1465b3418a..69c1d2802c 100644 --- a/packages/db/test/data/events.test.ts +++ b/packages/db/test/data/events.test.ts @@ -4,6 +4,7 @@ import { LOCAL_BASH_TASK_TYPE, LOCAL_SUBAGENT_TASK_TYPE, LOCAL_WORKFLOW_TASK_TYPE, + THREAD_CONTEXT_CLEAR_OPERATION, threadScope, turnScope, type PromptInput, @@ -2293,6 +2294,32 @@ describe("events", () => { }); expect(getLastStoredProviderThreadId(db, thread.id)).toBe("provider_old"); + appendStoredThreadEvent(db, noopNotifier, { + threadId: thread.id, + scope: threadScope(), + type: "system/operation", + data: { + operation: THREAD_CONTEXT_CLEAR_OPERATION, + operationId: "evt_context_clear", + status: "completed", + message: "Context cleared", + }, + }); + expect(getLastStoredProviderThreadId(db, thread.id)).toBeNull(); + + appendStoredThreadEvent(db, noopNotifier, { + threadId: thread.id, + scope: threadScope(), + type: "system/operation", + data: { + operation: THREAD_CONTEXT_CLEAR_OPERATION, + operationId: "evt_context_clear_again", + status: "completed", + message: "Context cleared", + }, + }); + expect(getLastStoredProviderThreadId(db, thread.id)).toBeNull(); + appendStoredThreadEvent(db, noopNotifier, { threadId: thread.id, scope: threadScope(), @@ -2305,6 +2332,39 @@ describe("events", () => { expect(getLastStoredProviderThreadId(db, thread.id)).toBe("provider_new"); }); + it("omits provider identities before a clear from batched interruption state", () => { + const { db, thread } = setup(); + + appendStoredThreadEvent(db, noopNotifier, { + threadId: thread.id, + scope: threadScope(), + providerThreadId: "provider_old", + type: "thread/identity", + data: { providerThreadId: "provider_old" }, + }); + appendStoredThreadEvent(db, noopNotifier, { + threadId: thread.id, + scope: threadScope(), + type: "system/operation", + data: { + operation: THREAD_CONTEXT_CLEAR_OPERATION, + operationId: "evt_context_clear", + status: "completed", + message: "Context cleared", + }, + }); + + expect( + listThreadTurnInterruptionEventStates(db, { threadIds: [thread.id] }), + ).toEqual([ + { + activeTurnId: null, + latestProviderThreadId: null, + threadId: thread.id, + }, + ]); + }); + it("ignores delegated child turn starts when reconstructing the active stored turn", () => { const { db, thread } = setup(); diff --git a/packages/domain/src/shared-types.ts b/packages/domain/src/shared-types.ts index 684ea7d705..665c6216cb 100644 --- a/packages/domain/src/shared-types.ts +++ b/packages/domain/src/shared-types.ts @@ -291,19 +291,18 @@ function isSelectedPromptCommandMention( } const BUILTIN_COMPACT_COMMAND = { trigger: "/", name: "compact" } as const; +const BUILTIN_CLEAR_COMMAND = { trigger: "/", name: "clear" } as const; -/** - * Whether input consists solely of one selected built-in `/compact` mention. - * Raw matching text and project/user commands intentionally do not qualify. - */ -export function isStandaloneBuiltinCompactCommand( +function isStandaloneBuiltinCommand( input: readonly PromptInput[], + selector: PromptCommandSelector, + commandText: string, ): boolean { const selected = input.flatMap((item) => item.type === "text" ? item.mentions .filter((mention) => - isSelectedPromptCommandMention(mention, BUILTIN_COMPACT_COMMAND), + isSelectedPromptCommandMention(mention, selector), ) .map((mention) => ({ mention, text: item.text })) : [], @@ -321,14 +320,30 @@ export function isStandaloneBuiltinCompactCommand( mention.resource.kind !== "command" || mention.resource.source !== "command" || mention.resource.origin !== "builtin" || - text.slice(mention.start, mention.end) !== "/compact" + text.slice(mention.start, mention.end) !== commandText ) { return false; } - return removeCommandMentionsFromPromptInput( - input, - BUILTIN_COMPACT_COMMAND, - ).every((item) => item.type === "text" && item.text.trim() === ""); + return removeCommandMentionsFromPromptInput(input, selector).every( + (item) => item.type === "text" && item.text.trim() === "", + ); +} + +/** + * Whether input consists solely of one selected built-in `/compact` mention. + * Raw matching text and project/user commands intentionally do not qualify. + */ +export function isStandaloneBuiltinCompactCommand( + input: readonly PromptInput[], +): boolean { + return isStandaloneBuiltinCommand(input, BUILTIN_COMPACT_COMMAND, "/compact"); +} + +/** Whether input consists solely of one selected built-in `/clear` mention. */ +export function isStandaloneBuiltinClearCommand( + input: readonly PromptInput[], +): boolean { + return isStandaloneBuiltinCommand(input, BUILTIN_CLEAR_COMMAND, "/clear"); } /** Structured prompt input for the selected built-in `/compact` command. */ diff --git a/packages/domain/src/thread-events.ts b/packages/domain/src/thread-events.ts index d14e509c2a..8d6410f6b5 100644 --- a/packages/domain/src/thread-events.ts +++ b/packages/domain/src/thread-events.ts @@ -202,6 +202,8 @@ export type OwnershipChangeOperationMetadata = z.infer< typeof ownershipChangeOperationMetadataSchema >; +export const THREAD_CONTEXT_CLEAR_OPERATION = "context_clear"; + export const systemOperationEventDataSchema = z.object({ operation: z.string(), status: z.string(), diff --git a/packages/domain/test/standalone-builtin-clear-command.test.ts b/packages/domain/test/standalone-builtin-clear-command.test.ts new file mode 100644 index 0000000000..01eb4848b3 --- /dev/null +++ b/packages/domain/test/standalone-builtin-clear-command.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { isStandaloneBuiltinClearCommand } from "../src/shared-types.js"; +import type { PromptInput, PromptMentionCommandOrigin } from "../src/index.js"; + +function clearInput(args?: { + origin?: PromptMentionCommandOrigin; + text?: string; +}): PromptInput { + const text = args?.text ?? "/clear"; + const start = text.indexOf("/clear"); + if (start === -1) throw new Error(`Missing /clear command in "${text}"`); + return { + type: "text", + text, + mentions: [ + { + start, + end: start + "/clear".length, + resource: { + kind: "command", + trigger: "/", + name: "clear", + source: "command", + origin: args?.origin ?? "builtin", + label: "clear", + argumentHint: null, + }, + }, + ], + }; +} + +describe("isStandaloneBuiltinClearCommand", () => { + it("accepts only a standalone selected built-in /clear command", () => { + expect(isStandaloneBuiltinClearCommand([clearInput()])).toBe(true); + expect( + isStandaloneBuiltinClearCommand([ + { type: "text", text: "/clear", mentions: [] }, + ]), + ).toBe(false); + expect( + isStandaloneBuiltinClearCommand([clearInput({ origin: "user" })]), + ).toBe(false); + expect( + isStandaloneBuiltinClearCommand([ + clearInput({ text: "/clear then summarize" }), + ]), + ).toBe(false); + }); +}); diff --git a/packages/sdk/src/areas/threads.ts b/packages/sdk/src/areas/threads.ts index d1a25551d7..aa8f7d3468 100644 --- a/packages/sdk/src/areas/threads.ts +++ b/packages/sdk/src/areas/threads.ts @@ -434,6 +434,7 @@ export interface ThreadsArea { childSummary(args: ThreadStatusArgs): Promise; compact(args: ThreadActionArgs): Promise; cancelPlan(args: ThreadActionArgs): Promise; + clearContext(args: ThreadActionArgs): Promise; clearGoal(args: ThreadActionArgs): Promise; conversationOutline( args: ThreadStatusArgs, @@ -1085,6 +1086,14 @@ export function createThreadsArea(args: CreateSdkAreaArgs): ThreadsArea { ); return { ok: true }; }, + async clearContext(input) { + await transport.readVoid( + transport.api.v1.threads[":id"].context.clear.$post({ + param: { id: input.threadId }, + }), + ); + return { ok: true }; + }, async cancelPlan(input) { await transport.readVoid( transport.api.v1.threads[":id"].plan.cancel.$post({ diff --git a/packages/sdk/test/public-types.test.ts b/packages/sdk/test/public-types.test.ts index 6389b7638b..fd7e419e38 100644 --- a/packages/sdk/test/public-types.test.ts +++ b/packages/sdk/test/public-types.test.ts @@ -354,6 +354,7 @@ type ExpectedThreadsKey = | "archiveAll" | "cancelPlan" | "childSummary" + | "clearContext" | "clearGoal" | "compact" | "conversationOutline" diff --git a/packages/server-contract/src/public-api.ts b/packages/server-contract/src/public-api.ts index e2033881c1..041e07c382 100644 --- a/packages/server-contract/src/public-api.ts +++ b/packages/server-contract/src/public-api.ts @@ -1080,6 +1080,12 @@ export const publicApiRoutes = { request: noRequest(), response: jsonResponse<{ ok: true }>(), }), + clearContext: defineRoute({ + path: "/threads/:id/context/clear", + method: "post", + request: noRequest(), + response: jsonResponse<{ ok: true }>(), + }), cancelPlan: defineRoute({ path: "/threads/:id/plan/cancel", method: "post", diff --git a/packages/templates/src/templates/bb-guide-threads.md b/packages/templates/src/templates/bb-guide-threads.md index ca48e8cf85..8a14f8c020 100644 --- a/packages/templates/src/templates/bb-guide-threads.md +++ b/packages/templates/src/templates/bb-guide-threads.md @@ -209,12 +209,15 @@ Messaging: bb thread stop [id] Stop work and release the agent runtime bb thread compact [id] Request compaction of an idle or errored thread's context + bb thread clear [id] Clear model context for an idle or failed thread bb thread cancel-plan [id] Exit the provider's active Plan mode bb thread clear-goal [id] Clear the provider's active Goal --self Target current thread `thread compact` enqueues the same structured /compact turn used by the composer. Follow the thread timeline for the eventual compaction result. + `thread clear` keeps the BB thread, timeline, workspace, and sticky execution + settings. Its next prompt starts a fresh provider conversation. Ownership: diff --git a/packages/thread-view/src/parse-operation-message.ts b/packages/thread-view/src/parse-operation-message.ts index ce4a469cab..c57a86f0c7 100644 --- a/packages/thread-view/src/parse-operation-message.ts +++ b/packages/thread-view/src/parse-operation-message.ts @@ -7,6 +7,7 @@ import type { UserQuestionInteractionLifecycle, } from "@bb/domain"; import { + THREAD_CONTEXT_CLEAR_OPERATION, isApprovalInteractionLifecycle, isUserQuestionInteractionLifecycle, ownershipChangeOperationMetadataSchema, @@ -200,6 +201,12 @@ function threadOperationTitle( case "ownership_change": return ownershipChangeOperationTitle(meta, threadName); case "other": + if ( + meta.rawOperation === THREAD_CONTEXT_CLEAR_OPERATION && + meta.status === "completed" + ) { + return "Context cleared"; + } return `${capitalize(meta.rawOperation.replace(/_/g, " "))} ${ meta.rawStatus }`; diff --git a/packages/thread-view/test/parse-operation-message.test.ts b/packages/thread-view/test/parse-operation-message.test.ts index 1d46aab71c..e7f433e7b3 100644 --- a/packages/thread-view/test/parse-operation-message.test.ts +++ b/packages/thread-view/test/parse-operation-message.test.ts @@ -140,6 +140,26 @@ describe("parseOperationMessage operation titles", () => { }); }); + it("renders a context clear as a concise completed boundary", () => { + const row = factory().systemOperation({ + operation: "context_clear", + status: "completed", + message: + "New prompts won’t include messages above. Thread history and workspace are unchanged.", + }); + const { event, meta } = decodeThreadEventRow(row); + + expect( + parseOperationMessage(event, meta, { threadName: THREAD_NAME }), + ).toMatchObject({ + kind: "operation", + title: "Context cleared", + detail: + "New prompts won’t include messages above. Thread history and workspace are unchanged.", + status: "completed", + }); + }); + describe("ownership-change", () => { it("links the thread to its new/previous parent by action", () => { expect( From c4756e0fb2d8d043017d0ca46536fed57f794187 Mon Sep 17 00:00:00 2001 From: Mateo Cerquetella <33433649+MateoCerquetella@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:31:28 -0300 Subject: [PATCH 2/2] Refine same-thread context clearing --- .../promptbox/PromptBoxInternal.test.tsx | 66 ++++++---------- .../promptbox/PromptBoxInternal.tsx | 4 +- .../threads/provider-command-typeahead.ts | 36 +++++---- .../threads/thread-context-mutation-guard.ts | 63 ++++++--------- .../public-project-workspace-routing.test.ts | 7 +- .../thread-context-mutation-guard.test.ts | 36 ++++----- packages/domain/src/shared-types.ts | 13 ++- .../standalone-builtin-clear-command.test.ts | 50 ------------ .../test/standalone-builtin-command.test.ts | 79 +++++++++++++++++++ ...standalone-builtin-compact-command.test.ts | 77 ------------------ packages/plugin-api-map/sdk-public-api.json | 2 +- 11 files changed, 173 insertions(+), 260 deletions(-) delete mode 100644 packages/domain/test/standalone-builtin-clear-command.test.ts create mode 100644 packages/domain/test/standalone-builtin-command.test.ts delete mode 100644 packages/domain/test/standalone-builtin-compact-command.test.ts diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx index 3ae79f68f8..b94dfe7743 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.test.tsx @@ -3715,14 +3715,6 @@ describe("PromptBoxInternal command typeahead submit", () => { description: "Compact context", argumentHint: null, }; - const clearSuggestion: ProviderCommandSuggestion = { - kind: "command", - name: "clear", - source: "command", - origin: "builtin", - description: "Start fresh context in this thread", - argumentHint: null, - }; const userSkillSuggestion: ProviderCommandSuggestion = { kind: "command", name: "review", @@ -3793,41 +3785,35 @@ describe("PromptBoxInternal command typeahead submit", () => { await waitFor(() => expect(screen.queryByText(name)).not.toBeNull()); } - it.each([ - { name: "compact", suggestion: compactSuggestion }, - { name: "clear", suggestion: clearSuggestion }, - ])( - "submits built-in /$name when selected with Enter", - async ({ name, suggestion }) => { - const { changes, onSubmit, promptBoxRef } = - renderCommandPromptBox(suggestion); - await openCommandMenu(promptBoxRef, `/${name}`, name); + it("submits a built-in command selected with Enter", async () => { + const { changes, onSubmit, promptBoxRef } = + renderCommandPromptBox(compactSuggestion); + await openCommandMenu(promptBoxRef, "/compact", "compact"); - await act(async () => { - fireEvent.keyDown(getPromptEditorElement(), { key: "Enter" }); - }); - await act(async () => {}); + await act(async () => { + fireEvent.keyDown(getPromptEditorElement(), { key: "Enter" }); + }); + await act(async () => {}); - expect(onSubmit).toHaveBeenCalledTimes(1); - // The command mention is applied (and therefore submitted), not left as - // bare text. - expect(latestChange(changes)?.mentions).toEqual([ - { - start: 0, - end: `/${name}`.length, - resource: { - kind: "command", - trigger: "/", - name, - source: "command", - origin: "builtin", - label: name, - argumentHint: null, - }, + expect(onSubmit).toHaveBeenCalledTimes(1); + // The command mention is applied (and therefore submitted), not left as + // bare text. + expect(latestChange(changes)?.mentions).toEqual([ + { + start: 0, + end: "/compact".length, + resource: { + kind: "command", + trigger: "/", + name: "compact", + source: "command", + origin: "builtin", + label: "compact", + argumentHint: null, }, - ]); - }, - ); + }, + ]); + }); it("does not submit when a non-built-in command is selected with Enter", async () => { const { changes, onSubmit, promptBoxRef } = diff --git a/apps/app/src/components/promptbox/PromptBoxInternal.tsx b/apps/app/src/components/promptbox/PromptBoxInternal.tsx index d17f617c99..5c23ec9c84 100644 --- a/apps/app/src/components/promptbox/PromptBoxInternal.tsx +++ b/apps/app/src/components/promptbox/PromptBoxInternal.tsx @@ -2794,7 +2794,7 @@ export function PromptBoxInternal({ [], ); - // A no-argument built-in command (such as `/compact` or `/clear`) is complete + // A no-argument built-in command is a complete // action the moment it is selected, so applying it with Enter should also // submit instead of leaving the pill parked for a second Enter. The submit is // deferred to this effect — keyed on the flag — so `onSubmit` runs after the @@ -2963,7 +2963,7 @@ export function PromptBoxInternal({ activeSuggestions[selectedIndex] ?? activeSuggestions[0]; if (selected) { applyTrigger(selected); - // Built-in commands (e.g. `/compact` and `/clear`) take no arguments, so picking + // Built-in commands take no arguments, so picking // one with Enter both inserts the pill and submits. Tab still only // inserts, and mention suggestions are unaffected. if ( diff --git a/apps/server/src/services/threads/provider-command-typeahead.ts b/apps/server/src/services/threads/provider-command-typeahead.ts index f944d413d1..1b711a630a 100644 --- a/apps/server/src/services/threads/provider-command-typeahead.ts +++ b/apps/server/src/services/threads/provider-command-typeahead.ts @@ -7,21 +7,22 @@ import type { HostProviderCommand } from "@bb/host-daemon-contract"; import type { ProviderRegistration } from "../providers/provider-registry.js"; import type { ResolvedSkillCatalogEntry } from "../skills/injected-skills.js"; -const BUILT_IN_CLEAR_COMMAND: ProviderCommand = { - name: "clear", - source: "command", - origin: "builtin", - description: "Start fresh context in this thread", - argumentHint: null, -}; - -const BUILT_IN_COMPACT_COMMAND: ProviderCommand = { - name: "compact", - source: "command", - origin: "builtin", - description: "Compact context", - argumentHint: null, -}; +const BUILT_IN_PROVIDER_COMMANDS: ProviderCommand[] = [ + { + name: "clear", + source: "command", + origin: "builtin", + description: "Start fresh context in this thread", + argumentHint: null, + }, + { + name: "compact", + source: "command", + origin: "builtin", + description: "Compact context", + argumentHint: null, + }, +]; function providerComposerHasSkillsAction( composerActions: readonly { kind: string }[], @@ -129,8 +130,9 @@ export function buildCommandListResponse( ): CommandListResponse { return { commands: dedupeBySourceAndName([ - BUILT_IN_CLEAR_COMMAND, - ...(args.includeBuiltinCompact ? [BUILT_IN_COMPACT_COMMAND] : []), + ...BUILT_IN_PROVIDER_COMMANDS.filter( + (command) => command.name !== "compact" || args.includeBuiltinCompact, + ), ...args.skillCatalog.map(toSkillCommand), ...args.commands.map(toProviderCommand), ]).sort(compareCommands), diff --git a/apps/server/src/services/threads/thread-context-mutation-guard.ts b/apps/server/src/services/threads/thread-context-mutation-guard.ts index a7561ad2a7..758ab8521b 100644 --- a/apps/server/src/services/threads/thread-context-mutation-guard.ts +++ b/apps/server/src/services/threads/thread-context-mutation-guard.ts @@ -1,63 +1,44 @@ import { ApiError } from "../../errors.js"; -interface ThreadContextMutationState { - clearing: boolean; - sends: number; -} - -const stateByThreadId = new Map(); - -function stateFor(threadId: string): ThreadContextMutationState { - const existing = stateByThreadId.get(threadId); - if (existing) return existing; - const created = { clearing: false, sends: 0 }; - stateByThreadId.set(threadId, created); - return created; -} - -function cleanup(threadId: string, state: ThreadContextMutationState): void { - if (!state.clearing && state.sends === 0) stateByThreadId.delete(threadId); -} +// Positive values count overlapping sends; -1 is the exclusive clear owner. +const inFlightByThreadId = new Map(); -export async function withThreadSendGuard( +async function withThreadContextMutationGuard( threadId: string, + mode: "clear" | "send", work: () => Promise, ): Promise { - const state = stateFor(threadId); - if (state.clearing) { + const inFlight = inFlightByThreadId.get(threadId) ?? 0; + if (inFlight !== 0 && (mode === "clear" || inFlight < 0)) { throw new ApiError( 409, "invalid_request", - "Thread context is being cleared", + mode === "send" + ? "Thread context is being cleared" + : "Thread is processing another request", ); } - state.sends += 1; + inFlightByThreadId.set(threadId, mode === "clear" ? -1 : inFlight + 1); try { return await work(); } finally { - state.sends -= 1; - cleanup(threadId, state); + const remaining = + mode === "clear" ? 0 : (inFlightByThreadId.get(threadId) ?? 1) - 1; + if (remaining === 0) inFlightByThreadId.delete(threadId); + else inFlightByThreadId.set(threadId, remaining); } } +export async function withThreadSendGuard( + threadId: string, + work: () => Promise, +): Promise { + return withThreadContextMutationGuard(threadId, "send", work); +} + export async function withThreadContextClearGuard( threadId: string, work: () => Promise, ): Promise { - const state = stateFor(threadId); - if (state.clearing || state.sends > 0) { - cleanup(threadId, state); - throw new ApiError( - 409, - "invalid_request", - "Thread is processing another request", - ); - } - state.clearing = true; - try { - return await work(); - } finally { - state.clearing = false; - cleanup(threadId, state); - } + return withThreadContextMutationGuard(threadId, "clear", work); } diff --git a/apps/server/test/public/public-project-workspace-routing.test.ts b/apps/server/test/public/public-project-workspace-routing.test.ts index 14d358b869..50e29b5643 100644 --- a/apps/server/test/public/public-project-workspace-routing.test.ts +++ b/apps/server/test/public/public-project-workspace-routing.test.ts @@ -149,10 +149,7 @@ describe("public project workspace routing", () => { `/api/v1/projects/${project.id}/commands?provider=codex`, ); await expect(readJson(primaryCommands)).resolves.toMatchObject({ - commands: [ - expect.objectContaining({ name: "compact" }), - primaryCommand, - ], + commands: expect.arrayContaining([primaryCommand]), }); const primaryContent = await harness.app.request( `/api/v1/projects/${project.id}/files/content?path=primary.txt`, @@ -183,7 +180,7 @@ describe("public project workspace routing", () => { `/api/v1/projects/${project.id}/commands?provider=codex&hostId=${remoteHost.id}`, ); await expect(readJson(commands)).resolves.toMatchObject({ - commands: [expect.objectContaining({ name: "compact" }), remoteCommand], + commands: expect.arrayContaining([remoteCommand]), }); expect( remoteRpc.requests.find( diff --git a/apps/server/test/services/threads/thread-context-mutation-guard.test.ts b/apps/server/test/services/threads/thread-context-mutation-guard.test.ts index 37241cb2d8..40b40079c2 100644 --- a/apps/server/test/services/threads/thread-context-mutation-guard.test.ts +++ b/apps/server/test/services/threads/thread-context-mutation-guard.test.ts @@ -1,21 +1,14 @@ +import { createDeferredPromise } from "@bb/test-helpers"; import { describe, expect, it } from "vitest"; import { withThreadContextClearGuard, withThreadSendGuard, } from "../../../src/services/threads/thread-context-mutation-guard.js"; -function deferred(): { promise: Promise; resolve: () => void } { - let resolve = () => {}; - const promise = new Promise((done) => { - resolve = done; - }); - return { promise, resolve }; -} - describe("thread context mutation guard", () => { it("rejects sends while a context clear owns the thread", async () => { - const started = deferred(); - const release = deferred(); + const started = createDeferredPromise(); + const release = createDeferredPromise(); const clear = withThreadContextClearGuard("thread-clear", async () => { started.resolve(); await release.promise; @@ -27,21 +20,26 @@ describe("thread context mutation guard", () => { ).rejects.toMatchObject({ status: 409 }); release.resolve(); await clear; + await expect( + withThreadSendGuard("thread-clear", async () => "sent"), + ).resolves.toBe("sent"); }); - it("rejects a context clear while a send owns the thread", async () => { - const started = deferred(); - const release = deferred(); - const send = withThreadSendGuard("thread-send", async () => { - started.resolve(); - await release.promise; - }); - await started.promise; + it("allows overlapping sends but excludes clear until both settle", async () => { + const release = createDeferredPromise(); + const send = () => + withThreadSendGuard("thread-send", async () => { + await release.promise; + }); + const sends = [send(), send()]; await expect( withThreadContextClearGuard("thread-send", async () => {}), ).rejects.toMatchObject({ status: 409 }); release.resolve(); - await send; + await Promise.all(sends); + await expect( + withThreadContextClearGuard("thread-send", async () => "cleared"), + ).resolves.toBe("cleared"); }); }); diff --git a/packages/domain/src/shared-types.ts b/packages/domain/src/shared-types.ts index 665c6216cb..1a86e3489f 100644 --- a/packages/domain/src/shared-types.ts +++ b/packages/domain/src/shared-types.ts @@ -290,14 +290,11 @@ function isSelectedPromptCommandMention( ); } -const BUILTIN_COMPACT_COMMAND = { trigger: "/", name: "compact" } as const; -const BUILTIN_CLEAR_COMMAND = { trigger: "/", name: "clear" } as const; - function isStandaloneBuiltinCommand( input: readonly PromptInput[], - selector: PromptCommandSelector, - commandText: string, + name: string, ): boolean { + const selector = { trigger: "/" as const, name }; const selected = input.flatMap((item) => item.type === "text" ? item.mentions @@ -320,7 +317,7 @@ function isStandaloneBuiltinCommand( mention.resource.kind !== "command" || mention.resource.source !== "command" || mention.resource.origin !== "builtin" || - text.slice(mention.start, mention.end) !== commandText + text.slice(mention.start, mention.end) !== `/${name}` ) { return false; } @@ -336,14 +333,14 @@ function isStandaloneBuiltinCommand( export function isStandaloneBuiltinCompactCommand( input: readonly PromptInput[], ): boolean { - return isStandaloneBuiltinCommand(input, BUILTIN_COMPACT_COMMAND, "/compact"); + return isStandaloneBuiltinCommand(input, "compact"); } /** Whether input consists solely of one selected built-in `/clear` mention. */ export function isStandaloneBuiltinClearCommand( input: readonly PromptInput[], ): boolean { - return isStandaloneBuiltinCommand(input, BUILTIN_CLEAR_COMMAND, "/clear"); + return isStandaloneBuiltinCommand(input, "clear"); } /** Structured prompt input for the selected built-in `/compact` command. */ diff --git a/packages/domain/test/standalone-builtin-clear-command.test.ts b/packages/domain/test/standalone-builtin-clear-command.test.ts deleted file mode 100644 index 01eb4848b3..0000000000 --- a/packages/domain/test/standalone-builtin-clear-command.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { isStandaloneBuiltinClearCommand } from "../src/shared-types.js"; -import type { PromptInput, PromptMentionCommandOrigin } from "../src/index.js"; - -function clearInput(args?: { - origin?: PromptMentionCommandOrigin; - text?: string; -}): PromptInput { - const text = args?.text ?? "/clear"; - const start = text.indexOf("/clear"); - if (start === -1) throw new Error(`Missing /clear command in "${text}"`); - return { - type: "text", - text, - mentions: [ - { - start, - end: start + "/clear".length, - resource: { - kind: "command", - trigger: "/", - name: "clear", - source: "command", - origin: args?.origin ?? "builtin", - label: "clear", - argumentHint: null, - }, - }, - ], - }; -} - -describe("isStandaloneBuiltinClearCommand", () => { - it("accepts only a standalone selected built-in /clear command", () => { - expect(isStandaloneBuiltinClearCommand([clearInput()])).toBe(true); - expect( - isStandaloneBuiltinClearCommand([ - { type: "text", text: "/clear", mentions: [] }, - ]), - ).toBe(false); - expect( - isStandaloneBuiltinClearCommand([clearInput({ origin: "user" })]), - ).toBe(false); - expect( - isStandaloneBuiltinClearCommand([ - clearInput({ text: "/clear then summarize" }), - ]), - ).toBe(false); - }); -}); diff --git a/packages/domain/test/standalone-builtin-command.test.ts b/packages/domain/test/standalone-builtin-command.test.ts new file mode 100644 index 0000000000..5bbe4fe4de --- /dev/null +++ b/packages/domain/test/standalone-builtin-command.test.ts @@ -0,0 +1,79 @@ +// Classification invariants shared by BB's no-argument built-in commands. +// +// These cases moved here from the legacy Codex adapter suite +// (`plugins/provider-codex/src/adapter.test.ts`) when that adapter was +// deleted. The function is shared with the canonical Codex bridge, which uses +// it to route `/compact` to `thread/compact/start`; the server uses the same +// rules for `/clear`. Their routing decisions are covered at those boundaries, +// while these cases pin classification only. + +import { describe, expect, it } from "vitest"; + +import { + isStandaloneBuiltinClearCommand, + isStandaloneBuiltinCompactCommand, +} from "../src/shared-types.js"; +import type { PromptInput, PromptMentionCommandOrigin } from "../src/index.js"; + +function promptCommandInput( + name: string, + args?: { + origin?: PromptMentionCommandOrigin; + text?: string; + }, +): PromptInput { + const commandText = `/${name}`; + const text = args?.text ?? commandText; + const start = text.indexOf(commandText); + if (start === -1) { + throw new Error(`Missing ${commandText} command text in "${text}".`); + } + return { + type: "text", + text, + mentions: [ + { + start, + end: start + commandText.length, + resource: { + kind: "command", + trigger: "/", + name, + source: "command", + origin: args?.origin ?? "builtin", + label: name, + argumentHint: null, + }, + }, + ], + }; +} + +function promptTextInput(text: string): PromptInput { + return { type: "text", text, mentions: [] }; +} + +describe.each([ + ["compact", isStandaloneBuiltinCompactCommand], + ["clear", isStandaloneBuiltinClearCommand], +] as const)("isStandaloneBuiltin%sCommand", (name, classify) => { + it("classifies a standalone built-in mention", () => { + expect(classify([promptCommandInput(name)])).toBe(true); + }); + + it("does not classify raw command text", () => { + expect(classify([promptTextInput(`/${name}`)])).toBe(false); + }); + + it("does not classify user-origin commands", () => { + expect(classify([promptCommandInput(name, { origin: "user" })])).toBe( + false, + ); + }); + + it("does not classify mixed command input", () => { + expect( + classify([promptCommandInput(name, { text: `/${name} then summarize` })]), + ).toBe(false); + }); +}); diff --git a/packages/domain/test/standalone-builtin-compact-command.test.ts b/packages/domain/test/standalone-builtin-compact-command.test.ts deleted file mode 100644 index 8c47b82464..0000000000 --- a/packages/domain/test/standalone-builtin-compact-command.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -// Classification invariants for `isStandaloneBuiltinCompactCommand`. -// -// These cases moved here from the legacy Codex adapter suite -// (`plugins/provider-codex/src/adapter.test.ts`) when that adapter was -// deleted. The function is shared with the canonical Codex bridge, which uses -// it to route a standalone builtin `/compact` prompt to `thread/compact/start` -// instead of `turn/start`; that routing decision is covered by the codex bridge -// tests, while these cases pin classification only. - -import { describe, expect, it } from "vitest"; - -import { isStandaloneBuiltinCompactCommand } from "../src/shared-types.js"; -import type { PromptInput, PromptMentionCommandOrigin } from "../src/index.js"; - -function promptCompactCommandInput(args?: { - origin?: PromptMentionCommandOrigin; - text?: string; -}): PromptInput { - const text = args?.text ?? "/compact"; - const start = text.indexOf("/compact"); - if (start === -1) { - throw new Error(`Missing /compact command text in "${text}".`); - } - return { - type: "text", - text, - mentions: [ - { - start, - end: start + "/compact".length, - resource: { - kind: "command", - trigger: "/", - name: "compact", - source: "command", - origin: args?.origin ?? "builtin", - label: "compact", - argumentHint: null, - }, - }, - ], - }; -} - -function promptTextInput(text: string): PromptInput { - return { type: "text", text, mentions: [] }; -} - -describe("isStandaloneBuiltinCompactCommand", () => { - it("classifies a standalone builtin /compact mention as a compact command", () => { - expect( - isStandaloneBuiltinCompactCommand([promptCompactCommandInput()]), - ).toBe(true); - }); - - it("does not classify raw /compact text as a compact command", () => { - expect( - isStandaloneBuiltinCompactCommand([promptTextInput("/compact")]), - ).toBe(false); - }); - - it("does not classify user-origin compact commands as a compact command", () => { - expect( - isStandaloneBuiltinCompactCommand([ - promptCompactCommandInput({ origin: "user" }), - ]), - ).toBe(false); - }); - - it("does not classify mixed compact command input as a compact command", () => { - expect( - isStandaloneBuiltinCompactCommand([ - promptCompactCommandInput({ text: "/compact then summarize" }), - ]), - ).toBe(false); - }); -}); diff --git a/packages/plugin-api-map/sdk-public-api.json b/packages/plugin-api-map/sdk-public-api.json index a506cd20af..09382e5352 100644 --- a/packages/plugin-api-map/sdk-public-api.json +++ b/packages/plugin-api-map/sdk-public-api.json @@ -3,7 +3,7 @@ "entries": { ".": { "types": "bundled-types/bb-plugin-sdk.d.ts", - "sha256": "7994e5e3cc8a3f743d1098cb09334201ae359aac5148bd945fb1ac429908c80f" + "sha256": "f3237754c2e9e75975db5966a257cde7c37cf358058f539bed9eefa702c159b3" }, "./ai-services": { "types": "bundled-types/bb-plugin-sdk-ai-services.d.ts",