Skip to content

Commit ef953e8

Browse files
clkaoclaudewesm
authored
feat: store tool result content in tool_calls with category blocklist (#96)
## What this does for users Tool results are now stored and displayed alongside tool calls. When you expand a tool block, you will see an **output** section showing what the tool returned — Bash command output, git commit messages, Write confirmations, and more. Click it to expand the full result, or see the first line at a glance while it is collapsed. File-reading results (Read, Glob) are excluded from storage by default since they are large and rarely useful. The blocklist is configurable: set `"result_content_blocked_categories": []` in your config file to store everything, or customize the list to your preference. Existing databases are upgraded automatically on next startup — no data is lost. ## Implementation **Backend** - **Parser**: extracts tool result content text into a new `Content` field on `ParsedToolResult`, handling both string and array block formats - **DB**: adds `result_content TEXT` to `tool_calls` via a non-destructive `ALTER TABLE` migration (idempotent probe via `pragma_table_info`); bumps `dataVersion` 1→2 to trigger resync of existing sessions - **Sync**: carries `Content` through `convertToolResults`; `pairToolResults` now accepts a `blocked map[string]bool` and omits `ResultContent` for blocked categories (nil map = store everything) - **Config**: `ResultContentBlockedCategories []string` with default `["Read", "Glob"]`; config file nil-check allows `[]` to clear the blocklist entirely **Frontend** - **ToolBlock**: adds a collapsible **output** section below the existing input/meta content, rendered only when `result_content` is present; collapsed by default with first-line preview; expands to a scrollable `<pre>` (max 300px height) - **Types**: `result_content?: string` added to `ToolCall` TypeScript interface ## Test Plan - [ ] `make test` passes (all packages) - [ ] `make build` produces a working binary - [ ] Existing DB upgraded correctly: start server against real data, verify `result_content` column appears in `tool_calls` without data loss - [ ] Bash tool results appear in DB with content and are visible in the UI output section; Read/Glob results show no output section - [ ] Output section starts collapsed, first line visible as preview; click expands full content - [ ] Override blocklist via config file: `"result_content_blocked_categories": []` stores and displays content for all categories Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Wes McKinney <wesmckinn+git@gmail.com>
1 parent 1c17320 commit ef953e8

15 files changed

Lines changed: 703 additions & 46 deletions

File tree

cmd/agentsview/main.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,8 +160,9 @@ func runServe(args []string) {
160160
cleanResyncTemp(cfg.DBPath)
161161

162162
engine := sync.NewEngine(database, sync.EngineConfig{
163-
AgentDirs: cfg.AgentDirs,
164-
Machine: "local",
163+
AgentDirs: cfg.AgentDirs,
164+
Machine: "local",
165+
BlockedResultCategories: cfg.ResultContentBlockedCategories,
165166
})
166167

167168
if database.NeedsResync() {

frontend/src/lib/api/types/core.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export interface ToolCall {
4545
input_json?: string;
4646
skill_name?: string;
4747
result_content_length?: number;
48+
result_content?: string;
4849
subagent_session_id?: string;
4950
}
5051

frontend/src/lib/components/content/ToolBlock.svelte

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,14 @@
2121
2222
let { content, label, toolCall }: Props = $props();
2323
let collapsed: boolean = $state(true);
24+
let outputCollapsed: boolean = $state(true);
25+
26+
let outputPreviewLine = $derived.by(() => {
27+
const rc = toolCall?.result_content;
28+
if (!rc) return "";
29+
const nl = rc.indexOf("\n");
30+
return (nl === -1 ? rc : rc.slice(0, nl)).slice(0, 100);
31+
});
2432
2533
let previewLine = $derived(
2634
content.split("\n")[0]?.slice(0, 100) ?? "",
@@ -161,6 +169,28 @@
161169
{:else if fallbackContent}
162170
<pre class="tool-content">{fallbackContent}</pre>
163171
{/if}
172+
{#if toolCall?.result_content}
173+
<button
174+
class="output-header"
175+
onclick={(e) => {
176+
e.stopPropagation();
177+
const sel = window.getSelection();
178+
if (sel && sel.toString().length > 0) return;
179+
outputCollapsed = !outputCollapsed;
180+
}}
181+
>
182+
<span class="tool-chevron" class:open={!outputCollapsed}>
183+
&#9656;
184+
</span>
185+
<span class="output-label">output</span>
186+
{#if outputCollapsed && outputPreviewLine}
187+
<span class="tool-preview">{outputPreviewLine}</span>
188+
{/if}
189+
</button>
190+
{#if !outputCollapsed}
191+
<pre class="tool-content output-content">{toolCall.result_content}</pre>
192+
{/if}
193+
{/if}
164194
{/if}
165195
{#if subagentSessionId}
166196
<SubagentInline sessionId={subagentSessionId} />
@@ -257,4 +287,38 @@
257287
overflow-x: auto;
258288
border-top: 1px solid var(--border-muted);
259289
}
290+
291+
.output-header {
292+
display: flex;
293+
align-items: center;
294+
gap: 6px;
295+
padding: 5px 10px;
296+
width: 100%;
297+
text-align: left;
298+
font-size: 12px;
299+
color: var(--text-secondary);
300+
min-width: 0;
301+
border-top: 1px solid var(--border-muted);
302+
transition: background 0.1s;
303+
user-select: text;
304+
}
305+
306+
.output-header:hover {
307+
background: var(--bg-surface-hover);
308+
color: var(--text-primary);
309+
}
310+
311+
.output-label {
312+
font-family: var(--font-mono);
313+
font-weight: 500;
314+
font-size: 11px;
315+
color: var(--text-secondary);
316+
white-space: nowrap;
317+
flex-shrink: 0;
318+
}
319+
320+
.output-content {
321+
max-height: 300px;
322+
overflow-y: auto;
323+
}
260324
</style>
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
// @vitest-environment jsdom
2+
// ABOUTME: Unit tests for ToolBlock's output section behavior.
3+
// ABOUTME: Covers visibility, collapse/expand, and preview of result_content.
4+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
5+
import { mount, unmount, tick } from "svelte";
6+
import type { ToolCall } from "../../api/types.js";
7+
8+
vi.mock("./SubagentInline.svelte", () => ({
9+
default: {},
10+
}));
11+
12+
// @ts-ignore
13+
import ToolBlock from "./ToolBlock.svelte";
14+
15+
describe("ToolBlock output section", () => {
16+
let component: ReturnType<typeof mount>;
17+
18+
afterEach(() => {
19+
if (component) unmount(component);
20+
document.body.innerHTML = "";
21+
});
22+
23+
it("does not render output-header when toolCall has no result_content", async () => {
24+
const toolCall: ToolCall = {
25+
tool_name: "Read",
26+
category: "file",
27+
};
28+
component = mount(ToolBlock, {
29+
target: document.body,
30+
props: { content: "some input", toolCall },
31+
});
32+
await tick();
33+
34+
expect(document.querySelector(".output-header")).toBeNull();
35+
});
36+
37+
it("does not render output-header when toolCall is absent", async () => {
38+
component = mount(ToolBlock, {
39+
target: document.body,
40+
props: { content: "some input" },
41+
});
42+
await tick();
43+
44+
expect(document.querySelector(".output-header")).toBeNull();
45+
});
46+
47+
it("renders output-header after expanding the tool block when result_content is set", async () => {
48+
const toolCall: ToolCall = {
49+
tool_name: "Read",
50+
category: "file",
51+
result_content: "line one\nline two",
52+
};
53+
component = mount(ToolBlock, {
54+
target: document.body,
55+
props: { content: "some input", toolCall },
56+
});
57+
await tick();
58+
59+
// Output section is inside the collapsed block — not visible yet.
60+
expect(document.querySelector(".output-header")).toBeNull();
61+
62+
// Expand the main tool block.
63+
const toolHeader = document.querySelector<HTMLButtonElement>(".tool-header");
64+
expect(toolHeader).not.toBeNull();
65+
toolHeader!.click();
66+
await tick();
67+
68+
expect(document.querySelector(".output-header")).not.toBeNull();
69+
});
70+
71+
it("output starts collapsed after expanding the tool block", async () => {
72+
const toolCall: ToolCall = {
73+
tool_name: "Read",
74+
category: "file",
75+
result_content: "line one\nline two",
76+
};
77+
component = mount(ToolBlock, {
78+
target: document.body,
79+
props: { content: "some input", toolCall },
80+
});
81+
await tick();
82+
83+
document.querySelector<HTMLButtonElement>(".tool-header")!.click();
84+
await tick();
85+
86+
// Output content pre block should not be present when output is collapsed.
87+
expect(document.querySelector(".output-content")).toBeNull();
88+
});
89+
90+
it("expands output content on clicking output-header", async () => {
91+
const resultText = "line one\nline two\nline three";
92+
const toolCall: ToolCall = {
93+
tool_name: "Read",
94+
category: "file",
95+
result_content: resultText,
96+
};
97+
component = mount(ToolBlock, {
98+
target: document.body,
99+
props: { content: "some input", toolCall },
100+
});
101+
await tick();
102+
103+
document.querySelector<HTMLButtonElement>(".tool-header")!.click();
104+
await tick();
105+
106+
document.querySelector<HTMLButtonElement>(".output-header")!.click();
107+
await tick();
108+
109+
const outputContent = document.querySelector(".output-content");
110+
expect(outputContent).not.toBeNull();
111+
expect(outputContent!.textContent).toBe(resultText);
112+
});
113+
114+
it("shows first line as preview when output is collapsed", async () => {
115+
const toolCall: ToolCall = {
116+
tool_name: "Read",
117+
category: "file",
118+
result_content: "first line\nsecond line",
119+
};
120+
component = mount(ToolBlock, {
121+
target: document.body,
122+
props: { content: "some input", toolCall },
123+
});
124+
await tick();
125+
126+
document.querySelector<HTMLButtonElement>(".tool-header")!.click();
127+
await tick();
128+
129+
// Output is collapsed — preview should show first line.
130+
const outputHeader = document.querySelector(".output-header");
131+
expect(outputHeader).not.toBeNull();
132+
const preview = outputHeader!.querySelector(".tool-preview");
133+
expect(preview).not.toBeNull();
134+
expect(preview!.textContent).toBe("first line");
135+
});
136+
});

internal/config/config.go

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ type Config struct {
3434
// agentDirSource tracks how each agent's dirs were
3535
// set so loadFile doesn't override env-set values.
3636
agentDirSource map[parser.AgentType]dirSource
37+
38+
ResultContentBlockedCategories []string `json:"result_content_blocked_categories,omitempty"`
3739
}
3840

3941
type dirSource int
@@ -82,13 +84,14 @@ func Default() (Config, error) {
8284
}
8385

8486
return Config{
85-
Host: "127.0.0.1",
86-
Port: 8080,
87-
DataDir: dataDir,
88-
DBPath: filepath.Join(dataDir, "sessions.db"),
89-
WriteTimeout: 30 * time.Second,
90-
AgentDirs: agentDirs,
91-
agentDirSource: agentDirSource,
87+
Host: "127.0.0.1",
88+
Port: 8080,
89+
DataDir: dataDir,
90+
DBPath: filepath.Join(dataDir, "sessions.db"),
91+
WriteTimeout: 30 * time.Second,
92+
AgentDirs: agentDirs,
93+
agentDirSource: agentDirSource,
94+
ResultContentBlockedCategories: []string{"Read", "Glob"},
9295
}, nil
9396
}
9497

@@ -138,8 +141,9 @@ func (c *Config) loadFile() error {
138141
}
139142

140143
var file struct {
141-
GithubToken string `json:"github_token"`
142-
CursorSecret string `json:"cursor_secret"`
144+
GithubToken string `json:"github_token"`
145+
CursorSecret string `json:"cursor_secret"`
146+
ResultContentBlockedCategories []string `json:"result_content_blocked_categories"`
143147
}
144148
if err := json.Unmarshal(data, &file); err != nil {
145149
return fmt.Errorf("parsing config: %w", err)
@@ -150,6 +154,9 @@ func (c *Config) loadFile() error {
150154
if file.CursorSecret != "" {
151155
c.CursorSecret = file.CursorSecret
152156
}
157+
if file.ResultContentBlockedCategories != nil {
158+
c.ResultContentBlockedCategories = file.ResultContentBlockedCategories
159+
}
153160

154161
// Parse config-file dir arrays for agents that have a
155162
// ConfigKey. Only apply when not already set by env var.

internal/config/config_test.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,3 +397,88 @@ func TestLoadFile_MalformedDirValueLogsWarning(t *testing.T) {
397397
)
398398
}
399399
}
400+
401+
func TestDefault_ResultContentBlockedCategories(t *testing.T) {
402+
cfg, err := Default()
403+
if err != nil {
404+
t.Fatal(err)
405+
}
406+
407+
want := []string{"Read", "Glob"}
408+
if len(cfg.ResultContentBlockedCategories) != len(want) {
409+
t.Fatalf(
410+
"ResultContentBlockedCategories len = %d, want %d",
411+
len(cfg.ResultContentBlockedCategories), len(want),
412+
)
413+
}
414+
for i, v := range cfg.ResultContentBlockedCategories {
415+
if v != want[i] {
416+
t.Errorf(
417+
"ResultContentBlockedCategories[%d] = %q, want %q",
418+
i, v, want[i],
419+
)
420+
}
421+
}
422+
}
423+
424+
func TestLoadFile_ResultContentBlockedCategories(t *testing.T) {
425+
tests := []struct {
426+
name string
427+
config map[string]any
428+
want []string
429+
}{
430+
{
431+
"NoConfigFileUsesDefault",
432+
map[string]any{},
433+
[]string{"Read", "Glob"},
434+
},
435+
{
436+
"ConfigFileOverridesWithCustomArray",
437+
map[string]any{
438+
"result_content_blocked_categories": []string{"Bash"},
439+
},
440+
[]string{"Bash"},
441+
},
442+
{
443+
"ConfigFileWithMultipleCategories",
444+
map[string]any{
445+
"result_content_blocked_categories": []string{"Bash", "Write", "Edit"},
446+
},
447+
[]string{"Bash", "Write", "Edit"},
448+
},
449+
{
450+
"ConfigFileWithEmptyArrayClearsBlocklist",
451+
map[string]any{
452+
"result_content_blocked_categories": []string{},
453+
},
454+
[]string{},
455+
},
456+
}
457+
458+
for _, tt := range tests {
459+
t.Run(tt.name, func(t *testing.T) {
460+
dir := setupTestEnv(t)
461+
writeConfig(t, dir, tt.config)
462+
463+
cfg, err := LoadMinimal()
464+
if err != nil {
465+
t.Fatal(err)
466+
}
467+
468+
if len(cfg.ResultContentBlockedCategories) != len(tt.want) {
469+
t.Fatalf(
470+
"ResultContentBlockedCategories len = %d, want %d",
471+
len(cfg.ResultContentBlockedCategories), len(tt.want),
472+
)
473+
}
474+
for i, v := range cfg.ResultContentBlockedCategories {
475+
if v != tt.want[i] {
476+
t.Errorf(
477+
"ResultContentBlockedCategories[%d] = %q, want %q",
478+
i, v, tt.want[i],
479+
)
480+
}
481+
}
482+
})
483+
}
484+
}

0 commit comments

Comments
 (0)