diff --git a/CHANGELOG.md b/CHANGELOG.md index aa5eca710..89d87e2cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - macOS: add node bridge heartbeat pings to detect half-open sockets and reconnect cleanly. (#572) — thanks @ngutman - 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: persist per-session verbose off and hide tool cards unless verbose is on. (#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 `clawdbot config --section ` to jump straight into a wizard section (repeatable). - Docs: add Hetzner Docker VPS guide. (#556) — thanks @Iamadig diff --git a/src/agents/pi-embedded-subscribe.test.ts b/src/agents/pi-embedded-subscribe.test.ts index c238b2b8b..e27f3fc9d 100644 --- a/src/agents/pi-embedded-subscribe.test.ts +++ b/src/agents/pi-embedded-subscribe.test.ts @@ -1,8 +1,13 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; import { describe, expect, it, vi } from "vitest"; +import { emitAgentEvent } from "../infra/agent-events.js"; import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; +vi.mock("../infra/agent-events.js", () => ({ + emitAgentEvent: vi.fn(), +})); + type StubSession = { subscribe: (fn: (evt: unknown) => void) => () => void; }; @@ -10,6 +15,8 @@ type StubSession = { type SessionEventHandler = (evt: unknown) => void; describe("subscribeEmbeddedPiSession", () => { + const emitAgentEventMock = vi.mocked(emitAgentEvent); + it("filters to and falls back when tags are malformed", () => { let handler: ((evt: unknown) => void) | undefined; const session: StubSession = { @@ -1467,6 +1474,48 @@ describe("subscribeEmbeddedPiSession", () => { 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", () => { let handler: ((evt: unknown) => void) | undefined; const session: StubSession = { diff --git a/src/agents/pi-embedded-subscribe.ts b/src/agents/pi-embedded-subscribe.ts index 82943a2fe..22c58b958 100644 --- a/src/agents/pi-embedded-subscribe.ts +++ b/src/agents/pi-embedded-subscribe.ts @@ -368,6 +368,7 @@ export function subscribeEmbeddedPiSession(params: { const toolMetas: Array<{ toolName?: string; meta?: string }> = []; const toolMetaById = new Map(); const toolSummaryById = new Set(); + const toolEventById = new Set(); const blockReplyBreak = params.blockReplyBreak ?? "text_end"; const reasoningMode = params.reasoningMode ?? "off"; const includeReasoning = reasoningMode === "on"; @@ -589,6 +590,7 @@ export function subscribeEmbeddedPiSession(params: { toolMetas.length = 0; toolMetaById.clear(); toolSummaryById.clear(); + toolEventById.clear(); messagingToolSentTexts.length = 0; messagingToolSentTargets.length = 0; pendingMessagingTexts.clear(); @@ -639,16 +641,20 @@ export function subscribeEmbeddedPiSession(params: { `embedded run tool start: runId=${params.runId} tool=${toolName} toolCallId=${toolCallId}`, ); - emitAgentEvent({ - runId: params.runId, - stream: "tool", - data: { - phase: "start", - name: toolName, - toolCallId, - args: args as Record, - }, - }); + const shouldEmitToolEvents = shouldEmitToolResult(); + if (shouldEmitToolEvents) { + toolEventById.add(toolCallId); + emitAgentEvent({ + runId: params.runId, + stream: "tool", + data: { + phase: "start", + name: toolName, + toolCallId, + args: args as Record, + }, + }); + } params.onAgentEvent?.({ stream: "tool", data: { phase: "start", name: toolName, toolCallId }, @@ -656,7 +662,7 @@ export function subscribeEmbeddedPiSession(params: { if ( params.onToolResult && - shouldEmitToolResult() && + shouldEmitToolEvents && !toolSummaryById.has(toolCallId) ) { toolSummaryById.add(toolCallId); @@ -704,16 +710,18 @@ export function subscribeEmbeddedPiSession(params: { const partial = (evt as AgentEvent & { partialResult?: unknown }) .partialResult; const sanitized = sanitizeToolResult(partial); - emitAgentEvent({ - runId: params.runId, - stream: "tool", - data: { - phase: "update", - name: toolName, - toolCallId, - partialResult: sanitized, - }, - }); + if (toolEventById.has(toolCallId)) { + emitAgentEvent({ + runId: params.runId, + stream: "tool", + data: { + phase: "update", + name: toolName, + toolCallId, + partialResult: sanitized, + }, + }); + } params.onAgentEvent?.({ stream: "tool", data: { @@ -760,18 +768,22 @@ export function subscribeEmbeddedPiSession(params: { } } - emitAgentEvent({ - runId: params.runId, - stream: "tool", - data: { - phase: "result", - name: toolName, - toolCallId, - meta, - isError, - result: sanitizedResult, - }, - }); + const shouldEmitToolEvents = toolEventById.has(toolCallId); + if (shouldEmitToolEvents) { + emitAgentEvent({ + runId: params.runId, + stream: "tool", + data: { + phase: "result", + name: toolName, + toolCallId, + meta, + isError, + result: sanitizedResult, + }, + }); + } + toolEventById.delete(toolCallId); params.onAgentEvent?.({ stream: "tool", data: { diff --git a/src/auto-reply/reply.directive.test.ts b/src/auto-reply/reply.directive.test.ts index ea5df3030..b86356e48 100644 --- a/src/auto-reply/reply.directive.test.ts +++ b/src/auto-reply/reply.directive.test.ts @@ -390,6 +390,34 @@ describe("directive behavior", () => { }); }); + it("persists verbose off when directive is standalone", async () => { + await withTempHome(async (home) => { + vi.mocked(runEmbeddedPiAgent).mockReset(); + const storePath = path.join(home, "sessions.json"); + + const res = await getReplyFromConfig( + { Body: "/verbose off", From: "+1222", To: "+1222" }, + {}, + { + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "clawd"), + }, + }, + session: { store: storePath }, + }, + ); + + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toMatch(/Verbose logging disabled\./); + const store = loadSessionStore(storePath); + const entry = Object.values(store)[0]; + expect(entry?.verboseLevel).toBe("off"); + expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + }); + }); + it("shows current think level when /think has no argument", async () => { await withTempHome(async (home) => { vi.mocked(runEmbeddedPiAgent).mockReset(); diff --git a/src/auto-reply/reply/directive-handling.ts b/src/auto-reply/reply/directive-handling.ts index d06491509..056ff462c 100644 --- a/src/auto-reply/reply/directive-handling.ts +++ b/src/auto-reply/reply/directive-handling.ts @@ -853,8 +853,7 @@ export async function handleDirectiveOnly(params: { else sessionEntry.thinkingLevel = directives.thinkLevel; } if (directives.hasVerboseDirective && directives.verboseLevel) { - if (directives.verboseLevel === "off") delete sessionEntry.verboseLevel; - else sessionEntry.verboseLevel = directives.verboseLevel; + sessionEntry.verboseLevel = directives.verboseLevel; } if (directives.hasReasoningDirective && directives.reasoningLevel) { if (directives.reasoningLevel === "off") diff --git a/src/gateway/server.sessions.test.ts b/src/gateway/server.sessions.test.ts index 129f87361..fa948ea16 100644 --- a/src/gateway/server.sessions.test.ts +++ b/src/gateway/server.sessions.test.ts @@ -143,7 +143,7 @@ describe("gateway server sessions", () => { const patched = await rpcReq<{ ok: true; key: string }>( ws, "sessions.patch", - { key: "agent:main:main", thinkingLevel: "medium", verboseLevel: null }, + { key: "agent:main:main", thinkingLevel: "medium", verboseLevel: "off" }, ); expect(patched.ok).toBe(true); expect(patched.payload?.ok).toBe(true); @@ -186,13 +186,32 @@ describe("gateway server sessions", () => { (s) => s.key === "agent:main:main", ); expect(main2?.thinkingLevel).toBe("medium"); - expect(main2?.verboseLevel).toBeUndefined(); + expect(main2?.verboseLevel).toBe("off"); expect(main2?.sendPolicy).toBe("deny"); const subagent = list2.payload?.sessions.find( (s) => s.key === "agent:main:subagent:one", ); expect(subagent?.label).toBe("Briefing"); + const clearedVerbose = await rpcReq<{ ok: true; key: string }>( + ws, + "sessions.patch", + { key: "agent:main:main", verboseLevel: null }, + ); + expect(clearedVerbose.ok).toBe(true); + + const list3 = await rpcReq<{ + sessions: Array<{ + key: string; + verboseLevel?: string; + }>; + }>(ws, "sessions.list", {}); + expect(list3.ok).toBe(true); + const main3 = list3.payload?.sessions.find( + (s) => s.key === "agent:main:main", + ); + expect(main3?.verboseLevel).toBeUndefined(); + const listByLabel = await rpcReq<{ sessions: Array<{ key: string }>; }>(ws, "sessions.list", { diff --git a/src/gateway/sessions-patch.ts b/src/gateway/sessions-patch.ts index f28c8d2e0..54fd41de5 100644 --- a/src/gateway/sessions-patch.ts +++ b/src/gateway/sessions-patch.ts @@ -108,8 +108,7 @@ export async function applySessionsPatchToStore(params: { } else if (raw !== undefined) { const normalized = normalizeVerboseLevel(String(raw)); if (!normalized) return invalid('invalid verboseLevel (use "on"|"off")'); - if (normalized === "off") delete next.verboseLevel; - else next.verboseLevel = normalized; + next.verboseLevel = normalized; } }