fix: centralize verbose overrides and tool stream gating

This commit is contained in:
Peter Steinberger
2026-01-10 00:52:11 +01:00
parent 9a8d3aed26
commit 097550c299
15 changed files with 203 additions and 127 deletions

View File

@@ -11,6 +11,7 @@
- Node bridge: harden keepalive + heartbeat handling (TCP keepalive, better disconnects, and keepalive config tests). (#577) — thanks @steipete - Node bridge: harden keepalive + heartbeat handling (TCP keepalive, better disconnects, and keepalive config tests). (#577) — thanks @steipete
- Control UI: improve mobile responsiveness. (#558) — thanks @carlulsoe - Control UI: improve mobile responsiveness. (#558) — thanks @carlulsoe
- Control UI: persist per-session verbose off and hide tool cards unless verbose is on. (#262) — thanks @steipete - Control UI: persist per-session verbose off and hide tool cards unless verbose is on. (#262) — thanks @steipete
- Gateway: centralize verbose overrides and gate tool stream events at the server. (#262) — thanks @steipete
- CLI: add `sandbox list` and `sandbox recreate` commands for managing Docker sandbox containers after image/config updates. (#563) — thanks @pasogott - CLI: add `sandbox list` and `sandbox recreate` commands for managing Docker sandbox containers after image/config updates. (#563) — thanks @pasogott
- Sandbox: allow `session_status` tool in sandboxed sessions by default. — thanks @steipete - Sandbox: allow `session_status` tool in sandboxed sessions by default. — thanks @steipete
- CLI: add `clawdbot config --section <name>` to jump straight into a wizard section (repeatable). - CLI: add `clawdbot config --section <name>` to jump straight into a wizard section (repeatable).

View File

@@ -32,6 +32,7 @@ read_when:
## Verbose directives (/verbose or /v) ## Verbose directives (/verbose or /v)
- Levels: `on|full` or `off` (default). - Levels: `on|full` or `off` (default).
- Directive-only message toggles session verbose and replies `Verbose logging enabled.` / `Verbose logging disabled.`; invalid levels return a hint without changing state. - Directive-only message toggles session verbose and replies `Verbose logging enabled.` / `Verbose logging disabled.`; invalid levels return a hint without changing state.
- `/verbose off` stores an explicit session override; clear it via the Sessions UI by choosing `inherit`.
- Inline directive affects only that message; session/global defaults apply otherwise. - Inline directive affects only that message; session/global defaults apply otherwise.
- Send `/verbose` (or `/verbose:`) with no argument to see the current verbose level. - Send `/verbose` (or `/verbose:`) with no argument to see the current verbose level.
- When verbose is on, agents that emit structured tool results (Pi, other JSON agents) send each tool result back as its own metadata-only message, prefixed with `<emoji> <tool-name>: <arg>` when available (path/command); the tool output itself is not forwarded. These tool summaries are sent as soon as each tool finishes (separate bubbles), not as streaming deltas. If you toggle `/verbose on|off` while a run is in-flight, subsequent tool bubbles honor the new setting. - When verbose is on, agents that emit structured tool results (Pi, other JSON agents) send each tool result back as its own metadata-only message, prefixed with `<emoji> <tool-name>: <arg>` when available (path/command); the tool output itself is not forwarded. These tool summaries are sent as soon as each tool finishes (separate bubbles), not as streaming deltas. If you toggle `/verbose on|off` while a run is in-flight, subsequent tool bubbles honor the new setting.

View File

@@ -1,13 +1,8 @@
import type { AssistantMessage } from "@mariozechner/pi-ai"; import type { AssistantMessage } from "@mariozechner/pi-ai";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { emitAgentEvent } from "../infra/agent-events.js";
import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js";
vi.mock("../infra/agent-events.js", () => ({
emitAgentEvent: vi.fn(),
}));
type StubSession = { type StubSession = {
subscribe: (fn: (evt: unknown) => void) => () => void; subscribe: (fn: (evt: unknown) => void) => () => void;
}; };
@@ -15,8 +10,6 @@ type StubSession = {
type SessionEventHandler = (evt: unknown) => void; type SessionEventHandler = (evt: unknown) => void;
describe("subscribeEmbeddedPiSession", () => { describe("subscribeEmbeddedPiSession", () => {
const emitAgentEventMock = vi.mocked(emitAgentEvent);
it("filters to <final> and falls back when tags are malformed", () => { it("filters to <final> and falls back when tags are malformed", () => {
let handler: ((evt: unknown) => void) | undefined; let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = { const session: StubSession = {
@@ -1474,48 +1467,6 @@ describe("subscribeEmbeddedPiSession", () => {
expect(onToolResult).not.toHaveBeenCalled(); expect(onToolResult).not.toHaveBeenCalled();
}); });
it("skips tool stream events when tool verbose is off", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
emitAgentEventMock.mockReset();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<
typeof subscribeEmbeddedPiSession
>[0]["session"],
runId: "run-tool-events-off",
shouldEmitToolResult: () => false,
});
handler?.({
type: "tool_execution_start",
toolName: "read",
toolCallId: "tool-evt-1",
args: { path: "/tmp/off.txt" },
});
handler?.({
type: "tool_execution_update",
toolName: "read",
toolCallId: "tool-evt-1",
partialResult: "partial",
});
handler?.({
type: "tool_execution_end",
toolName: "read",
toolCallId: "tool-evt-1",
isError: false,
result: "ok",
});
expect(emitAgentEventMock).not.toHaveBeenCalled();
});
it("emits tool summaries when shouldEmitToolResult overrides verbose", () => { it("emits tool summaries when shouldEmitToolResult overrides verbose", () => {
let handler: ((evt: unknown) => void) | undefined; let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = { const session: StubSession = {

View File

@@ -368,7 +368,6 @@ export function subscribeEmbeddedPiSession(params: {
const toolMetas: Array<{ toolName?: string; meta?: string }> = []; const toolMetas: Array<{ toolName?: string; meta?: string }> = [];
const toolMetaById = new Map<string, string | undefined>(); const toolMetaById = new Map<string, string | undefined>();
const toolSummaryById = new Set<string>(); const toolSummaryById = new Set<string>();
const toolEventById = new Set<string>();
const blockReplyBreak = params.blockReplyBreak ?? "text_end"; const blockReplyBreak = params.blockReplyBreak ?? "text_end";
const reasoningMode = params.reasoningMode ?? "off"; const reasoningMode = params.reasoningMode ?? "off";
const includeReasoning = reasoningMode === "on"; const includeReasoning = reasoningMode === "on";
@@ -590,7 +589,6 @@ export function subscribeEmbeddedPiSession(params: {
toolMetas.length = 0; toolMetas.length = 0;
toolMetaById.clear(); toolMetaById.clear();
toolSummaryById.clear(); toolSummaryById.clear();
toolEventById.clear();
messagingToolSentTexts.length = 0; messagingToolSentTexts.length = 0;
messagingToolSentTargets.length = 0; messagingToolSentTargets.length = 0;
pendingMessagingTexts.clear(); pendingMessagingTexts.clear();
@@ -642,19 +640,16 @@ export function subscribeEmbeddedPiSession(params: {
); );
const shouldEmitToolEvents = shouldEmitToolResult(); const shouldEmitToolEvents = shouldEmitToolResult();
if (shouldEmitToolEvents) { emitAgentEvent({
toolEventById.add(toolCallId); runId: params.runId,
emitAgentEvent({ stream: "tool",
runId: params.runId, data: {
stream: "tool", phase: "start",
data: { name: toolName,
phase: "start", toolCallId,
name: toolName, args: args as Record<string, unknown>,
toolCallId, },
args: args as Record<string, unknown>, });
},
});
}
params.onAgentEvent?.({ params.onAgentEvent?.({
stream: "tool", stream: "tool",
data: { phase: "start", name: toolName, toolCallId }, data: { phase: "start", name: toolName, toolCallId },
@@ -710,18 +705,16 @@ export function subscribeEmbeddedPiSession(params: {
const partial = (evt as AgentEvent & { partialResult?: unknown }) const partial = (evt as AgentEvent & { partialResult?: unknown })
.partialResult; .partialResult;
const sanitized = sanitizeToolResult(partial); const sanitized = sanitizeToolResult(partial);
if (toolEventById.has(toolCallId)) { emitAgentEvent({
emitAgentEvent({ runId: params.runId,
runId: params.runId, stream: "tool",
stream: "tool", data: {
data: { phase: "update",
phase: "update", name: toolName,
name: toolName, toolCallId,
toolCallId, partialResult: sanitized,
partialResult: sanitized, },
}, });
});
}
params.onAgentEvent?.({ params.onAgentEvent?.({
stream: "tool", stream: "tool",
data: { data: {
@@ -768,22 +761,18 @@ export function subscribeEmbeddedPiSession(params: {
} }
} }
const shouldEmitToolEvents = toolEventById.has(toolCallId); emitAgentEvent({
if (shouldEmitToolEvents) { runId: params.runId,
emitAgentEvent({ stream: "tool",
runId: params.runId, data: {
stream: "tool", phase: "result",
data: { name: toolName,
phase: "result", toolCallId,
name: toolName, meta,
toolCallId, isError,
meta, result: sanitizedResult,
isError, },
result: sanitizedResult, });
},
});
}
toolEventById.delete(toolCallId);
params.onAgentEvent?.({ params.onAgentEvent?.({
stream: "tool", stream: "tool",
data: { data: {

View File

@@ -346,7 +346,10 @@ export async function runReplyAgent(params: {
try { try {
const runId = crypto.randomUUID(); const runId = crypto.randomUUID();
if (sessionKey) { if (sessionKey) {
registerAgentRunContext(runId, { sessionKey }); registerAgentRunContext(runId, {
sessionKey,
verboseLevel: resolvedVerboseLevel,
});
} }
let runResult: Awaited<ReturnType<typeof runEmbeddedPiAgent>>; let runResult: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
let fallbackProvider = followupRun.run.provider; let fallbackProvider = followupRun.run.provider;

View File

@@ -37,6 +37,7 @@ import {
saveSessionStore, saveSessionStore,
} from "../../config/sessions.js"; } from "../../config/sessions.js";
import { enqueueSystemEvent } from "../../infra/system-events.js"; import { enqueueSystemEvent } from "../../infra/system-events.js";
import { applyVerboseOverride } from "../../sessions/level-overrides.js";
import { shortenHomePath } from "../../utils.js"; import { shortenHomePath } from "../../utils.js";
import { extractModelDirective } from "../model.js"; import { extractModelDirective } from "../model.js";
import type { MsgContext } from "../templating.js"; import type { MsgContext } from "../templating.js";
@@ -853,7 +854,7 @@ export async function handleDirectiveOnly(params: {
else sessionEntry.thinkingLevel = directives.thinkLevel; else sessionEntry.thinkingLevel = directives.thinkLevel;
} }
if (directives.hasVerboseDirective && directives.verboseLevel) { if (directives.hasVerboseDirective && directives.verboseLevel) {
sessionEntry.verboseLevel = directives.verboseLevel; applyVerboseOverride(sessionEntry, directives.verboseLevel);
} }
if (directives.hasReasoningDirective && directives.reasoningLevel) { if (directives.hasReasoningDirective && directives.reasoningLevel) {
if (directives.reasoningLevel === "off") if (directives.reasoningLevel === "off")
@@ -1027,11 +1028,7 @@ export async function persistInlineDirectives(params: {
updated = true; updated = true;
} }
if (directives.hasVerboseDirective && directives.verboseLevel) { if (directives.hasVerboseDirective && directives.verboseLevel) {
if (directives.verboseLevel === "off") { applyVerboseOverride(sessionEntry, directives.verboseLevel);
delete sessionEntry.verboseLevel;
} else {
sessionEntry.verboseLevel = directives.verboseLevel;
}
updated = true; updated = true;
} }
if (directives.hasReasoningDirective && directives.reasoningLevel) { if (directives.hasReasoningDirective && directives.reasoningLevel) {

View File

@@ -119,7 +119,10 @@ export function createFollowupRunner(params: {
try { try {
const runId = crypto.randomUUID(); const runId = crypto.randomUUID();
if (queued.run.sessionKey) { if (queued.run.sessionKey) {
registerAgentRunContext(runId, { sessionKey: queued.run.sessionKey }); registerAgentRunContext(runId, {
sessionKey: queued.run.sessionKey,
verboseLevel: queued.run.verboseLevel,
});
} }
let autoCompactionCompleted = false; let autoCompactionCompleted = false;
let runResult: Awaited<ReturnType<typeof runEmbeddedPiAgent>>; let runResult: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;

View File

@@ -58,6 +58,7 @@ import {
import { resolveOutboundTarget } from "../infra/outbound/targets.js"; import { resolveOutboundTarget } from "../infra/outbound/targets.js";
import { normalizeMainKey } from "../routing/session-key.js"; import { normalizeMainKey } from "../routing/session-key.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { applyVerboseOverride } from "../sessions/level-overrides.js";
import { resolveSendPolicy } from "../sessions/send-policy.js"; import { resolveSendPolicy } from "../sessions/send-policy.js";
import { import {
normalizeMessageProvider, normalizeMessageProvider,
@@ -249,10 +250,6 @@ export async function agentCommand(
let sessionEntry = resolvedSessionEntry; let sessionEntry = resolvedSessionEntry;
const runId = opts.runId?.trim() || sessionId; const runId = opts.runId?.trim() || sessionId;
if (sessionKey) {
registerAgentRunContext(runId, { sessionKey });
}
if (opts.deliver === true) { if (opts.deliver === true) {
const sendPolicy = resolveSendPolicy({ const sendPolicy = resolveSendPolicy({
cfg, cfg,
@@ -276,6 +273,13 @@ export async function agentCommand(
persistedVerbose ?? persistedVerbose ??
(agentCfg?.verboseDefault as VerboseLevel | undefined); (agentCfg?.verboseDefault as VerboseLevel | undefined);
if (sessionKey) {
registerAgentRunContext(runId, {
sessionKey,
verboseLevel: resolvedVerboseLevel,
});
}
const needsSkillsSnapshot = isNewSession || !sessionEntry?.skillsSnapshot; const needsSkillsSnapshot = isNewSession || !sessionEntry?.skillsSnapshot;
const skillsSnapshot = needsSkillsSnapshot const skillsSnapshot = needsSkillsSnapshot
? buildWorkspaceSkillSnapshot(workspaceDir, { config: cfg }) ? buildWorkspaceSkillSnapshot(workspaceDir, { config: cfg })
@@ -306,10 +310,7 @@ export async function agentCommand(
if (thinkOverride === "off") delete next.thinkingLevel; if (thinkOverride === "off") delete next.thinkingLevel;
else next.thinkingLevel = thinkOverride; else next.thinkingLevel = thinkOverride;
} }
if (verboseOverride) { applyVerboseOverride(next, verboseOverride);
if (verboseOverride === "off") delete next.verboseLevel;
else next.verboseLevel = verboseOverride;
}
sessionStore[sessionKey] = next; sessionStore[sessionKey] = next;
await saveSessionStore(storePath, sessionStore); await saveSessionStore(storePath, sessionStore);
} }

View File

@@ -425,8 +425,12 @@ export async function runCronIsolatedAgentTurn(params: {
const sessionFile = resolveSessionTranscriptPath( const sessionFile = resolveSessionTranscriptPath(
cronSession.sessionEntry.sessionId, cronSession.sessionEntry.sessionId,
); );
const resolvedVerboseLevel =
(cronSession.sessionEntry.verboseLevel as "on" | "off" | undefined) ??
(agentCfg?.verboseDefault as "on" | "off" | undefined);
registerAgentRunContext(cronSession.sessionEntry.sessionId, { registerAgentRunContext(cronSession.sessionEntry.sessionId, {
sessionKey: params.sessionKey, sessionKey: params.sessionKey,
verboseLevel: resolvedVerboseLevel,
}); });
const messageProvider = resolvedDelivery.provider; const messageProvider = resolvedDelivery.provider;
const claudeSessionId = cronSession.sessionEntry.claudeCliSessionId?.trim(); const claudeSessionId = cronSession.sessionEntry.claudeCliSessionId?.trim();
@@ -464,12 +468,7 @@ export async function runCronIsolatedAgentTurn(params: {
provider: providerOverride, provider: providerOverride,
model: modelOverride, model: modelOverride,
thinkLevel, thinkLevel,
verboseLevel: verboseLevel: resolvedVerboseLevel,
(cronSession.sessionEntry.verboseLevel as
| "on"
| "off"
| undefined) ??
(agentCfg?.verboseDefault as "on" | "off" | undefined),
timeoutMs, timeoutMs,
runId: cronSession.sessionEntry.sessionId, runId: cronSession.sessionEntry.sessionId,
}); });

View File

@@ -1,4 +1,9 @@
import type { AgentEventPayload } from "../infra/agent-events.js"; import { normalizeVerboseLevel } from "../auto-reply/thinking.js";
import {
type AgentEventPayload,
getAgentRunContext,
} from "../infra/agent-events.js";
import { loadSessionEntry } from "./session-utils.js";
import { formatForLog } from "./ws-log.js"; import { formatForLog } from "./ws-log.js";
export type ChatRunEntry = { export type ChatRunEntry = {
@@ -185,6 +190,24 @@ export function createAgentEventHandler({
bridgeSendToSession(sessionKey, "chat", payload); bridgeSendToSession(sessionKey, "chat", payload);
}; };
const shouldEmitToolEvents = (runId: string, sessionKey?: string) => {
const runContext = getAgentRunContext(runId);
const runVerbose = normalizeVerboseLevel(runContext?.verboseLevel);
if (runVerbose) return runVerbose === "on";
if (!sessionKey) return false;
try {
const { cfg, entry } = loadSessionEntry(sessionKey);
const sessionVerbose = normalizeVerboseLevel(entry?.verboseLevel);
if (sessionVerbose) return sessionVerbose === "on";
const defaultVerbose = normalizeVerboseLevel(
cfg.agents?.defaults?.verboseDefault,
);
return defaultVerbose === "on";
} catch {
return false;
}
};
return (evt: AgentEventPayload) => { return (evt: AgentEventPayload) => {
const chatLink = chatRunState.registry.peek(evt.runId); const chatLink = chatRunState.registry.peek(evt.runId);
const sessionKey = const sessionKey =
@@ -192,6 +215,10 @@ export function createAgentEventHandler({
// Include sessionKey so Control UI can filter tool streams per session. // Include sessionKey so Control UI can filter tool streams per session.
const agentPayload = sessionKey ? { ...evt, sessionKey } : evt; const agentPayload = sessionKey ? { ...evt, sessionKey } : evt;
const last = agentRunSeq.get(evt.runId) ?? 0; const last = agentRunSeq.get(evt.runId) ?? 0;
if (evt.stream === "tool" && !shouldEmitToolEvents(evt.runId, sessionKey)) {
agentRunSeq.set(evt.runId, evt.seq);
return;
}
if (evt.seq !== last + 1) { if (evt.seq !== last + 1) {
broadcast("agent", { broadcast("agent", {
runId: evt.runId, runId: evt.runId,

View File

@@ -785,7 +785,10 @@ describe("gateway server agent", () => {
}, },
}); });
registerAgentRunContext("run-tool-1", { sessionKey: "main" }); registerAgentRunContext("run-tool-1", {
sessionKey: "main",
verboseLevel: "on",
});
const agentEvtP = onceMessage( const agentEvtP = onceMessage(
ws, ws,
@@ -813,6 +816,66 @@ describe("gateway server agent", () => {
await server.close(); await server.close();
}); });
test("suppresses tool stream events when verbose is off", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-main",
updatedAt: Date.now(),
verboseLevel: "off",
},
},
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws, {
client: {
name: "webchat",
version: "1.0.0",
platform: "test",
mode: "webchat",
},
});
registerAgentRunContext("run-tool-off", { sessionKey: "agent:main:main" });
emitAgentEvent({
runId: "run-tool-off",
stream: "tool",
data: { phase: "start", name: "read", toolCallId: "tool-1" },
});
emitAgentEvent({
runId: "run-tool-off",
stream: "assistant",
data: { text: "hello" },
});
const evt = await onceMessage(
ws,
(o) =>
o.type === "event" &&
o.event === "agent" &&
o.payload?.runId === "run-tool-off",
8000,
);
const payload =
evt.payload && typeof evt.payload === "object"
? (evt.payload as Record<string, unknown>)
: {};
expect(payload.stream).toBe("assistant");
ws.close();
await server.close();
});
test("agent.wait resolves after lifecycle end", async () => { test("agent.wait resolves after lifecycle end", async () => {
const { server, ws } = await startServerWithClient(); const { server, ws } = await startServerWithClient();
await connectOk(ws); await connectOk(ws);

View File

@@ -12,11 +12,14 @@ import {
normalizeReasoningLevel, normalizeReasoningLevel,
normalizeThinkLevel, normalizeThinkLevel,
normalizeUsageDisplay, normalizeUsageDisplay,
normalizeVerboseLevel,
} from "../auto-reply/thinking.js"; } from "../auto-reply/thinking.js";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import type { SessionEntry } from "../config/sessions.js"; import type { SessionEntry } from "../config/sessions.js";
import { isSubagentSessionKey } from "../routing/session-key.js"; import { isSubagentSessionKey } from "../routing/session-key.js";
import {
applyVerboseOverride,
parseVerboseOverride,
} from "../sessions/level-overrides.js";
import { normalizeSendPolicy } from "../sessions/send-policy.js"; import { normalizeSendPolicy } from "../sessions/send-policy.js";
import { parseSessionLabel } from "../sessions/session-label.js"; import { parseSessionLabel } from "../sessions/session-label.js";
import { import {
@@ -103,13 +106,9 @@ export async function applySessionsPatchToStore(params: {
if ("verboseLevel" in patch) { if ("verboseLevel" in patch) {
const raw = patch.verboseLevel; const raw = patch.verboseLevel;
if (raw === null) { const parsed = parseVerboseOverride(raw);
delete next.verboseLevel; if (!parsed.ok) return invalid(parsed.error);
} else if (raw !== undefined) { applyVerboseOverride(next, parsed.value);
const normalized = normalizeVerboseLevel(String(raw));
if (!normalized) return invalid('invalid verboseLevel (use "on"|"off")');
next.verboseLevel = normalized;
}
} }
if ("reasoningLevel" in patch) { if ("reasoningLevel" in patch) {

View File

@@ -16,6 +16,7 @@ export type AgentEventPayload = {
export type AgentRunContext = { export type AgentRunContext = {
sessionKey?: string; sessionKey?: string;
verboseLevel?: "off" | "on";
}; };
// Keep per-run counters so streams stay strictly monotonic per runId. // Keep per-run counters so streams stay strictly monotonic per runId.
@@ -36,6 +37,9 @@ export function registerAgentRunContext(
if (context.sessionKey && existing.sessionKey !== context.sessionKey) { if (context.sessionKey && existing.sessionKey !== context.sessionKey) {
existing.sessionKey = context.sessionKey; existing.sessionKey = context.sessionKey;
} }
if (context.verboseLevel && existing.verboseLevel !== context.verboseLevel) {
existing.verboseLevel = context.verboseLevel;
}
} }
export function getAgentRunContext(runId: string) { export function getAgentRunContext(runId: string) {

View File

@@ -0,0 +1,34 @@
import {
normalizeVerboseLevel,
type VerboseLevel,
} from "../auto-reply/thinking.js";
import type { SessionEntry } from "../config/sessions.js";
export function parseVerboseOverride(
raw: unknown,
):
| { ok: true; value: VerboseLevel | null | undefined }
| { ok: false; error: string } {
if (raw === null) return { ok: true, value: null };
if (raw === undefined) return { ok: true, value: undefined };
if (typeof raw !== "string") {
return { ok: false, error: 'invalid verboseLevel (use "on"|"off")' };
}
const normalized = normalizeVerboseLevel(raw);
if (!normalized) {
return { ok: false, error: 'invalid verboseLevel (use "on"|"off")' };
}
return { ok: true, value: normalized };
}
export function applyVerboseOverride(
entry: SessionEntry,
level: VerboseLevel | null | undefined,
) {
if (level === undefined) return;
if (level === null) {
delete entry.verboseLevel;
return;
}
entry.verboseLevel = level;
}

View File

@@ -32,7 +32,11 @@ export type SessionsProps = {
}; };
const THINK_LEVELS = ["", "off", "minimal", "low", "medium", "high"] as const; const THINK_LEVELS = ["", "off", "minimal", "low", "medium", "high"] as const;
const VERBOSE_LEVELS = ["", "off", "on"] as const; const VERBOSE_LEVELS = [
{ value: "", label: "inherit" },
{ value: "off", label: "off (explicit)" },
{ value: "on", label: "on" },
] as const;
const REASONING_LEVELS = ["", "off", "on", "stream"] as const; const REASONING_LEVELS = ["", "off", "on", "stream"] as const;
export function renderSessions(props: SessionsProps) { export function renderSessions(props: SessionsProps) {
@@ -178,8 +182,8 @@ function renderRow(
onPatch(row.key, { verboseLevel: value || null }); onPatch(row.key, { verboseLevel: value || null });
}} }}
> >
${VERBOSE_LEVELS.map((level) => ${VERBOSE_LEVELS.map(
html`<option value=${level}>${level || "inherit"}</option>`, (level) => html`<option value=${level.value}>${level.label}</option>`,
)} )}
</select> </select>
</div> </div>