subagent fixes

This commit is contained in:
Simon Klee 2026-04-19 16:20:36 +02:00
parent fd30530d86
commit 7b1de3e46c
No known key found for this signature in database
GPG key ID: B91696044D47BEA3
6 changed files with 347 additions and 48 deletions

View file

@ -249,8 +249,15 @@ export class RunScrollbackStream {
this.renderer.writeToScrollback(spacerWriter())
}
if (body.type !== "structured" && entryCanStream(commit, body)) {
if (
body.type !== "structured" &&
(entryCanStream(commit, body) ||
(commit.kind === "tool" && commit.phase === "final" && body.type === "markdown"))
) {
await this.writeStreaming(commit, body)
if (entryDone(commit)) {
await this.finishActive(entryFlags(commit).trailingNewline)
}
this.wrote = true
this.tail = commit
return

View file

@ -2,6 +2,7 @@
import { createScrollbackWriter } from "@opentui/solid"
import { TextRenderable, type ScrollbackWriter } from "@opentui/core"
import { createMemo } from "solid-js"
import { entryBody, entryFlags } from "./entry.body"
import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
import { toolDiffView, toolFiletype, toolStructuredFinal } from "./tool"
@ -57,53 +58,53 @@ export function RunEntryContent(props: {
width?: number
}) {
const theme = props.theme ?? RUN_THEME_FALLBACK
const body = entryBody(props.commit)
if (body.type === "none") {
const body = createMemo(() => entryBody(props.commit))
if (body().type === "none") {
return null
}
if (body.type === "text") {
if (body().type === "text") {
const style = entryLook(props.commit, theme.entry)
return (
<text width="100%" wrapMode="word" fg={style.fg} attributes={style.attrs}>
{body.content}
{body().content}
</text>
)
}
if (body.type === "code") {
if (body().type === "code") {
return (
<code
width="100%"
wrapMode="word"
filetype={body.filetype}
filetype={body().filetype}
drawUnstyledText={false}
streaming={props.commit.phase === "progress"}
syntaxStyle={entrySyntax(props.commit, theme)}
content={body.content}
content={body().content}
fg={entryColor(props.commit, theme)}
/>
)
}
if (body.type === "structured") {
if (body().type === "structured") {
const width = Math.max(1, Math.trunc(props.width ?? 80))
if (body.snapshot.kind === "code") {
if (body().snapshot.kind === "code") {
return (
<box width="100%" flexDirection="column" gap={1}>
<text width="100%" wrapMode="word" fg={theme.block.muted}>
{body.snapshot.title}
{body().snapshot.title}
</text>
<box width="100%" paddingLeft={1}>
<line_number width="100%" fg={theme.block.muted} minWidth={3} paddingRight={1}>
<code
width="100%"
wrapMode="char"
filetype={toolFiletype(body.snapshot.file)}
filetype={toolFiletype(body().snapshot.file)}
streaming={false}
syntaxStyle={entrySyntax(props.commit, theme)}
content={body.snapshot.content}
content={body().snapshot.content}
fg={theme.block.text}
/>
</line_number>
@ -112,11 +113,11 @@ export function RunEntryContent(props: {
)
}
if (body.snapshot.kind === "diff") {
if (body().snapshot.kind === "diff") {
const view = toolDiffView(width, props.opts?.diffStyle)
return (
<box width="100%" flexDirection="column" gap={1}>
{body.snapshot.items.map((item) => (
{body().snapshot.items.map((item) => (
<box width="100%" flexDirection="column" gap={1}>
<text width="100%" wrapMode="word" fg={theme.block.muted}>
{item.title}
@ -154,21 +155,21 @@ export function RunEntryContent(props: {
)
}
if (body.snapshot.kind === "task") {
if (body().snapshot.kind === "task") {
return (
<box width="100%" flexDirection="column" gap={1}>
<text width="100%" wrapMode="word" fg={theme.block.muted}>
{body.snapshot.title}
{body().snapshot.title}
</text>
<box width="100%" flexDirection="column" gap={0} paddingLeft={1}>
{body.snapshot.rows.map((row) => (
{body().snapshot.rows.map((row) => (
<text width="100%" wrapMode="word" fg={theme.block.text}>
{row}
</text>
))}
{body.snapshot.tail ? (
{body().snapshot.tail ? (
<text width="100%" wrapMode="word" fg={theme.block.muted}>
{body.snapshot.tail}
{body().snapshot.tail}
</text>
) : null}
</box>
@ -176,21 +177,21 @@ export function RunEntryContent(props: {
)
}
if (body.snapshot.kind === "todo") {
if (body().snapshot.kind === "todo") {
return (
<box width="100%" flexDirection="column" gap={1}>
<text width="100%" wrapMode="word" fg={theme.block.muted}>
# Todos
</text>
<box width="100%" flexDirection="column" gap={0} paddingLeft={1}>
{body.snapshot.items.map((item) => (
{body().snapshot.items.map((item) => (
<text width="100%" wrapMode="word" fg={theme.block.text}>
{todoText(item)}
</text>
))}
{body.snapshot.tail ? (
{body().snapshot.tail ? (
<text width="100%" wrapMode="word" fg={theme.block.muted}>
{body.snapshot.tail}
{body().snapshot.tail}
</text>
) : null}
</box>
@ -199,27 +200,27 @@ export function RunEntryContent(props: {
}
return (
<box width="100%" flexDirection="column" gap={1}>
<text width="100%" wrapMode="word" fg={theme.block.muted}>
# Questions
</text>
<box width="100%" flexDirection="column" gap={1} paddingLeft={1}>
{body.snapshot.items.map((item) => (
<box width="100%" flexDirection="column" gap={0}>
<text width="100%" wrapMode="word" fg={theme.block.muted}>
{item.question}
<box width="100%" flexDirection="column" gap={1}>
<text width="100%" wrapMode="word" fg={theme.block.muted}>
# Questions
</text>
<box width="100%" flexDirection="column" gap={1} paddingLeft={1}>
{body().snapshot.items.map((item) => (
<box width="100%" flexDirection="column" gap={0}>
<text width="100%" wrapMode="word" fg={theme.block.muted}>
{item.question}
</text>
<text width="100%" wrapMode="word" fg={theme.block.text}>
{item.answer}
</text>
</box>
))}
{body.snapshot.tail ? (
<text width="100%" wrapMode="word" fg={theme.block.muted}>
{body.snapshot.tail}
</text>
) : null}
</box>
</box>
))}
{body().snapshot.tail ? (
<text width="100%" wrapMode="word" fg={theme.block.muted}>
{body().snapshot.tail}
</text>
) : null}
</box>
</box>
)
}
@ -229,7 +230,7 @@ export function RunEntryContent(props: {
width="100%"
syntaxStyle={entrySyntax(props.commit, theme)}
streaming={props.commit.phase === "progress"}
content={body.content}
content={body().content}
fg={entryColor(props.commit, theme)}
tableOptions={{ widthMode: "content" }}
/>

View file

@ -771,14 +771,26 @@ function scrollPatchFinal(p: ToolProps<typeof ApplyPatchTool>): string {
return rows.join("\n")
}
function scrollTaskStart(p: ToolProps<typeof TaskTool>): string {
const kind = Locale.titlecase(p.input.subagent_type || "general")
const desc = p.input.description
if (!desc) {
return `${kind} Task`
function scrollTaskStart(_: ToolProps<typeof TaskTool>): string {
return ""
}
function taskResult(output: string) {
if (!output.trim()) {
return
}
return `${kind} Task — ${desc}`
const match = output.match(/<task_result>\s*([\s\S]*?)\s*<\/task_result>/)
if (match) {
return match[1].trim() || undefined
}
const next = output
.split("\n")
.filter((line) => !line.startsWith("task_id:"))
.join("\n")
.trim()
return next || undefined
}
function scrollTaskFinal(p: ToolProps<typeof TaskTool>): string {
@ -1412,6 +1424,17 @@ function textBody(content: string): RunEntryBody | undefined {
}
}
function markdownBody(content: string): RunEntryBody | undefined {
if (!content) {
return
}
return {
type: "markdown",
content,
}
}
function structuredBody(commit: StreamCommit, raw: string): RunEntryBody | undefined {
const snap = toolSnapshot(commit, raw)
if (!snap) {
@ -1428,6 +1451,19 @@ export function toolEntryBody(commit: StreamCommit, raw: string): RunEntryBody |
const ctx = toolFrame(commit, raw)
const view = toolView(ctx.name)
if (ctx.name === "task") {
if (commit.phase === "start") {
return
}
if (commit.phase === "final" && ctx.status === "completed") {
const result = taskResult(text(ctx.state.output))
if (result) {
return markdownBody(result)
}
}
}
if (commit.phase === "progress" && !view.output) {
return
}

View file

@ -114,6 +114,139 @@ describe("run entry body", () => {
)).toBe(true)
})
test("keeps running task tool state out of scrollback", () => {
expect(
entryBody(
commit({
kind: "tool",
text: "running inspect reducer",
phase: "start",
source: "tool",
tool: "task",
toolState: "running",
part: {
id: "task-1",
sessionID: "session-1",
messageID: "msg-1",
type: "tool",
callID: "call-1",
tool: "task",
state: {
status: "running",
input: {
description: "Inspect reducer",
subagent_type: "explore",
},
},
} as never,
}),
),
).toEqual({
type: "none",
})
})
test("renders completed task tool finals from promoted task results", () => {
expect(
entryBody(
commit({
kind: "tool",
text: "",
phase: "final",
source: "tool",
tool: "task",
toolState: "completed",
part: {
id: "task-1",
sessionID: "session-1",
messageID: "msg-1",
type: "tool",
callID: "call-1",
tool: "task",
state: {
status: "completed",
input: {
description: "Inspect reducer",
subagent_type: "explore",
},
output: [
"task_id: child-1 (for resuming to continue this task if needed)",
"",
"<task_result>",
"# Findings\n\n- Footer stays live",
"</task_result>",
].join("\n"),
metadata: {
sessionId: "child-1",
},
time: {
start: 1,
end: 2,
},
},
} as never,
}),
),
).toEqual({
type: "markdown",
content: "# Findings\n\n- Footer stays live",
})
})
test("falls back to structured task final when task result is empty", () => {
const body = entryBody(
commit({
kind: "tool",
text: "",
phase: "final",
source: "tool",
tool: "task",
toolState: "completed",
part: {
id: "task-1",
sessionID: "session-1",
messageID: "msg-1",
type: "tool",
callID: "call-1",
tool: "task",
state: {
status: "completed",
input: {
description: "Inspect reducer",
subagent_type: "explore",
},
output: [
"task_id: child-1 (for resuming to continue this task if needed)",
"",
"<task_result>",
"",
"</task_result>",
].join("\n"),
metadata: {
sessionId: "child-1",
},
time: {
start: 1,
end: 2,
},
},
} as never,
}),
)
expect(body.type).toBe("structured")
if (body.type !== "structured") {
throw new Error("expected structured body")
}
expect(body.snapshot).toEqual({
kind: "task",
title: "# Explore Task",
rows: ["◉ Inspect reducer", "↳ session child-1"],
tail: "└ Explore task completed · 1ms",
})
})
test("streams tool progress text", () => {
const body = entryBody(
commit({

View file

@ -1,6 +1,53 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import { createSignal } from "solid-js"
import { RunEntryContent } from "../../../src/cli/cmd/run/scrollback.writer"
import { RunFooterView } from "../../../src/cli/cmd/run/footer.view"
import { RUN_THEME_FALLBACK } from "../../../src/cli/cmd/run/theme"
import type { StreamCommit } from "../../../src/cli/cmd/run/types"
test("run footer view loads", () => {
expect(typeof RunFooterView).toBe("function")
})
test("run entry content updates when live commit text changes", async () => {
const [commit, setCommit] = createSignal<StreamCommit>({
kind: "tool",
text: "I",
phase: "progress",
source: "tool",
messageID: "msg-1",
partID: "part-1",
tool: "bash",
})
const app = await testRender(() => (
<box width={80} height={4}>
<RunEntryContent commit={commit()} theme={RUN_THEME_FALLBACK} width={80} />
</box>
), {
width: 80,
height: 4,
})
try {
await app.renderOnce()
expect(app.captureCharFrame()).toContain("I")
setCommit({
kind: "tool",
text: "I need to inspect the codebase",
phase: "progress",
source: "tool",
messageID: "msg-1",
partID: "part-1",
tool: "bash",
})
await app.renderOnce()
expect(app.captureCharFrame()).toContain("I need to inspect the codebase")
} finally {
app.renderer.destroy()
}
})

View file

@ -277,3 +277,78 @@ test("renders structured write finals as native code blocks", async () => {
destroyCommits(commits)
}
})
test("renders promoted task-result markdown without leading blank rows", async () => {
const out = await createTestRenderer({
width: 80,
screenMode: "split-footer",
footerHeight: 6,
externalOutputMode: "capture-stdout",
consoleMode: "disabled",
})
active.push(out.renderer)
const treeSitterClient = new MockTreeSitterClient({ autoResolveTimeout: 0 })
treeSitterClient.setMockResult({ highlights: [] })
const scrollback = new RunScrollbackStream(out.renderer, RUN_THEME_FALLBACK, {
treeSitterClient,
wrote: false,
})
await scrollback.append({
kind: "tool",
text: "",
phase: "final",
source: "tool",
partID: "task-1",
messageID: "msg-1",
tool: "task",
toolState: "completed",
part: {
id: "task-1",
sessionID: "session-1",
messageID: "msg-1",
type: "tool",
callID: "call-1",
tool: "task",
state: {
status: "completed",
input: {
description: "Explore run.ts",
subagent_type: "explore",
},
output: [
"task_id: child-1 (for resuming to continue this task if needed)",
"",
"<task_result>",
"Location: `/tmp/run.ts`",
"",
"Summary:",
"- Local interactive mode",
"- Attach mode",
"</task_result>",
].join("\n"),
metadata: {
sessionId: "child-1",
},
time: {
start: 1,
end: 2,
},
},
} as never,
})
const commits = claimCommits(out.renderer)
try {
expect(commits.length).toBeGreaterThan(0)
const rendered = commits.map((item) => decoder.decode(item.snapshot.getRealCharBytes(true))).join("")
expect(rendered.startsWith("\n")).toBe(false)
expect(rendered.split("\n")[0]?.trim()).toBe("Location: `/tmp/run.ts`")
expect(rendered).toContain("Summary:")
expect(rendered).toContain("Local interactive mode")
} finally {
destroyCommits(commits)
}
})