mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-14 08:31:29 +00:00
subagent fixes
This commit is contained in:
parent
fd30530d86
commit
7b1de3e46c
6 changed files with 347 additions and 48 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" }}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue