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
38 changes: 32 additions & 6 deletions apps/server/src/services/plugins/plugin-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,14 @@ function releaseMutableRoots(rootUrls: Iterable<string>): void {
mutableRootHooks = null;
}

/** Which build target a dev build problem belongs to. */
export type PluginDevBuildKind = "frontend" | "host";

const DEV_BUILD_PROBLEM_LABELS: Record<PluginDevBuildKind, string> = {
frontend: "frontend bundle build failed",
host: "host bundle build failed",
};

const DEFAULT_LOAD_TIMEOUT_MS = 30_000;
const DEFAULT_SERVICE_STOP_TIMEOUT_MS = 5_000;
const DEFAULT_SERVICE_RESTART_BASE_MS = 1_000;
Expand Down Expand Up @@ -402,7 +410,10 @@ export function createPluginRuntime(context: PluginRuntimeContext) {
string,
{ status: PluginRuntimeStatus; detail: string | null }
>();
const devBuildProblems = new Map<string, string>();
const devBuildProblems = new Map<
string,
Partial<Record<PluginDevBuildKind, string>>
>();
const statusListeners = new Map<
string,
Set<(status: PluginRuntimeStatus, detail: string | null) => void>
Expand Down Expand Up @@ -467,19 +478,30 @@ export function createPluginRuntime(context: PluginRuntimeContext) {
detail: string | null = null,
): void {
baseStatuses.set(id, { status, detail });
const buildProblem = devBuildProblems.get(id);
const buildProblems = devBuildProblems.get(id);
publishStatus(
id,
status,
[detail, buildProblem]
[detail, buildProblems?.frontend, buildProblems?.host]
.filter((part): part is string => part !== null && part !== undefined)
.join("; ") || null,
);
}

function setDevBuildProblem(id: string, message: string | null): void {
if (message === null) devBuildProblems.delete(id);
else devBuildProblems.set(id, `frontend bundle build failed: ${message}`);
function setDevBuildProblem(
id: string,
kind: PluginDevBuildKind,
message: string | null,
): void {
const problems = devBuildProblems.get(id) ?? {};
if (message === null) {
if (problems[kind] === undefined) return;
delete problems[kind];
} else {
problems[kind] = `${DEV_BUILD_PROBLEM_LABELS[kind]}: ${message}`;
}
if (Object.keys(problems).length === 0) devBuildProblems.delete(id);
else devBuildProblems.set(id, problems);
const base = baseStatuses.get(id);
if (base !== undefined) setStatus(id, base.status, base.detail);
}
Expand Down Expand Up @@ -1072,6 +1094,7 @@ export function createPluginRuntime(context: PluginRuntimeContext) {
deps.appVersion,
await getPluginBuildToolchain(deps),
);
setDevBuildProblem(row.id, "frontend", null);
} catch (error) {
const message =
error instanceof Error ? error.message : String(error);
Expand Down Expand Up @@ -1106,6 +1129,9 @@ export function createPluginRuntime(context: PluginRuntimeContext) {
deps.appVersion,
await getPluginBuildToolchain(deps),
);
// A successful rebuild through the load path (enable/reload) must clear
// a stale dev-loop failure, or it sticks until the next source change.
setDevBuildProblem(row.id, "host", null);
}
const jsPath = join(row.rootDir, "dist", "host.js");
const metaPath = join(row.rootDir, "dist", "host.meta.json");
Expand Down
6 changes: 4 additions & 2 deletions apps/server/src/services/plugins/plugin-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1503,11 +1503,12 @@ export function createPluginService(deps: PluginServiceDeps): PluginService {
deps.appVersion,
await getPluginBuildToolchain(deps),
);
setDevBuildProblem(row.id, null);
setDevBuildProblem(row.id, "frontend", null);
notifyPluginsChanged();
} catch (error) {
setDevBuildProblem(
row.id,
"frontend",
error instanceof Error ? error.message : String(error),
);
notifyPluginsChanged();
Expand All @@ -1521,11 +1522,12 @@ export function createPluginService(deps: PluginServiceDeps): PluginService {
deps.appVersion,
await getPluginBuildToolchain(deps),
);
setDevBuildProblem(row.id, null);
setDevBuildProblem(row.id, "host", null);
notifyPluginsChanged();
} catch (error) {
setDevBuildProblem(
row.id,
"host",
error instanceof Error ? error.message : String(error),
);
notifyPluginsChanged();
Expand Down
5 changes: 4 additions & 1 deletion apps/server/src/services/threads/thread-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,10 @@ function resolveProviderWorkflowsEnabled(
if (!resolveWorkflowsEnabledPolicy(deps.providerRegistry, providerId)) {
return false;
}
return !getAppSettings(deps.db).claudeCodeWorkflowsDisabled;
if (providerId === "claude-code") {
return !getAppSettings(deps.db).claudeCodeWorkflowsDisabled;
}
return true;
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { createConnection, migrate } from "@bb/db";
import type { Logger } from "@bb/logger";
import { createPluginRuntime } from "../../../src/services/plugins/plugin-runtime.js";
import { testLogger } from "../../helpers/test-app.js";

async function createRuntime() {
const db = createConnection(":memory:");
migrate(db);
return createPluginRuntime({
deps: {
db,
hub: {
getDaemonSessionIdForHost: () => null,
notifyPluginSignal: () => 0,
notifySystem: () => {},
},
logger: testLogger as unknown as Logger,
dataDir: await mkdtemp(join(tmpdir(), "bb-dev-build-problems-")),
appVersion: "0.9.0",
},
nextCronRunAt: () => Number.MAX_SAFE_INTEGER,
settledWithin: async () => true,
});
}

describe("plugin dev build problems", () => {
it("labels problems by build target and clears each target independently", async () => {
const runtime = await createRuntime();
runtime.setStatus("demo", "running", null);

runtime.setDevBuildProblem("demo", "host", "boom in host entry");
expect(runtime.statuses.get("demo")?.detail).toBe(
"host bundle build failed: boom in host entry",
);

runtime.setDevBuildProblem("demo", "frontend", "boom in app entry");
expect(runtime.statuses.get("demo")?.detail).toBe(
"frontend bundle build failed: boom in app entry; host bundle build failed: boom in host entry",
);

// A successful frontend build must not clear the host failure.
runtime.setDevBuildProblem("demo", "frontend", null);
expect(runtime.statuses.get("demo")?.detail).toBe(
"host bundle build failed: boom in host entry",
);

// A successful host build clears the last problem entirely.
runtime.setDevBuildProblem("demo", "host", null);
expect(runtime.statuses.get("demo")?.detail).toBeNull();
expect(runtime.statuses.get("demo")?.status).toBe("running");
});

it("keeps build problems appended to later status updates until cleared", async () => {
const runtime = await createRuntime();
runtime.setStatus("demo", "running", null);
runtime.setDevBuildProblem("demo", "host", "boom");

runtime.setStatus("demo", "degraded", "service crashed");
expect(runtime.statuses.get("demo")?.detail).toBe(
"service crashed; host bundle build failed: boom",
);

runtime.setDevBuildProblem("demo", "host", null);
expect(runtime.statuses.get("demo")?.detail).toBe("service crashed");
});
});
60 changes: 60 additions & 0 deletions apps/server/test/threads/thread-runtime-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
registerHostRpcResponder,
type HostRpcResponder,
} from "../helpers/host-rpc.js";
import { registerFakeProviders } from "../helpers/provider-registry.js";
import type { TestAppHarness } from "../helpers/test-app.js";
import { textInput } from "../helpers/prompt-input.js";
import { withTestHarness } from "../helpers/test-app.js";
Expand Down Expand Up @@ -980,6 +981,65 @@ describe("thread runtime config", () => {
});
});

