mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-07 09:26:26 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0408dc0373 | ||
|
|
64c870e166 |
@@ -92,15 +92,18 @@ export const Plugin = {
|
||||
source,
|
||||
})
|
||||
const result = yield* fileMutation.writeTextPreservingBom({ target, content: input.content })
|
||||
const bom = (yield* FileMutation.readText(environment.files, target.absolute)).bom
|
||||
if (yield* formatter.file(target.absolute)) {
|
||||
yield* FileMutation.syncTextBom(environment.files, target.absolute, bom)
|
||||
const written = yield* FileMutation.readText(environment.files, target.absolute)
|
||||
const formatted = (yield* formatter.file(target.absolute))
|
||||
? yield* FileMutation.syncTextBom(environment.files, target.absolute, written.bom)
|
||||
: written.text
|
||||
return {
|
||||
output: result,
|
||||
content: toModelContent(result),
|
||||
metadata: {
|
||||
files: [fileDiff(result.resource, current?.text ?? "", formatted, current ? "modified" : "added")],
|
||||
},
|
||||
}
|
||||
return result
|
||||
}).pipe(
|
||||
Effect.map((output) => ({ output, content: toModelContent(output) })),
|
||||
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
|
||||
),
|
||||
}).pipe(Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error }))),
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -119,6 +119,17 @@ describe("WriteTool", () => {
|
||||
existed: false,
|
||||
},
|
||||
content: [{ type: "text", text: "Created file successfully: src/new.txt" }],
|
||||
metadata: {
|
||||
files: [
|
||||
{
|
||||
file: "src/new.txt",
|
||||
status: "added",
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
patch: expect.stringContaining("+created"),
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "src", "new.txt"), "utf8"))).toBe(
|
||||
"created",
|
||||
@@ -158,6 +169,20 @@ describe("WriteTool", () => {
|
||||
Effect.gen(function* () {
|
||||
expect(yield* executeTool(registry, call({ path: "formatted.txt", content: "format me" }))).toMatchObject({
|
||||
status: "completed",
|
||||
metadata: {
|
||||
files: [
|
||||
{
|
||||
file: "formatted.txt",
|
||||
status: "added",
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
patch: expect.stringContaining("+FORMAT ME"),
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(fixture.assertions[0]?.metadata).toMatchObject({
|
||||
files: [{ patch: expect.stringContaining("+format me") }],
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("FORMAT ME")
|
||||
}),
|
||||
@@ -180,6 +205,7 @@ describe("WriteTool", () => {
|
||||
if (settled.status !== "completed") return
|
||||
expect(settled.content).toEqual([{ type: "text", text: "Wrote file successfully: existing.txt" }])
|
||||
expect(settled.output).toMatchObject({ resource: "existing.txt", existed: true })
|
||||
expect(settled.metadata).toEqual(fixture.assertions[0]?.metadata)
|
||||
expect(fixture.assertions[0]?.metadata).toMatchObject({
|
||||
files: [
|
||||
{
|
||||
@@ -215,7 +241,22 @@ describe("WriteTool", () => {
|
||||
Effect.andThen(
|
||||
withTool(tmp.path, fixture, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
yield* executeTool(registry, call({ path: "preserved.txt", content: "after" }, "call-preserved"))
|
||||
const settled = yield* executeTool(
|
||||
registry,
|
||||
call({ path: "preserved.txt", content: "after" }, "call-preserved"),
|
||||
)
|
||||
expect(settled).toMatchObject({
|
||||
metadata: {
|
||||
files: [
|
||||
{
|
||||
file: "preserved.txt",
|
||||
status: "modified",
|
||||
patch: expect.stringMatching(/-before[\s\S]*\+after/),
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
if (settled.status === "completed") expect(JSON.stringify(settled.metadata)).not.toContain("\uFEFF")
|
||||
yield* executeTool(
|
||||
registry,
|
||||
call({ path: "deduplicated.txt", content: "\uFEFFafter" }, "call-deduplicated"),
|
||||
@@ -230,6 +271,27 @@ describe("WriteTool", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("reports zero-change metadata for empty and unchanged writes", () =>
|
||||
withTempDir((tmp) => {
|
||||
const fixture = makeWriteFixture()
|
||||
return withTool(tmp.path, fixture, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
const created = yield* executeTool(registry, call({ path: "empty.txt", content: "" }, "call-empty"))
|
||||
expect(created).toMatchObject({
|
||||
status: "completed",
|
||||
metadata: { files: [{ file: "empty.txt", status: "added", additions: 0, deletions: 0 }] },
|
||||
})
|
||||
const unchanged = yield* executeTool(registry, call({ path: "empty.txt", content: "" }, "call-unchanged"))
|
||||
expect(unchanged).toMatchObject({
|
||||
status: "completed",
|
||||
metadata: { files: [{ file: "empty.txt", status: "modified", additions: 0, deletions: 0 }] },
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "empty.txt"), "utf8"))).toBe("")
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("accepts an absolute file path inside the active Location", () =>
|
||||
withTempDir((tmp) => {
|
||||
const fixture = makeWriteFixture()
|
||||
|
||||
@@ -1,38 +1,49 @@
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
story("merges follow-up patches into one stack with a distinct file count", async ({ mount }, info) => {
|
||||
const root = await mount("current-tool-group--patch-follow-ups")
|
||||
const group = root.locator('[data-component="collapsed-tool-group"]')
|
||||
const patches = group.locator('[data-component="apply-patch-tool"]')
|
||||
await expect(patches).toHaveCount(1)
|
||||
await expect(patches.getByText("2 files", { exact: true })).toBeVisible()
|
||||
const first = patches.locator('[data-scope="apply-patch"] button').filter({ hasText: "a.ts" })
|
||||
await first.click()
|
||||
await expect(first).toHaveAttribute("aria-expanded", "true")
|
||||
await root.getByRole("button", { name: "Start follow-up patch" }).click()
|
||||
await expect(
|
||||
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
|
||||
).toHaveText(/^3 /)
|
||||
await expect(patches).toHaveCount(1)
|
||||
await expect(patches.getByText("2 files", { exact: true })).toBeVisible()
|
||||
await root.getByRole("button", { name: "Finish follow-up patch" }).click()
|
||||
await expect(patches).toHaveCount(1)
|
||||
await expect(patches.getByText("3 files", { exact: true })).toBeVisible()
|
||||
await expect(patches.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts"])
|
||||
await expect(first).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(patches.locator('[data-component="file"]')).toBeVisible()
|
||||
await group.screenshot({ path: info.outputPath("merged.png") })
|
||||
})
|
||||
|
||||
for (const separator of ["shell", "error", "reasoning"]) {
|
||||
story(`does not merge patches across an intervening ${separator}`, async ({ mount }) => {
|
||||
const root = await mount("current-tool-group--patch-follow-ups", { args: { separator } })
|
||||
await root.getByRole("button", { name: "Finish follow-up patch" }).click()
|
||||
for (const tool of ["patch", "edit", "write", "mixed"]) {
|
||||
story(`merges follow-up ${tool} calls into one stack with a distinct file count`, async ({ mount }, info) => {
|
||||
const root = await mount("current-tool-group--patch-follow-ups", { args: { tool } })
|
||||
const group = root.locator('[data-component="collapsed-tool-group"]')
|
||||
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(2)
|
||||
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "a.ts", "c.ts"])
|
||||
if (separator === "error") await expect(group.locator('[data-kind="tool-error-card"]')).toBeVisible()
|
||||
const patches = group.locator('[data-component="apply-patch-tool"]')
|
||||
await expect(patches).toHaveCount(1)
|
||||
await expect(patches.getByText("2 files", { exact: true })).toBeVisible()
|
||||
await expect(
|
||||
patches.getByLabel(tool === "mixed" ? "Edit" : `${tool[0].toUpperCase()}${tool.slice(1)}`, { exact: true }),
|
||||
).toBeVisible()
|
||||
const first = patches.locator('[data-scope="apply-patch"] button').filter({ hasText: "a.ts" })
|
||||
await first.click()
|
||||
await expect(first).toHaveAttribute("aria-expanded", "true")
|
||||
await root.getByRole("button", { name: "Start follow-up patch" }).click()
|
||||
await expect(group).toHaveAttribute(
|
||||
"data-timeline-part-ids",
|
||||
tool === "patch" ? "patch_shell,patch_first,patch_next" : "patch_shell,first_0,first_1,next_0,next_1",
|
||||
)
|
||||
await expect(patches).toHaveCount(1)
|
||||
await expect(patches.getByText("2 files", { exact: true })).toBeVisible()
|
||||
await root.getByRole("button", { name: "Finish follow-up patch" }).click()
|
||||
await expect(patches).toHaveCount(1)
|
||||
await expect(patches.getByText("3 files", { exact: true })).toBeVisible()
|
||||
await expect(patches.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts"])
|
||||
await expect(first).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(patches.locator('[data-component="file"]')).toHaveCount(2)
|
||||
await expect(patches.locator('[data-component="file"]').nth(0)).toBeVisible()
|
||||
await expect(patches.locator('[data-component="file"]').nth(1)).toBeVisible()
|
||||
await expect(patches.locator('[data-component="apply-patch-file-diff"]')).toHaveCount(2)
|
||||
await group.screenshot({ path: info.outputPath("merged.png") })
|
||||
})
|
||||
|
||||
for (const separator of ["shell", "error", "reasoning"]) {
|
||||
story(`does not merge ${tool} calls across an intervening ${separator}`, async ({ mount }) => {
|
||||
const root = await mount("current-tool-group--patch-follow-ups", { args: { separator, tool } })
|
||||
await expect(root.locator("[data-file-tool]")).toHaveAttribute("data-file-tool", tool)
|
||||
await expect(root.locator("[data-file-separator]")).toHaveAttribute("data-file-separator", separator)
|
||||
await root.getByRole("button", { name: "Finish follow-up patch" }).click()
|
||||
const group = root.locator('[data-component="collapsed-tool-group"]')
|
||||
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(2)
|
||||
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "a.ts", "c.ts"])
|
||||
if (separator === "error") await expect(group.locator('[data-kind="tool-error-card"]')).toBeVisible()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
story("does not retain patch files in the wrong batch when thoughts are shown", async ({ mount }) => {
|
||||
@@ -46,3 +57,33 @@ story("does not retain patch files in the wrong batch when thoughts are shown",
|
||||
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(2)
|
||||
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "a.ts", "c.ts"])
|
||||
})
|
||||
|
||||
for (const placement of ["separate", "grouped"]) {
|
||||
story(
|
||||
`preserves mixed file disclosures through append and split in ${placement} timeline rows`,
|
||||
async ({ mount, page }) => {
|
||||
await page.setViewportSize({ width: placement === "grouped" ? 390 : 1280, height: 900 })
|
||||
const root = await mount("current-tool-group--patch-follow-ups", {
|
||||
args: { tool: "mixed", placement, separator: "reasoning" },
|
||||
})
|
||||
const timeline = root.locator('[data-component="session-timeline"]')
|
||||
if (placement === "grouped") await timeline.getByRole("button", { name: /^Used / }).click()
|
||||
const stacks = timeline.locator('[data-component="apply-patch-tool"]')
|
||||
const first = stacks.first().locator('[data-scope="apply-patch"] button').filter({ hasText: "a.ts" })
|
||||
await expect(stacks).toHaveCount(1)
|
||||
if (placement === "grouped") await first.click()
|
||||
await expect(first).toHaveAttribute("aria-expanded", "true")
|
||||
await root.getByRole("button", { name: "Hide thoughts", exact: true }).click()
|
||||
await root.getByRole("button", { name: "Finish follow-up patch" }).click()
|
||||
await expect(stacks).toHaveCount(1)
|
||||
await expect(stacks.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts"])
|
||||
await expect(first).toHaveAttribute("aria-expanded", "true")
|
||||
await root.getByRole("button", { name: "Show thoughts", exact: true }).click()
|
||||
await expect(stacks).toHaveCount(2)
|
||||
await expect(stacks.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "a.ts", "c.ts"])
|
||||
await expect(first).toHaveAttribute("aria-expanded", "true")
|
||||
const second = stacks.nth(1).locator('[data-scope="apply-patch"] button').filter({ hasText: "a.ts" })
|
||||
await expect(second).toHaveAttribute("aria-expanded", "false")
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -92,3 +92,36 @@ story("keeps patch file disclosures independent", async ({ mount }) => {
|
||||
await expect(modified).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(deleted).toHaveAttribute("aria-expanded", "false")
|
||||
})
|
||||
|
||||
for (const placement of ["separate", "grouped"]) {
|
||||
for (const profile of [
|
||||
{ tool: "edit", empty: false, noOp: false },
|
||||
{ tool: "write", empty: false, noOp: false },
|
||||
{ tool: "write", empty: true, noOp: false },
|
||||
{ tool: "edit", empty: false, noOp: true },
|
||||
{ tool: "write", empty: true, noOp: true },
|
||||
]) {
|
||||
story(
|
||||
`keeps ${profile.empty ? "empty " : ""}${profile.tool} input fallback with ${profile.noOp ? "zero-change" : "missing"} metadata in ${placement} rows`,
|
||||
async ({ mount }) => {
|
||||
const root = await mount("current-session-file-changes--file-tool-fallbacks", {
|
||||
args: { ...profile, timeline: true, placement },
|
||||
})
|
||||
const timeline = root.locator('[data-component="session-timeline"]')
|
||||
if (placement === "grouped") await timeline.getByRole("button", { name: /^Used / }).click()
|
||||
const fallback = timeline.locator(`[data-component="${profile.tool}-tool"]`)
|
||||
await expect(fallback).toHaveCount(1)
|
||||
await expect(fallback.getByText("example.ts", { exact: true })).toBeVisible()
|
||||
if (placement === "grouped") await fallback.getByRole("button", { name: /example\.ts/ }).click()
|
||||
await expect(fallback.locator('[data-component="file"]')).toBeAttached()
|
||||
if (!profile.empty) await expect(fallback.locator('[data-component="file"]')).toBeVisible()
|
||||
await root.getByRole("button", { name: "Complete file tool" }).click()
|
||||
await expect(fallback.getByText("example.ts", { exact: true })).toBeVisible()
|
||||
await expect(fallback.getByRole("button", { name: /example\.ts/ })).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(fallback.locator('[data-component="file"]')).toBeAttached()
|
||||
if (!profile.empty) await expect(fallback.locator('[data-component="file"]')).toBeVisible()
|
||||
await expect(timeline.locator('[data-component="apply-patch-tool"]')).toHaveCount(0)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { JsonValue, SessionMessageAssistantTool } from "@opencode-ai/client/promise"
|
||||
import { currentContentDefaultOpen } from "./current-tool-state"
|
||||
import { currentContentDefaultOpen, currentToolCanGroupFiles } from "./current-tool-state"
|
||||
|
||||
function tool(name: string, files: JsonValue[] = []): SessionMessageAssistantTool {
|
||||
return {
|
||||
@@ -17,6 +17,33 @@ function tool(name: string, files: JsonValue[] = []): SessionMessageAssistantToo
|
||||
}
|
||||
}
|
||||
|
||||
describe("current file grouping eligibility", () => {
|
||||
test.each(["edit", "write"])("keeps %s input fallbacks unless changed files are available", (name) => {
|
||||
const unchanged = { file: "src/example.ts", patch: "", status: "modified", additions: 0, deletions: 0 }
|
||||
expect(currentToolCanGroupFiles(tool(name, [unchanged]))).toBe(false)
|
||||
expect(currentToolCanGroupFiles(tool(name))).toBe(false)
|
||||
expect(currentToolCanGroupFiles({ ...tool(name), state: { status: "running", input: {}, metadata: {} } })).toBe(
|
||||
false,
|
||||
)
|
||||
expect(
|
||||
currentToolCanGroupFiles(
|
||||
tool(name, [{ ...unchanged, patch: "@@ -1 +1 @@\n-before\n+after", additions: 1, deletions: 1 }]),
|
||||
),
|
||||
).toBe(true)
|
||||
expect(
|
||||
currentToolCanGroupFiles({
|
||||
...tool(name),
|
||||
state: {
|
||||
status: "error",
|
||||
input: {},
|
||||
error: { type: "ToolError", message: "failed" },
|
||||
metadata: { files: [{ ...unchanged, additions: 1 }] },
|
||||
},
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("current content default open", () => {
|
||||
test("uses the shell disclosure preference", () => {
|
||||
expect(currentContentDefaultOpen(tool("shell"), true, false)).toBe(true)
|
||||
|
||||
@@ -14,6 +14,23 @@ export function currentToolMetadata(tool: SessionMessageAssistantTool): Record<s
|
||||
return tool.state.metadata ?? empty
|
||||
}
|
||||
|
||||
export function currentToolCanGroupFiles(tool: SessionMessageAssistantTool) {
|
||||
if (tool.state.status === "error") return false
|
||||
if (tool.name === "patch") return true
|
||||
// Keep the input-based renderer when older edit/write results have no file diffs.
|
||||
if (tool.name !== "edit" && tool.name !== "write") return false
|
||||
const files = currentToolMetadata(tool).files
|
||||
if (!Array.isArray(files)) return false
|
||||
// Empty and unchanged results still need their filename and content preview.
|
||||
return files.some(
|
||||
(file) =>
|
||||
!!file &&
|
||||
typeof file === "object" &&
|
||||
(("additions" in file && typeof file.additions === "number" && file.additions > 0) ||
|
||||
("deletions" in file && typeof file.deletions === "number" && file.deletions > 0)),
|
||||
)
|
||||
}
|
||||
|
||||
export function currentToolOutput(tool: SessionMessageAssistantTool) {
|
||||
if (tool.state.status === "running") {
|
||||
const output = tool.state.metadata.output
|
||||
|
||||
@@ -264,44 +264,98 @@ export const CreatedANewFile = {
|
||||
}
|
||||
|
||||
export const FileToolFallbacks = {
|
||||
args: { tool: "edit", empty: false, forceOpen: false, controlled: true },
|
||||
args: {
|
||||
tool: "edit",
|
||||
empty: false,
|
||||
forceOpen: false,
|
||||
controlled: true,
|
||||
timeline: false,
|
||||
placement: "separate",
|
||||
noOp: false,
|
||||
},
|
||||
argTypes: {
|
||||
tool: { control: "select", options: ["edit", "write"] },
|
||||
empty: { control: "boolean" },
|
||||
forceOpen: { control: "boolean" },
|
||||
controlled: { control: "boolean" },
|
||||
timeline: { control: "boolean" },
|
||||
noOp: { control: "boolean" },
|
||||
placement: { control: "select", options: ["separate", "grouped"] },
|
||||
},
|
||||
render: (args: { tool: string; empty: boolean; forceOpen: boolean; controlled: boolean }) => {
|
||||
render: (args: {
|
||||
tool: string
|
||||
empty: boolean
|
||||
forceOpen: boolean
|
||||
controlled: boolean
|
||||
timeline: boolean
|
||||
noOp: boolean
|
||||
placement: "separate" | "grouped"
|
||||
}) => {
|
||||
const [state, setState] = createStore({ completed: false, open: false })
|
||||
const document = createMemo(() =>
|
||||
storyDocument([
|
||||
storyTool(
|
||||
"tool_file_fallback",
|
||||
args.tool,
|
||||
state.completed ? "completed" : "running",
|
||||
{
|
||||
path: "src/example.ts",
|
||||
oldString: "export const before = true\n",
|
||||
newString: "export const after = true\n",
|
||||
content: args.empty ? "" : "export const written = true\n",
|
||||
},
|
||||
{
|
||||
metadata:
|
||||
state.completed && args.noOp
|
||||
? {
|
||||
files: [
|
||||
{
|
||||
file: "src/example.ts",
|
||||
patch: createTwoFilesPatch("src/example.ts", "src/example.ts", "", ""),
|
||||
status: "modified",
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
},
|
||||
],
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
),
|
||||
]),
|
||||
)
|
||||
return (
|
||||
<section class="mx-auto flex w-full max-w-[860px] flex-col gap-4 p-6">
|
||||
<button type="button" onClick={() => setState("completed", true)}>
|
||||
Complete file tool
|
||||
</button>
|
||||
<CurrentSessionProviders document={storyDocument([])}>
|
||||
<ToolDisplay
|
||||
id="tool_file_fallback"
|
||||
tool={args.tool}
|
||||
status={state.completed ? "completed" : "running"}
|
||||
input={{
|
||||
path: "src/example.ts",
|
||||
oldString: "export const before = true\n",
|
||||
newString: "export const after = true\n",
|
||||
content: args.empty ? "" : "export const written = true\n",
|
||||
}}
|
||||
metadata={{
|
||||
diagnostics: state.completed
|
||||
? {
|
||||
"src/example.ts": [
|
||||
{ severity: 1, message: "Example diagnostic", range: { start: { line: 0, character: 0 } } },
|
||||
],
|
||||
}
|
||||
: {},
|
||||
}}
|
||||
open={args.controlled ? state.open : undefined}
|
||||
onOpenChange={(open) => setState("open", open)}
|
||||
forceOpen={args.forceOpen}
|
||||
/>
|
||||
<CurrentSessionProviders document={document()}>
|
||||
{args.timeline ? (
|
||||
<SessionTimeline document={document()} editToolDefaultOpen={args.placement === "separate"} />
|
||||
) : (
|
||||
<ToolDisplay
|
||||
id="tool_file_fallback"
|
||||
tool={args.tool}
|
||||
status={state.completed ? "completed" : "running"}
|
||||
input={{
|
||||
path: "src/example.ts",
|
||||
oldString: "export const before = true\n",
|
||||
newString: "export const after = true\n",
|
||||
content: args.empty ? "" : "export const written = true\n",
|
||||
}}
|
||||
metadata={{
|
||||
diagnostics: state.completed
|
||||
? {
|
||||
"src/example.ts": [
|
||||
{ severity: 1, message: "Example diagnostic", range: { start: { line: 0, character: 0 } } },
|
||||
],
|
||||
}
|
||||
: {},
|
||||
}}
|
||||
open={args.controlled ? state.open : undefined}
|
||||
onOpenChange={(open) => setState("open", open)}
|
||||
forceOpen={args.forceOpen}
|
||||
/>
|
||||
)}
|
||||
</CurrentSessionProviders>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -8,7 +8,12 @@ import type {
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { Option, Schema } from "effect"
|
||||
import { createMemo, mapArray, type Accessor } from "solid-js"
|
||||
import { currentContentDefaultOpen, currentToolFailed, currentToolHasLoadedFiles } from "../message/current-tool-state"
|
||||
import {
|
||||
currentContentDefaultOpen,
|
||||
currentToolCanGroupFiles,
|
||||
currentToolFailed,
|
||||
currentToolHasLoadedFiles,
|
||||
} from "../message/current-tool-state"
|
||||
import { TimelineRow, type PartGroup, type PartRef, type TimelineRowMap } from "./timeline-row"
|
||||
import { timelineCategory, timelineNoticeRequired, type TimelineDetail } from "./detail"
|
||||
|
||||
@@ -597,7 +602,7 @@ function groupContent(
|
||||
detail?: TimelineDetail,
|
||||
): PartGroup[] {
|
||||
const groups: PartGroup[] = []
|
||||
let adjacent: { type: "context" | "patch" | "edit"; refs: PartRef[]; tools: boolean } | undefined
|
||||
let adjacent: { type: "context" | "file"; refs: PartRef[]; tools: boolean } | undefined
|
||||
const flush = () => {
|
||||
const current = adjacent
|
||||
const first = current?.refs[0]
|
||||
@@ -665,8 +670,7 @@ function toolGroupType(
|
||||
const category = timelineCategory(content)!
|
||||
if (detail[category].placement === "grouped") return "context"
|
||||
if (currentToolFailed(content)) return undefined
|
||||
if (content.name === "patch") return "patch"
|
||||
if (content.name === "edit") return "edit"
|
||||
if (currentToolCanGroupFiles(content)) return "file"
|
||||
return undefined
|
||||
}
|
||||
if (content.name === "question" || currentToolHasLoadedFiles(content)) return undefined
|
||||
@@ -684,8 +688,7 @@ function toolGroupType(
|
||||
)
|
||||
return undefined
|
||||
if (currentContentDefaultOpen(content, shellExpanded, editExpanded) !== true) return "context"
|
||||
if (content.name === "patch") return "patch"
|
||||
if (content.name === "edit") return "edit"
|
||||
if (currentToolCanGroupFiles(content)) return "file"
|
||||
return undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
SessionMessageAssistantTool,
|
||||
SessionMessageInfo,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { storyDocument, storyTool } from "../storybook/current-session-scenarios"
|
||||
import { storyDocument, storyPatchFile, storyTool } from "../storybook/current-session-scenarios"
|
||||
import { createTimelineProjection, Timeline, TimelineRow } from "./projection"
|
||||
|
||||
describe("current session timeline rows", () => {
|
||||
@@ -625,7 +625,7 @@ describe("current session timeline rows", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("groups adjacent successful patches and leaves failed patches separate", () => {
|
||||
test("groups adjacent patch, edit, and write calls and leaves failed calls separate", () => {
|
||||
const source = [
|
||||
{ id: "msg_user", type: "user", text: "edit", time: { created: 1 } },
|
||||
{
|
||||
@@ -681,16 +681,23 @@ describe("current session timeline rows", () => {
|
||||
type: "tool",
|
||||
id: "tool_edit_1",
|
||||
name: "edit",
|
||||
state: { status: "running", input: {}, metadata: { files: [] } },
|
||||
state: { status: "running", input: {}, metadata: { files: [storyPatchFile("src/edited.ts")] } },
|
||||
time: { created: 9 },
|
||||
},
|
||||
{
|
||||
type: "tool",
|
||||
id: "tool_edit_2",
|
||||
name: "edit",
|
||||
state: { status: "running", input: {}, metadata: { files: [] } },
|
||||
state: { status: "running", input: {}, metadata: { files: [storyPatchFile("src/edited.ts")] } },
|
||||
time: { created: 10 },
|
||||
},
|
||||
{
|
||||
type: "tool",
|
||||
id: "tool_write",
|
||||
name: "write",
|
||||
state: { status: "running", input: {}, metadata: { files: [storyPatchFile("src/written.ts")] } },
|
||||
time: { created: 11 },
|
||||
},
|
||||
],
|
||||
time: { created: 2, completed: 8 },
|
||||
},
|
||||
@@ -716,14 +723,11 @@ describe("current session timeline rows", () => {
|
||||
{
|
||||
type: "file",
|
||||
key: "part:msg_assistant:tool_patch_3",
|
||||
refs: [{ messageID: "msg_assistant", partID: "tool_patch_3" }],
|
||||
},
|
||||
{
|
||||
type: "file",
|
||||
key: "part:msg_assistant:tool_edit_1",
|
||||
refs: [
|
||||
{ messageID: "msg_assistant", partID: "tool_patch_3" },
|
||||
{ messageID: "msg_assistant", partID: "tool_edit_1" },
|
||||
{ messageID: "msg_assistant", partID: "tool_edit_2" },
|
||||
{ messageID: "msg_assistant", partID: "tool_write" },
|
||||
],
|
||||
},
|
||||
])
|
||||
@@ -790,8 +794,8 @@ describe("current session timeline rows", () => {
|
||||
test.each([
|
||||
{ shell: false, edit: false, types: ["context"] },
|
||||
{ shell: true, edit: false, types: ["part", "context"] },
|
||||
{ shell: false, edit: true, types: ["context", "file", "part", "file", "context"] },
|
||||
{ shell: true, edit: true, types: ["part", "file", "part", "file", "context"] },
|
||||
{ shell: false, edit: true, types: ["context", "part", "part", "file", "context"] },
|
||||
{ shell: true, edit: true, types: ["part", "part", "part", "file", "context"] },
|
||||
])("keeps tools expanded by settings outside collapsed groups ($shell, $edit)", ({ shell, edit, types }) => {
|
||||
const source = [
|
||||
{ id: "msg_user", type: "user", text: "work", time: { created: 1 } },
|
||||
|
||||
@@ -23,7 +23,7 @@ import type { ContextGroupPart } from "../tools/tool-renderer"
|
||||
import { SessionRetry } from "../components/session-retry"
|
||||
import { SessionError } from "../components/session-error"
|
||||
import { timelineCategory, type TimelineDetail } from "./detail"
|
||||
import { currentToolFailed } from "../message/current-tool-state"
|
||||
import { currentToolCanGroupFiles, currentToolFailed } from "../message/current-tool-state"
|
||||
import {
|
||||
createReactiveTimelineProjection,
|
||||
Timeline,
|
||||
@@ -72,7 +72,7 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
if (row._tag !== "AssistantPart" || row.group.type !== "context") return
|
||||
row.group.refs.forEach((ref) => {
|
||||
const content = Timeline.resolveContent(input.projection.messageByID().get(ref.messageID), ref.partID)
|
||||
if (content?.type !== "tool" || content.name !== "patch" || content.state.status === "error") return
|
||||
if (content?.type !== "tool" || !currentToolCanGroupFiles(content)) return
|
||||
const part = `${ref.messageID}:${ref.partID}`
|
||||
const key = patchGroupKeys.get(part)
|
||||
if (key && !owners.has(key)) owners.set(key, part)
|
||||
@@ -223,7 +223,7 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
const open = input.disclosure.value(`${row().group.key}:file:${path}`)
|
||||
if (open !== undefined) return open
|
||||
if (input.timelineDetail) return input.timelineDetail().edit.details === "expanded"
|
||||
if (tools()[0]?.name !== "edit" || path !== firstPath()) return false
|
||||
if (!["edit", "write"].includes(tools()[0]?.name ?? "") || path !== firstPath()) return false
|
||||
return input.disclosure.value(row().group.key) ?? input.editToolDefaultOpen()
|
||||
}}
|
||||
onFileOpenChange={(path, open) => input.disclosure.set(`${row().group.key}:file:${path}`, open)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createTwoFilesPatch } from "diff"
|
||||
import { CurrentSessionProviders } from "../storybook/current-session-story"
|
||||
import { storyDocument, storyTool } from "../storybook/current-session-scenarios"
|
||||
import { type ContextGroupPart, CurrentContextToolGroup } from "./tool-renderer"
|
||||
import { SessionTimeline } from "../timeline/session-timeline"
|
||||
|
||||
export default {
|
||||
title: "OpenCode/Work/Tool group",
|
||||
@@ -78,36 +79,59 @@ export const MixedReasoning = {
|
||||
}
|
||||
|
||||
export const PatchFollowUps = {
|
||||
args: { separator: "none" },
|
||||
argTypes: { separator: { control: "select", options: ["none", "shell", "error", "reasoning"] } },
|
||||
render: (args: { separator: string }) => {
|
||||
args: { separator: "none", tool: "patch", placement: "used" },
|
||||
argTypes: {
|
||||
separator: { control: "select", options: ["none", "shell", "error", "reasoning"] },
|
||||
tool: { control: "select", options: ["patch", "edit", "write", "mixed"] },
|
||||
placement: { control: "select", options: ["used", "separate", "grouped"] },
|
||||
},
|
||||
render: (args: { separator: string; tool: string; placement: "used" | "separate" | "grouped" }) => {
|
||||
const [state, setState] = createStore({ phase: "initial", open: true, reasoning: true })
|
||||
const source = (value: number) => `export const value = ${value}\n`
|
||||
const file = (path: string, before: number, after: number) => ({
|
||||
file: path,
|
||||
status: "modified",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
patch: createTwoFilesPatch(
|
||||
path,
|
||||
path,
|
||||
`export const value = ${before}\n`,
|
||||
`export const value = ${after}\n`,
|
||||
"",
|
||||
"",
|
||||
{ context: Infinity },
|
||||
),
|
||||
patch: createTwoFilesPatch(path, path, source(before), source(after)),
|
||||
})
|
||||
const changes = (next: boolean) => {
|
||||
const files = next
|
||||
? [file("src/a.ts", 1, 2), file("src/c.ts", 0, 1)]
|
||||
: [file("src/a.ts", 0, 1), file("src/b.ts", 0, 1)]
|
||||
const status = next && state.phase === "running" ? "running" : "completed"
|
||||
if (args.tool === "patch")
|
||||
return [
|
||||
storyTool(
|
||||
next ? "patch_next" : "patch_first",
|
||||
"patch",
|
||||
status,
|
||||
{},
|
||||
{ metadata: status === "running" ? {} : { files } },
|
||||
),
|
||||
]
|
||||
return files.map((file, index) => {
|
||||
const name = args.tool === "mixed" ? (next ? ["patch", "write"] : ["edit", "write"])[index]! : args.tool
|
||||
return storyTool(
|
||||
`${next ? "next" : "first"}_${index}`,
|
||||
name,
|
||||
status,
|
||||
name === "patch"
|
||||
? { patchText: `Update ${file.file}` }
|
||||
: name === "write"
|
||||
? { path: file.file, content: source(next && index === 0 ? 2 : 1) }
|
||||
: {
|
||||
path: file.file,
|
||||
oldString: source(next && index === 0 ? 1 : 0),
|
||||
newString: source(next && index === 0 ? 2 : 1),
|
||||
},
|
||||
{ metadata: status === "running" ? {} : { files: [file] } },
|
||||
)
|
||||
})
|
||||
}
|
||||
const parts = createMemo<ContextGroupPart[]>(() => [
|
||||
storyTool("patch_shell", "shell", "completed", { command: "printf checked" }, { output: "checked" }),
|
||||
storyTool(
|
||||
"patch_first",
|
||||
"patch",
|
||||
"completed",
|
||||
{},
|
||||
{
|
||||
metadata: { files: [file("src/a.ts", 0, 1), file("src/b.ts", 0, 1)] },
|
||||
},
|
||||
),
|
||||
...changes(false),
|
||||
...(state.phase === "initial"
|
||||
? []
|
||||
: [
|
||||
@@ -115,7 +139,15 @@ export const PatchFollowUps = {
|
||||
? [storyTool("patch_separator", "shell", "completed", { command: "printf checked" })]
|
||||
: []),
|
||||
...(args.separator === "error"
|
||||
? [storyTool("patch_error", "patch", "error", {}, { error: "Patch failed" })]
|
||||
? [
|
||||
storyTool(
|
||||
"patch_error",
|
||||
args.tool === "mixed" ? "write" : args.tool,
|
||||
"error",
|
||||
{},
|
||||
{ error: "File change failed" },
|
||||
),
|
||||
]
|
||||
: []),
|
||||
...(args.separator === "reasoning" && state.reasoning
|
||||
? [
|
||||
@@ -126,19 +158,16 @@ export const PatchFollowUps = {
|
||||
},
|
||||
]
|
||||
: []),
|
||||
storyTool(
|
||||
"patch_next",
|
||||
"patch",
|
||||
state.phase === "running" ? "running" : "completed",
|
||||
{},
|
||||
{
|
||||
metadata: state.phase === "running" ? {} : { files: [file("src/a.ts", 1, 2), file("src/c.ts", 0, 1)] },
|
||||
},
|
||||
),
|
||||
...changes(true),
|
||||
]),
|
||||
])
|
||||
const document = createMemo(() => storyDocument(parts()))
|
||||
return (
|
||||
<section class="mx-auto flex w-full max-w-[860px] flex-col gap-4 p-6">
|
||||
<section
|
||||
class="mx-auto flex w-full max-w-[860px] flex-col gap-4 p-6"
|
||||
data-file-tool={args.tool}
|
||||
data-file-separator={args.separator}
|
||||
>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button type="button" onClick={() => setState("phase", "running")}>
|
||||
Start follow-up patch
|
||||
@@ -152,13 +181,20 @@ export const PatchFollowUps = {
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<CurrentSessionProviders document={storyDocument(parts())}>
|
||||
<CurrentContextToolGroup
|
||||
parts={parts()}
|
||||
busy={state.phase === "running"}
|
||||
open={state.open}
|
||||
onOpenChange={(open) => setState("open", open)}
|
||||
/>
|
||||
<CurrentSessionProviders document={document()}>
|
||||
<Show
|
||||
when={args.placement !== "used"}
|
||||
fallback={
|
||||
<CurrentContextToolGroup
|
||||
parts={parts()}
|
||||
busy={state.phase === "running"}
|
||||
open={state.open}
|
||||
onOpenChange={(open) => setState("open", open)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SessionTimeline document={document()} editToolDefaultOpen={args.placement === "separate"} />
|
||||
</Show>
|
||||
</CurrentSessionProviders>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -45,6 +45,7 @@ import type {
|
||||
import {
|
||||
currentToolError,
|
||||
currentToolHasLoadedFiles,
|
||||
currentToolCanGroupFiles,
|
||||
currentToolInput,
|
||||
currentToolMetadata,
|
||||
currentToolOutput,
|
||||
@@ -547,11 +548,10 @@ export function CurrentContextToolGroup(props: {
|
||||
}
|
||||
const previous = groups.at(-1)
|
||||
if (
|
||||
tool.name === "patch" &&
|
||||
tool.state.status !== "error" &&
|
||||
currentToolCanGroupFiles(tool) &&
|
||||
Array.isArray(previous) &&
|
||||
previous?.[0]?.name === "patch" &&
|
||||
previous[0].state.status !== "error"
|
||||
previous[0] &&
|
||||
currentToolCanGroupFiles(previous[0])
|
||||
) {
|
||||
previous.push(tool)
|
||||
return groups
|
||||
@@ -577,7 +577,7 @@ export function CurrentContextToolGroup(props: {
|
||||
const patchKeys = createMemo(() => {
|
||||
const keys = new Map<SessionMessageAssistantTool, string>()
|
||||
items().forEach((item) => {
|
||||
if (!Array.isArray(item) || item[0]?.name !== "patch" || item[0].state.status === "error") return
|
||||
if (!Array.isArray(item) || !item[0] || !currentToolCanGroupFiles(item[0])) return
|
||||
const key = props.patchGroupKey?.(item) ?? item[0].id
|
||||
item.forEach((tool) => keys.set(tool, key))
|
||||
})
|
||||
@@ -682,7 +682,7 @@ export function CurrentContextToolGroup(props: {
|
||||
when={tool().name === "skill" && group().length > 1 && skills().length === group().length}
|
||||
fallback={
|
||||
<Show
|
||||
when={tool().name === "patch" && tool().state.status !== "error"}
|
||||
when={currentToolCanGroupFiles(tool())}
|
||||
fallback={
|
||||
<ToolDisplay
|
||||
id={tool().id}
|
||||
@@ -836,7 +836,7 @@ export function CurrentFileToolGroup(props: {
|
||||
props.tools.some((tool) => tool.state.status === "streaming" || tool.state.status === "running"),
|
||||
)
|
||||
const render = ToolRegistry.render("patch") ?? GenericTool
|
||||
const tool = createMemo(() => (props.tools[0]?.name === "edit" ? "edit" : "patch"))
|
||||
const tool = createMemo(() => props.tools[0]?.name ?? "patch")
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -1879,7 +1879,11 @@ ToolRegistry.register({
|
||||
const files = createMemo(() => patchFileGroups(props.metadata.files))
|
||||
const [expanded, setExpanded] = createSignal<string[]>([])
|
||||
const title = createMemo(() =>
|
||||
props.tool === "edit" ? i18n.t("ui.messagePart.title.edit") : i18n.t("ui.tool.patch"),
|
||||
props.tool === "edit"
|
||||
? i18n.t("ui.messagePart.title.edit")
|
||||
: props.tool === "write"
|
||||
? i18n.t("ui.messagePart.title.write")
|
||||
: i18n.t("ui.tool.patch"),
|
||||
)
|
||||
const open = createMemo(() => {
|
||||
if (!props.fileOpen) return expanded()
|
||||
|
||||
Reference in New Issue
Block a user