Selaa lähdekoodia

subagent fixes

Simon Klee 1 päivä sitten
vanhempi
sitoutus
737975cae1

+ 8 - 1
packages/opencode/src/cli/cmd/run/scrollback.surface.ts

@@ -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

+ 42 - 41
packages/opencode/src/cli/cmd/run/scrollback.writer.tsx

@@ -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" }}
     />

+ 42 - 6
packages/opencode/src/cli/cmd/run/tool.ts

@@ -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
   }

+ 133 - 0
packages/opencode/test/cli/run/entry.body.test.ts

@@ -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({

+ 47 - 0
packages/opencode/test/cli/run/footer.view.test.tsx

@@ -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()
+  }
+})

+ 75 - 0
packages/opencode/test/cli/run/scrollback.surface.test.ts

@@ -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)
+  }
+})