Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions apps/app/src/components/promptbox/PromptBoxInternal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3785,7 +3785,7 @@ 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 () => {
it("submits a built-in command selected with Enter", async () => {
const { changes, onSubmit, promptBoxRef } =
renderCommandPromptBox(compactSuggestion);
await openCommandMenu(promptBoxRef, "/compact", "compact");
Expand All @@ -3797,8 +3797,7 @@ describe("PromptBoxInternal command typeahead submit", () => {

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.
// bare text.
expect(latestChange(changes)?.mentions).toEqual([
{
start: 0,
Expand Down
4 changes: 2 additions & 2 deletions apps/app/src/components/promptbox/PromptBoxInternal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 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
Expand Down Expand Up @@ -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 take no arguments, so picking
// one with Enter both inserts the pill and submits. Tab still only
// inserts, and mention suggestions are unaffected.
if (
Expand Down
12 changes: 12 additions & 0 deletions apps/cli/src/__tests__/command-output/thread-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
15 changes: 15 additions & 0 deletions apps/cli/src/commands/thread/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
8 changes: 8 additions & 0 deletions apps/server/src/routes/threads/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,9 @@ For review or fix pipelines, get the environment ID from
- `bb thread stop <id>` also releases an idle or stuck agent runtime. The
command is idempotent and preserves thread history.
- Use `bb thread compact <id>` 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 <id>` 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 <id>` to exit an active Plan turn without
optimistically clearing its banner. Use `bb thread clear-goal <id>` to clear
a Codex thread's durable active Goal. Both wait for provider confirmation.
Expand Down
11 changes: 10 additions & 1 deletion apps/server/src/services/threads/provider-command-typeahead.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ import type { ProviderRegistration } from "../providers/provider-registry.js";
import type { ResolvedSkillCatalogEntry } from "../skills/injected-skills.js";

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",
Expand Down Expand Up @@ -123,7 +130,9 @@ export function buildCommandListResponse(
): CommandListResponse {
return {
commands: dedupeBySourceAndName([
...(args.includeBuiltinCompact ? BUILT_IN_PROVIDER_COMMANDS : []),
...BUILT_IN_PROVIDER_COMMANDS.filter(
(command) => command.name !== "compact" || args.includeBuiltinCompact,
),
...args.skillCatalog.map(toSkillCommand),
...args.commands.map(toProviderCommand),
]).sort(compareCommands),
Expand Down
71 changes: 71 additions & 0 deletions apps/server/src/services/threads/thread-context-clear.ts
Original file line number Diff line number Diff line change
@@ -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<Environment, "hostId" | "id">;
thread: Thread;
},
): Promise<void> {
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.",
},
});
});
}
44 changes: 44 additions & 0 deletions apps/server/src/services/threads/thread-context-mutation-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { ApiError } from "../../errors.js";

// Positive values count overlapping sends; -1 is the exclusive clear owner.
const inFlightByThreadId = new Map<string, number>();

async function withThreadContextMutationGuard<T>(
threadId: string,
mode: "clear" | "send",
work: () => Promise<T>,
): Promise<T> {
const inFlight = inFlightByThreadId.get(threadId) ?? 0;
if (inFlight !== 0 && (mode === "clear" || inFlight < 0)) {
throw new ApiError(
409,
"invalid_request",
mode === "send"
? "Thread context is being cleared"
: "Thread is processing another request",
);
}
inFlightByThreadId.set(threadId, mode === "clear" ? -1 : inFlight + 1);
try {
return await work();
} finally {
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<T>(
threadId: string,
work: () => Promise<T>,
): Promise<T> {
return withThreadContextMutationGuard(threadId, "send", work);
}

export async function withThreadContextClearGuard<T>(
threadId: string,
work: () => Promise<T>,
): Promise<T> {
return withThreadContextMutationGuard(threadId, "clear", work);
}
5 changes: 4 additions & 1 deletion apps/server/src/services/threads/thread-send-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -66,7 +66,9 @@ export async function acceptThreadSendRequest(
args: AcceptThreadSendRequestArgs,
): Promise<SendMessageResponse> {
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)));
Expand All @@ -78,6 +80,7 @@ export async function acceptThreadSendRequest(
return { ok: true, delivery: "queued" };
}
if (
!isContextClear &&
payload.mode !== "start" &&
deps.pendingInteractions.hasPendingThreadInteraction(thread.id)
) {
Expand Down
19 changes: 19 additions & 0 deletions apps/server/src/services/threads/thread-send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -396,6 +399,22 @@ function appendAndQueueSendThreadMessageInTransaction({
export async function sendThreadMessage(
deps: LoggedPendingInteractionWorkSessionDeps,
args: SendThreadMessageArgs,
): Promise<void> {
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<void> {
const { environment, payload, thread } = args;
ensureThreadIsWritable(thread);
Expand Down
Loading
Loading