it("scopes the Claude workflows toggle to claude-code only", async () => {
await withTestHarness(async (harness) => {
// A non-Claude provider that declares `supportsWorkflows: true`, like
// the first third-party workflow provider would.
registerFakeProviders(
harness.deps.providerRegistry,
harness.deps.pluginHostArtifacts,
);
setAppSettings(harness.db, {
...defaultAppSettings,
claudeCodeWorkflowsDisabled: true,
});
const { host } = seedHostSession(harness.deps, {
id: "host-provider-workflows-scope",
});
const { project } = seedProjectWithSource(harness.deps, {
hostId: host.id,
});
const environment = seedEnvironment(harness.deps, {
hostId: host.id,
projectId: project.id,
});

async function build(providerId: "fake" | "claude-code", model: string) {
const thread = seedThread(harness.deps, {
projectId: project.id,
environmentId: environment.id,
providerId,
});
return buildThreadStartCommand(harness.deps, {
environment,
execution: {
model,
permissionMode: "auto",
reasoningLevel: "medium",
serviceTier: "default",
source: "client/turn/requested",
},
fork: null,
permissionEscalation: "ask",
input: textInput("hello"),
projectId: project.id,
providerId,
requestId: encodeClientTurnRequestIdNumber({ value: 1 }),
syncGeneratedTitle: false,
thread,
});
}

// Claude Code honors the Claude-named toggle.
const claudeCode = await build("claude-code", "claude-sonnet-4-6");
expect(claudeCode.options.workflowsEnabled).toBe(false);

// Another workflows-capable provider is unaffected by it.
const fake = await build("fake", "fake-model");
expect(fake.options.workflowsEnabled).toBe(true);
});
});

