Skip to content
Merged
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
1 change: 1 addition & 0 deletions apps/app/src/components/plugin/PluginNewThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,7 @@ export function PluginNewThreadComposer({
const commandSuggestions = useCommandSuggestions({
projectId,
providerId: selectedProviderId,
commandScope: "new-thread",
skillsTrigger: providerPromptActions.skillsTrigger,
promptActions,
environmentId: reuseEnvironmentId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,7 @@ function EmbeddedThreadChatWithComposer({
projectId,
providerId,
environmentId: promptContextEnvironmentId,
commandScope: threadId === null ? "new-thread" : "thread",
currentThreadId: threadId ?? composer.executionDefaultsThreadId,
selectedProviderComposerActions,
resolveMentionLink,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ interface UseComposerTypeaheadArgs {
mentionsProjectId?: string;
providerId: string;
environmentId: string | null;
/** Composer surface used to exclude commands that require an existing thread. */
commandScope: "new-thread" | "thread";
/** The thread the composer belongs to (excluded from thread mentions). */
currentThreadId: string;
selectedProviderComposerActions:
Expand All @@ -37,6 +39,7 @@ export function useComposerTypeahead({
mentionsProjectId,
providerId,
environmentId,
commandScope,
currentThreadId,
selectedProviderComposerActions,
resolveMentionLink,
Expand All @@ -57,6 +60,7 @@ export function useComposerTypeahead({
const commandSuggestions = useCommandSuggestions({
projectId,
providerId,
commandScope,
skillsTrigger: providerPromptActions.skillsTrigger,
promptActions,
environmentId,
Expand Down
13 changes: 12 additions & 1 deletion apps/app/src/hooks/useCommandSuggestions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { useProjectCommands } from "./queries/project-queries";
export interface UseCommandSuggestionsArgs {
projectId: string | undefined;
providerId: string | undefined;
/** Composer surface used to exclude commands that require an existing thread. */
commandScope: "new-thread" | "thread";
skillsTrigger: PromptMentionCommandTrigger | null;
promptActions?: readonly CommandSuggestionPromptAction[];
/**
Expand Down Expand Up @@ -203,14 +205,23 @@ export function useCommandSuggestions(
return [];
}
const discoveredSuggestions = filterCommandSuggestions(
(commandsQuery.data?.commands ?? []).map(toProviderCommandSuggestion),
(commandsQuery.data?.commands ?? [])
.map(toProviderCommandSuggestion)
.filter(
(suggestion) =>
args.commandScope === "thread" ||
suggestion.source !== "command" ||
suggestion.origin !== "builtin" ||
suggestion.name !== "compact",
),
trimmedQuery,
);
return orderCommandSuggestionsBySection(
mergeCommandSuggestions(promptActionSuggestions, discoveredSuggestions),
);
}, [
commandsQuery.data?.commands,
args.commandScope,
isActive,
promptActionSuggestions,
trimmedQuery,
Expand Down
1 change: 1 addition & 0 deletions apps/app/src/views/RootComposeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1909,6 +1909,7 @@ export function RootComposeView() {
const commandSuggestions = useCommandSuggestions({
projectId,
providerId: selectedProviderId,
commandScope: "new-thread",
skillsTrigger: providerPromptActions.skillsTrigger,
promptActions: providerPromptActionProps.promptActions,
environmentId: reuseEnvironmentId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,7 @@ export function ThreadDetailPromptArea({
mentionsProjectId: projectId,
providerId: thread.providerId,
environmentId: thread.environmentId,
commandScope: "thread",
currentThreadId: thread.id,
selectedProviderComposerActions,
resolveMentionLink,
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 @@ -400,6 +400,18 @@ describe("bb thread action command output", () => {
expect(stopPost).toHaveBeenCalledTimes(1);
});

it("bb thread compact calls the manual compaction endpoint", async () => {
const post = vi.fn(async () => ({ ok: true }));
stubServerApi({ "v1.threads.:id.compact.$post": post });

await runCommand(["thread", "compact", "thread-compact"], register);

expect(post).toHaveBeenCalledWith({ param: { id: "thread-compact" } });
expect(collectLogLines(vi.mocked(console.log))).toContain(
"Thread thread-compact context compaction requested",
);
});

it.each([
["cancel-plan", "plan.cancel", "exited Plan mode"],
["clear-goal", "goal.clear", "cleared its Goal"],
Expand Down
61 changes: 32 additions & 29 deletions apps/cli/src/commands/thread/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,17 +72,15 @@ interface ThreadTellCommandOptions {
image?: string[];
}

interface ThreadStopCommandOptions {
interface ThreadActionOptions {
self?: boolean;
json?: boolean;
}

interface ThreadRetryCommandOptions extends ThreadStopCommandOptions {
interface ThreadRetryCommandOptions extends ThreadActionOptions {
requestId?: string;
}

type ThreadBannerActionCommandOptions = ThreadStopCommandOptions;

type ThreadTellDeliveryMode = "auto" | "queue" | "steer";

interface PostThreadMessageArgs {
Expand Down Expand Up @@ -447,7 +445,7 @@ export function registerActionsCommands(
.option("--self", "Target the current thread (from BB_THREAD_ID)")
.option("--json", "Print machine-readable JSON output")
.action(
action(async (id: string | undefined, opts: ThreadStopCommandOptions) => {
action(async (id: string | undefined, opts: ThreadActionOptions) => {
const threadId = requireThreadIdOrSelf(id, opts);
const sdk = createCliBbSdk(getUrl());
await sdk.threads.stop({ threadId });
Expand All @@ -456,24 +454,34 @@ export function registerActionsCommands(
}),
);

parent
.command("compact [id]")
.description("Request compaction of an idle or errored thread's context")
.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.compact({ threadId });
if (outputJson(opts, { ok: true, threadId })) return;
console.log(`Thread ${threadId} context compaction requested`);
}),
);

parent
.command("cancel-plan [id]")
.description("Ask the provider to exit the active Plan mode")
.option("--self", "Target the current thread (from BB_THREAD_ID)")
.option("--json", "Print machine-readable JSON output")
.action(
action(
async (
id: string | undefined,
opts: ThreadBannerActionCommandOptions,
) => {
const threadId = requireThreadIdOrSelf(id, opts);
const sdk = createCliBbSdk(getUrl());
await sdk.threads.cancelPlan({ threadId });
if (outputJson(opts, { ok: true, threadId })) return;
console.log(`Thread ${threadId} exited Plan mode`);
},
),
action(async (id: string | undefined, opts: ThreadActionOptions) => {
const threadId = requireThreadIdOrSelf(id, opts);
const sdk = createCliBbSdk(getUrl());
await sdk.threads.cancelPlan({ threadId });
if (outputJson(opts, { ok: true, threadId })) return;
console.log(`Thread ${threadId} exited Plan mode`);
}),
);

parent
Expand All @@ -482,18 +490,13 @@ export function registerActionsCommands(
.option("--self", "Target the current thread (from BB_THREAD_ID)")
.option("--json", "Print machine-readable JSON output")
.action(
action(
async (
id: string | undefined,
opts: ThreadBannerActionCommandOptions,
) => {
const threadId = requireThreadIdOrSelf(id, opts);
const sdk = createCliBbSdk(getUrl());
await sdk.threads.clearGoal({ threadId });
if (outputJson(opts, { ok: true, threadId })) return;
console.log(`Thread ${threadId} cleared its Goal`);
},
),
action(async (id: string | undefined, opts: ThreadActionOptions) => {
const threadId = requireThreadIdOrSelf(id, opts);
const sdk = createCliBbSdk(getUrl());
await sdk.threads.clearGoal({ threadId });
if (outputJson(opts, { ok: true, threadId })) return;
console.log(`Thread ${threadId} cleared its Goal`);
}),
);
}

Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/routes/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
type PublicApiSchema,
} from "@bb/server-contract";
import type { Hono } from "hono";
import { supportsManualCompaction } from "@bb/agent-providers";
import type { AppDeps } from "../types.js";
import { COMMAND_TIMEOUT_MS } from "../constants.js";
import { ApiError } from "../errors.js";
Expand Down Expand Up @@ -732,6 +733,7 @@ export function registerProjectRoutes(app: Hono, deps: AppDeps): void {
return context.json(
buildCommandListResponse({
commands: result.commands,
includeBuiltinCompact: supportsManualCompaction(query.provider),
skillCatalog,
}),
);
Expand Down
56 changes: 53 additions & 3 deletions apps/server/src/routes/threads/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,12 @@ import {
type SendMessageRequest,
} from "@bb/server-contract";
import type { Hono } from "hono";
import type { Thread, ThreadQueuedMessage } from "@bb/domain";
import {
createStandaloneBuiltinCompactCommandInput,
type Thread,
type ThreadQueuedMessage,
} from "@bb/domain";
import { supportsManualCompaction } from "@bb/agent-providers";
import type { AppDeps } from "../../types.js";
import { ApiError } from "../../errors.js";
import { toThreadQueuedMessage } from "../../services/threads/thread-queued-messages.js";
Expand Down Expand Up @@ -53,7 +58,10 @@ import {
dispatchThreadUnarchiveCommand,
prepareTurnSubmitCommandPayload,
} from "../../services/threads/thread-commands.js";
import { getLastProviderThreadId } from "../../services/threads/thread-events.js";
import {
getLastProviderThreadId,
isManualCompactionActive,
} from "../../services/threads/thread-events.js";
import { requestThreadStopForCurrentState } from "../../services/threads/thread-lifecycle.js";
import {
getThreadPromptBannerActivity,
Expand Down Expand Up @@ -119,6 +127,38 @@ function toQueuedMessageOrderResponse(
}
}

async function compactThreadContext(
deps: AppDeps,
thread: Thread,
): Promise<void> {
ensureThreadIsWritable(thread);
if (!supportsManualCompaction(thread.providerId)) {
throw new ApiError(
409,
"invalid_request",
`Provider "${thread.providerId}" does not support manual context compaction`,
);
}
if (thread.status !== "idle" && thread.status !== "error") {
throw new ApiError(
409,
"invalid_request",
"Context can only be compacted while the thread is idle or errored",
);
}

const environment = await requireThreadCommandEnvironment(deps, { thread });
await sendThreadMessage(deps, {
environment,
payload: {
input: createStandaloneBuiltinCompactCommandInput(),
mode: "start",
},
thread,
trigger: "user",
});
}

function toQueuedMessageGroupBoundaryResponse(
result: SetQueuedThreadMessageGroupBoundaryResult,
): ThreadQueuedMessage[] {
Expand Down Expand Up @@ -278,7 +318,11 @@ export function registerThreadActionRoutes(app: Hono, deps: AppDeps): void {

post(routes.send, async (context, payload) => {
const thread = requirePublicThread(deps.db, context.req.param("id"));
if (payload.mode === "queue-if-active" && thread.status === "active") {
const shouldQueue =
thread.status === "active" &&
(payload.mode === "queue-if-active" ||
(payload.mode !== "start" && isManualCompactionActive(deps, thread)));
if (shouldQueue) {
ensureThreadIsNotAwaitingUserInteraction(deps, thread.id);
await createQueuedMessageForThread(deps, {
payload: queuedMessagePayloadFromSendRequest(payload),
Expand Down Expand Up @@ -444,6 +488,12 @@ export function registerThreadActionRoutes(app: Hono, deps: AppDeps): void {
return context.json({ ok: true });
});

post(routes.compact, async (context) => {
const thread = requirePublicThread(deps.db, context.req.param("id"));
await compactThreadContext(deps, 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 @@ -304,7 +304,8 @@ environment pull-request show <id>`. Diff commands require an explicit target
relative paths resolve from the bb data dir. Custom ACP agents can use
`modelCli` for CLI model listing/selection, `reasoningCli` for launch-time
reasoning flags, and `nativeReasoning` for ACP `session/set_config_option`
reasoning. Optional `nativeSkillRoots.user` paths resolve from the target
reasoning. Optional
`nativeSkillRoots.user` paths resolve from the target
host home directory. Optional `nativeSkillRoots.project` paths resolve from
the selected workspace. The composer lists skills from these roots.
- Top-level `customModels` in the same `config.json` registers extra picker
Expand Down Expand Up @@ -436,6 +437,7 @@ For review or fix pipelines, get the environment ID from
- For interrupted or stopped threads, inspect first. If the user stopped the
thread, treat that as intentional unless they ask you to continue.
- Use `bb thread stop <id>` when a thread is stuck or no longer needed.
- 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 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
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ signatures.

| Area | Methods |
| --- | --- |
| `threads` | `list` `get` `search` `spawn` `fork` `send` `update` `delete` `stop` `wait` `open` `output` `timeline` `conversationOutline` `promptHistory` `archive` `archiveAll` `unarchive` `pin` `unpin` `reorderPinned` `markRead` `markUnread` `childSummary` `paneAction` `timelineTurnSummaryDetails` `storageFiles` `storagePaths` `cancelPlan` `clearGoal` `continueAfterRateLimit` `rateLimitRecovery` `defaultExecutionOptions`; sub-areas `events` (`list` `wait`), `interactions` (`get` `list` `cancel` `resolve` `respond`), `queuedMessages` (`create` `list` `update` `delete` `send` `reorder` `setGroupBoundary`), `tabs` (`get` `update`) |
| `threads` | `list` `get` `search` `spawn` `fork` `send` `update` `delete` `stop` `compact` `wait` `open` `output` `timeline` `conversationOutline` `promptHistory` `archive` `archiveAll` `unarchive` `pin` `unpin` `reorderPinned` `markRead` `markUnread` `childSummary` `paneAction` `timelineTurnSummaryDetails` `storageFiles` `storagePaths` `cancelPlan` `clearGoal` `continueAfterRateLimit` `rateLimitRecovery` `defaultExecutionOptions`; sub-areas `events` (`list` `wait`), `interactions` (`get` `list` `cancel` `resolve` `respond`), `queuedMessages` (`create` `list` `update` `delete` `send` `reorder` `setGroupBoundary`), `tabs` (`get` `update`) |
| `threadSections` | `list` `create` `update` `delete` |
| `projects` | `list` `get` `create` `update` `delete` `reorder` `paths` `files` `fileContent` `branches` `commands` `defaultExecutionOptions` `promptHistory`; sub-areas `attachments` (`upload` `read` `copy`), `sources` (`add` `update` `delete`) |
| `environments` | `get` `update` `status` `paths` `commit` `archiveThreads` `diff` `diffFile` `diffFiles` `diffBranches` `diffPatch` `pullRequest` `markPullRequestDraft` `markPullRequestReady` `mergePullRequest` `squashMerge` |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ function compareCommands(a: ProviderCommand, b: ProviderCommand): number {

export interface BuildCommandListResponseArgs {
commands: HostProviderCommand[];
includeBuiltinCompact: boolean;
skillCatalog: readonly ResolvedSkillCatalogEntry[];
}

Expand All @@ -138,7 +139,7 @@ export function buildCommandListResponse(
): CommandListResponse {
return {
commands: dedupeBySourceAndName([
...BUILT_IN_PROVIDER_COMMANDS,
...(args.includeBuiltinCompact ? BUILT_IN_PROVIDER_COMMANDS : []),
...args.skillCatalog.map(toSkillCommand),
...args.commands.map(toProviderCommand),
]).sort(compareCommands),
Expand Down
Loading
Loading