it("sets Claude Code native plan mode when the prompt starts from a plan command pill", async () => {
await withTestHarness(async (harness) => {
const { host } = seedHostSession(harness.deps, {
Expand Down
31 changes: 17 additions & 14 deletions docs/api_to_audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,20 +250,23 @@ build inlines the SDK's published, self-contained bundle.
domain. Decide, before third parties depend on the shapes, whether the
protocol should own a narrower event vocabulary of its own that `@bb/domain`
then derives from, or whether this facade is the permanent answer.
2. **Surface size.** ~190 names is a large promise. Several are there for one
first-party bridge only (the Claude mock-CLI traffic config, the ACP
reasoning/permission CLI schemas, the workflow snapshot types). Each is a
candidate to move into its own plugin instead of being promised to everyone.
The root cause is upstream of the SDK: the wave-5 move relocated
`agent-runtime/src/shared/` byte-identically, and `bridge-kit/adapter-utils.ts`
is a self-described grab-bag of "functions and constants duplicated across
the claude-code, pi, and codex adapters" whose single-consumer passengers
moved with it — `claudeCodeMockCliTrafficConfigSchema`,
`claudeTaskToolNameSchema`, `claudeTaskToolOutputSchema` (claude-code plugin
only) and `acpNativeReasoningSchema` (acp plugin only). Repatriate those to
their owning plugins and shrink `adapter-utils` to what two-plus bridges
actually share; the kit barrel is `export *`, so trimming the kit trims the
published surface (and this audit) with it.
2. **Surface size.** ~190 names is a large promise. Single-consumer
repatriation done (Aug 2026): `extractEnvOverrides` and
`getMessageContentTypes` moved into the claude-code plugin,
`normalizePendingInteractionRequestedPermissionProfile` (whole
`pending-interaction-normalization` module plus test) into the codex
plugin, and the `cloneReasoningEfforts` helper out of `@bb/domain` into
claude-code's model catalog. The other named candidates turned out not to
be movable: they are `@bb/domain`/protocol definitions with core consumers
— `claudeCodeMockCliTrafficConfigSchema` is the source of the
core-consumed `ClaudeCodeMockCliTrafficConfig`/default (agent-runtime,
server), the `claudeTaskTool*` schemas share their contract file with
thread-view, the `acp*Cli`/`acpNativeReasoning` schemas are parsed by
host-daemon-contract and config, and the workflow snapshot types are
rendered by the app. `buildEditDiff`, `completeStartedToolItem`, and
`decodeToolCallResponsePayload` are used inside the kit itself. The
surface is still large; any further shrink is a per-name product decision,
not a mechanical move.
3. **The ACP launch spec.** `hostDaemonAcpLaunchSpecSchema` is a
server↔daemon wire shape a bridge parses out of its provider-scoped static
options. It is the one core contract leaking into the published surface;
Expand Down
8 changes: 0 additions & 8 deletions packages/domain/src/reasoning-efforts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,3 @@ export function reasoningEffortsForLevels(
): ModelReasoningEffort[] {
return levels.map((level) => ({ ...REASONING_EFFORT_BY_LEVEL[level] }));
}

// Defensive copy so callers can hand out reasoning efforts in mutable API
// responses without aliasing the module-level constants above.
export function cloneReasoningEfforts(
efforts: readonly ModelReasoningEffort[],
): ModelReasoningEffort[] {
return efforts.map((effort) => ({ ...effort }));
}
Loading
Loading