Compare commits

..
Author SHA1 Message Date
Kit Langton 917dcd7004 fix(core): decouple model catalog refresh from persistence 2026-08-26 14:38:41 -04:00
101 changed files with 1698 additions and 5077 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"@opencode-ai/core": patch
"@opencode-ai/server": patch
---
Keep the live models.dev catalog independent of persistence so failed cache reads or writes cannot prevent model updates. Cache downloaded catalogs in local files on Bun and Node, and use the bundled snapshot plus in-memory refreshes on workerd instead of storing the catalog in each Durable Object's database. Explicit catalog files refresh locally without fetching or writing an implicit cache.
-2
View File
@@ -731,7 +731,6 @@
},
"devDependencies": {
"@happy-dom/global-registrator": "20.0.11",
"@playwright/test": "catalog:",
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@types/luxon": "catalog:",
@@ -837,7 +836,6 @@
"@opencode-ai/client": "workspace:*",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@playwright/test": "catalog:",
"@solidjs/meta": "catalog:",
"@storybook/addon-a11y": "10.4.4",
"@storybook/addon-docs": "10.4.4",
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-XHUy+Hk+RHUHREt4x0HfSzr3RlEvgBd4H/fV0rlXw2M=",
"aarch64-linux": "sha256-/gIaM62uV2X6KCnkSi6QjyT7t7uJ2L7u7CxXxPQYK+w=",
"aarch64-darwin": "sha256-PWG6ALh6kG7mnC6AzEuIAU54BEQ4IB+SyyQFGb/s+Dc=",
"x86_64-darwin": "sha256-TcgRDHG4CAT+XoCi0JNansONjg06ZPRmeLRsqmdJ4B4="
"x86_64-linux": "sha256-QWLIdvu985FH5I9cZJOAuoeFeXU+4Jx9RzBB9RPoeeQ=",
"aarch64-linux": "sha256-SSzGD5hMj2vFvyw+dUPR9g/ZH6qhs0ZyZ/DnltZt3N8=",
"aarch64-darwin": "sha256-CeFUxiV+e8pKho+YcSclC3soQBogoxNMxwyIMztAExU=",
"x86_64-darwin": "sha256-FYwcACzU72y0+KtOpFfU7ndak8vMasqMgd5NLS6+XtY="
}
}
-2
View File
@@ -1,5 +1,3 @@
src/assets/theme.css
e2e/test-results
e2e/playwright-report
component-tests/test-results
component-tests/playwright-report
@@ -1,26 +0,0 @@
import { expect, story } from "../../storybook/playwright/story"
// Moved from packages/app/e2e/regression/prompt-thinking-level.spec.ts
story("shows the thinking level control while relevant", async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--model-and-variant")
const composer = component.locator('[data-component="composer"]')
const input = composer.locator('[data-component="composer-editor"]')
const control = composer.getByRole("button", { name: "Choose model variant" })
await page.mouse.move(0, 0)
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
await expect(control).toBeVisible()
await control.click()
const high = page.getByRole("menuitemradio", { name: "high" })
await expect(high).toBeVisible()
await page.mouse.move(0, 0)
await expect(control).toBeVisible()
await expect(high).toBeVisible()
await high.click()
await input.focus()
await expect(control).toBeVisible()
await input.blur()
await expect(control).toBeVisible()
})
@@ -12,6 +12,54 @@ test.beforeEach(async ({ page }) => {
await openReview(page)
})
test("opens the comment editor when code is clicked", async ({ page }) => {
const review = page.locator('[data-component="session-review"]')
const line = review.getByText("export const value = 'after'", { exact: true })
await expectAppVisible(line)
await line.click()
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 2")
})
test("opens the comment editor when a line number is clicked", async ({ page }) => {
const review = page.locator('[data-component="session-review"]')
const lineNumber = review.locator('[data-column-number="1"]').last()
await expectAppVisible(lineNumber)
await lineNumber.click()
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
})
test("opens the comment editor for a line number range", async ({ page }) => {
const review = page.locator('[data-component="session-review"]')
const start = review.locator('[data-column-number="1"]').last()
const end = review.locator('[data-column-number="3"]').last()
await expectAppVisible(start)
await expectAppVisible(end)
await start.dragTo(end)
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on lines 1-3")
})
test("shows a comment button when a diff line is hovered", async ({ page }) => {
const review = page.locator('[data-component="session-review"]')
const line = review.getByText("export const first = 1", { exact: true })
await expectAppVisible(line)
const comment = review.getByRole("button", { name: "Comment", exact: true, includeHidden: true })
await expect(comment).toHaveCount(1)
await line.dispatchEvent("pointermove", { pointerType: "mouse", bubbles: true, composed: true })
await expect(comment).toBeVisible()
await expect(comment).toHaveCSS("pointer-events", "auto")
await comment.dispatchEvent("click")
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
})
test("stages a submitted line comment in the prompt context", async ({ page }) => {
page.on("request", (request) => {
expect.soft(request.method(), `unexpected ${request.method()} ${new URL(request.url()).pathname}`).toBe("GET")
@@ -201,15 +201,12 @@ test("editing restores the existing draft and replaces only the original queue p
await view.input.fill("my in-progress draft")
await original.click()
await expect(view.input).toHaveText("tighten the error copy")
await expect(view.input).toBeFocused()
await view.input.press("Escape")
await expect(view.input).toHaveText("my in-progress draft")
await original.click()
await expect(view.input).toHaveText("tighten the error copy")
await expect(view.input).toBeFocused()
await view.input.fill("tighten the error copy and add a retry hint")
await expect(view.input).toHaveText("tighten the error copy and add a retry hint")
await view.input.press("Enter")
await expect(view.rows.locator('[data-action="session-queue-edit"]')).toHaveText([
@@ -1,43 +1,16 @@
import { expect, test } from "@playwright/test"
import {
assistantMessage,
setupTimeline,
shell,
textPart,
userMessage,
} from "../performance/timeline-stability/fixture"
import { assistantMessage, setupTimeline, shell, userMessage } from "../performance/timeline-stability/fixture"
test("space activates a focused timeline button instead of scrolling", async ({ page }) => {
const shellID = "prt_space_button_shell"
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
shell(shellID, "completed", lines(5)),
textPart(
"prt_space_following",
"Following content leaves room to focus the command away from the bottom. ".repeat(40),
),
]),
],
messages: [userMessage(), assistantMessage([shell(shellID, "completed", lines(5))])],
settings: { shellToolPartsExpanded: false },
reducedMotion: true,
seedHistory: true,
})
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
const trigger = page.getByRole("button", { name: "Used Shell" })
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight))
.toBeGreaterThan(300)
await trigger.scrollIntoViewIfNeeded()
await scroller.hover()
await page.mouse.wheel(0, -100)
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeGreaterThan(50)
await expect(trigger).toBeInViewport()
await trigger.focus()
await expect(trigger).toBeFocused()
const before = await scroller.evaluate((element) => element.scrollTop)
await trigger.press("Space")
await expect(trigger).toHaveAttribute("aria-expanded", "true")
@@ -8,6 +8,21 @@ import {
userText,
} from "../performance/timeline-stability/fixture"
test("renders completed write content", async ({ page }) => {
const id = "prt_file_projection_write"
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(id, "write", "completed", { path: "src/write.ts", content: "export const written = true\n" }),
]),
],
settings: { editToolPartsExpanded: true },
})
await expect(page.locator(`[data-timeline-part-id="${id}"] [data-component="write-content"]`)).toBeVisible()
})
test("renders a completed single-file patch", async ({ page }) => {
const id = "prt_file_projection_single_patch"
await setupTimeline(page, {
@@ -0,0 +1,53 @@
import { expect, test } from "@playwright/test"
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
import { createTwoFilesPatch } from "diff"
test("keeps patch file disclosures independent", async ({ page }) => {
const patchID = "prt_nested_patch"
const files = [patchFile("src/a.ts", "modified"), patchFile("src/b.ts", "added"), patchFile("src/old.ts", "deleted")]
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(
patchID,
"patch",
"completed",
{ patchText: "Update three files" },
{ metadata: { files } },
),
]),
],
settings: { editToolPartsExpanded: true },
})
const wrapper = page.locator(`[data-timeline-part-id="${patchID}"]`)
const modified = wrapper.locator('[data-scope="apply-patch"] [data-type="update"]')
const deleted = wrapper.locator('[data-scope="apply-patch"] [data-type="delete"]')
await expect(wrapper.locator('[data-scope="apply-patch"] [aria-expanded="false"]')).toHaveCount(3)
await deleted.getByRole("button").click()
await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "true")
await expect(modified.getByRole("button")).toHaveAttribute("aria-expanded", "false")
await modified.getByRole("button").click()
await expect(modified.getByRole("button")).toHaveAttribute("aria-expanded", "true")
await deleted.getByRole("button").click()
await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "false")
await expect(modified.getByRole("button")).toHaveAttribute("aria-expanded", "true")
})
function patchFile(file: string, status: "added" | "modified" | "deleted") {
const before = status === "added" ? "" : source(false)
const after = status === "deleted" ? "" : source(true)
return {
file,
status,
patch: createTwoFilesPatch(`a/${file}`, `b/${file}`, before, after),
additions: status === "deleted" ? 0 : 4,
deletions: status === "added" ? 0 : 3,
}
}
function source(changed: boolean) {
return Array.from({ length: 12 }, (_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`).join(
"",
)
}
@@ -122,7 +122,6 @@ test("transitions thinking and hidden reasoning through busy to idle", async ({
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible()
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0)
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(reasoningID)}"]`)).toHaveCount(0)
await timeline.send(partUpdated(shell("prt_reasoning_shell", "running")), 160)
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await timeline.send(partUpdated(shell("prt_reasoning_shell", "completed", "done")), 180)
@@ -130,7 +129,6 @@ test("transitions thinking and hidden reasoning through busy to idle", async ({
await timeline.send(status("idle"), 300)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0)
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(reasoningID)}"]`)).toHaveCount(0)
})
test("moves busy through retry and recovery to final idle content", async ({ page }) => {
@@ -122,7 +122,6 @@ test("updates running compactions to failed and cancelled boundaries", async ({
await timeline.send(compactionStarted({ sessionID, reason: "auto", recent: "" }))
await timeline.send(compactionDelta({ sessionID, text: "Partial summary that should be discarded." }))
await expect(page.getByText("Partial summary that should be discarded.", { exact: true })).toBeVisible()
await timeline.send(
compactionFailed({
sessionID,
@@ -141,9 +140,6 @@ test("updates running compactions to failed and cancelled boundaries", async ({
await expect(failed).not.toContainText("Partial summary that should be discarded.")
await timeline.send(compactionStarted({ sessionID, reason: "manual", recent: "" }))
await expect(compactions).toHaveCount(2)
await timeline.send(compactionDelta({ sessionID, text: "Summary before cancellation." }))
await expect(page.getByText("Summary before cancellation.", { exact: true })).toBeVisible()
await timeline.send(
compactionFailed({
sessionID,
@@ -156,7 +152,88 @@ test("updates running compactions to failed and cancelled boundaries", async ({
const cancelled = compactions.filter({ hasNotText: "The provider rejected the summary." })
await expect(cancelled.getByText("Session compacted", { exact: true })).toBeVisible()
await expect(cancelled).not.toContainText("Cancellation detail should stay hidden.")
await expect(cancelled).not.toContainText("Summary before cancellation.")
})
test("shows a delegating row while subagent input streams", async ({ page }) => {
await setupTimeline(page, {
sessionMessages: [
user,
{
...assistant(false),
content: [
{
type: "tool",
id: "call_subagent",
name: "subagent",
state: { status: "streaming", input: "" },
time: { created: 2 },
},
],
},
],
})
const delegating = page.locator('[data-component="task-tool-delegating"]')
await expect(delegating).toBeVisible()
const shimmer = delegating.locator('[data-component="text-shimmer"]')
await expect(shimmer).toHaveAttribute("aria-label", "Delegating agent...")
await expect(shimmer).toHaveCSS("line-height", "16px")
const icon = delegating.locator('[data-slot="icon-svg"]')
await expect(icon.locator('use[href="#opencode-v2-icon-subagent"]')).toBeVisible()
await expect(icon).toHaveCSS("color", "rgb(174, 174, 174)")
await expect(page.locator('[data-component="task-tool-card"]')).toHaveCount(0)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
})
test("renders the moved location notice in its compact timeline style", async ({ page }) => {
const directory = `/Users/usrnk1/Developer/opencode/${"nested-directory/".repeat(24)}session`
await page.setViewportSize({ width: 480, height: 720 })
await setupTimeline(page, {
sessionMessages: [
user,
{
id: "msg_location",
type: "location-switched",
location: { directory },
time: { created: 2 },
},
],
})
const notice = page.locator('[data-slot="session-timeline-notice"][data-type="location-switched"]')
const label = notice.locator('[data-slot="session-timeline-notice-label"]')
const value = notice.locator('[data-slot="session-timeline-notice-value"]')
const tooltipTrigger = notice.locator('[data-component="tooltip-v2-trigger"]')
await expect(label).toHaveText("Moved to")
await expect(value).toHaveText(directory)
await expect(notice).not.toContainText("·")
await expect(notice.locator("svg")).toHaveCount(0)
await expect(notice).toHaveCSS("height", "28px")
await expect(notice).toHaveCSS("gap", "8px")
await expect(notice).toHaveCSS("padding-top", "4px")
await expect(notice).toHaveCSS("padding-bottom", "4px")
await expect(label).toHaveCSS("font-size", "13px")
await expect(label).toHaveCSS("font-weight", "530")
await expect(label).toHaveCSS("line-height", "16px")
await expect(label).toHaveCSS("color", "rgb(128, 128, 128)")
await expect(value).toHaveCSS("font-size", "13px")
await expect(value).toHaveCSS("font-weight", "440")
await expect(value).toHaveCSS("line-height", "16px")
await expect(value).toHaveCSS("color", "rgb(128, 128, 128)")
await expect(value).toHaveCSS("text-overflow", "ellipsis")
await expect(value).toHaveCSS("white-space", "nowrap")
await expect(value).toHaveAttribute("dir", "ltr")
await expect.poll(() => value.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true)
const tooltip = page.getByText("Session working directory changed", { exact: true })
await label.hover()
await expect(tooltip).toBeVisible()
await page.mouse.move(0, 0)
await expect(tooltip).toBeHidden()
await tooltipTrigger.focus()
await expect(tooltipTrigger).toBeFocused()
await expect(tooltip).toBeVisible()
})
test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
@@ -194,6 +271,11 @@ test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
await request
})
test("waits for completion before labeling requested background work", async ({ page }) => {
await setupTimeline(page, { sessionMessages: [user, assistant(false, true, undefined, true)] })
await expect(page.locator('[data-component="task-tool-card"]')).not.toContainText("(background)")
})
test("navigates from a running subagent card and hides background controls in the child", async ({ page }) => {
const childID = "ses_running_child"
await setupTimeline(page, {
@@ -7,9 +7,86 @@ import {
toolPart,
userMessage,
userText,
type PartSeed,
} from "../performance/timeline-stability/fixture"
test.describe("session timeline projection", () => {
test("renders every admitted tool family and hides timeline-only exclusions", async ({ page }) => {
const parts = [
toolPart("prt_01_read", "read", "completed", { path: "src/a.ts" }),
toolPart("prt_02_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }),
toolPart("prt_03_grep", "grep", "completed", { path: ".", pattern: "value" }),
toolPart("prt_04_list", "list", "completed", { path: "src" }),
toolPart("prt_webfetch", "webfetch", "completed", { url: "https://example.com" }),
toolPart(
"prt_websearch",
"websearch",
"completed",
{ query: "timeline stability" },
{ output: "https://example.com/result" },
),
toolPart("prt_task", "subagent", "completed", {
description: "Inspect timeline",
agent: "explore",
prompt: "Inspect the timeline implementation.",
}),
toolPart(
"prt_bash",
"shell",
"completed",
{ command: "printf stable" },
{ output: "stable", title: "printf stable" },
),
editPart("prt_edit"),
toolPart("prt_write", "write", "completed", { path: "src/new.ts", content: "export const stable = true\n" }),
patchPart("prt_patch"),
toolPart("prt_todo", "todowrite", "completed", { todos: [{ content: "Hidden", status: "pending" }] }),
toolPart(
"prt_question",
"question",
"completed",
{ questions: [{ question: "Keep stable?", header: "Stability", options: [] }] },
{ metadata: { answers: [["Yes"]] } },
),
toolPart("prt_skill", "skill", "completed", { name: "stability" }),
toolPart("prt_custom", "custom_mcp_tool", "completed", { target: "timeline", count: 2 }),
]
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
const first = page.locator(
'[data-timeline-part-ids="prt_01_read,prt_02_glob,prt_03_grep,prt_04_list,prt_webfetch,prt_websearch,prt_task,prt_bash,prt_edit,prt_write,prt_patch"]',
)
const second = page.locator('[data-timeline-part-ids="prt_skill,prt_custom"]')
await expect(first).toBeVisible()
await expect(second).toBeVisible()
await first.getByRole("button").click()
await second.getByRole("button").click()
for (const id of [
"prt_webfetch",
"prt_websearch",
"prt_task",
"prt_bash",
"prt_edit",
"prt_write",
"prt_patch",
"prt_question",
"prt_skill",
"prt_custom",
]) {
await expect(page.locator(`[data-timeline-part-id="${id}"]`).first(), id).toBeVisible()
}
const patch = page.locator('[data-timeline-part-id="prt_patch"]')
await expect(patch.getByText("1 file", { exact: true })).toBeVisible()
await expect(patch.getByRole("button", { name: "Patch 1 file", exact: true })).toHaveCount(0)
await expect(patch.getByRole("button")).toHaveCount(1)
await expect(patch.locator('[data-scope="apply-patch"] button[aria-expanded="false"]')).toHaveCount(1)
await expect(patch.locator('[data-slot="message-part-title-filename"]')).toHaveCount(0)
await expect(patch.locator('[data-slot="message-part-actions"]')).toHaveCount(0)
const edit = page.locator('[data-timeline-part-id="prt_edit"]')
await expect(edit).toContainText("Edit")
await expect(page.locator('[data-timeline-part-id="prt_todo"]')).toHaveCount(0)
})
test("combines adjacent patch calls and repeated files into one group", async ({ page }) => {
const first = "prt_patch_first"
const second = "prt_patch_second"
@@ -81,6 +158,43 @@ test.describe("session timeline projection", () => {
await expect(page.locator(`[data-timeline-part-id="${first}"], [data-timeline-part-id="${second}"]`)).toHaveCount(0)
})
test("combines adjacent edit calls and repeated files into one group", async ({ page }) => {
const first = "prt_edit_first"
const second = "prt_edit_second"
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(
first,
"edit",
"completed",
{ path: "src/first.ts", oldString: "one", newString: "two" },
{
metadata: { files: [patchFile("src/first.ts", "modified")] },
},
),
toolPart(
second,
"edit",
"completed",
{ path: "src/first.ts", oldString: "two", newString: "three" },
{
metadata: { files: [patchFile("src/first.ts", "modified")] },
},
),
]),
],
settings: { editToolPartsExpanded: true },
})
const group = page.locator(`[data-timeline-part-ids="${first},${second}"]`)
await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toContainText("Edit")
await expect(group.getByText("1 file", { exact: true })).toBeVisible()
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["first.ts"])
await expect(group.locator('[data-scope="apply-patch"] button')).toHaveAttribute("aria-expanded", "true")
})
test("projects gaps, dividers, assistant parts, and errors together", async ({ page }) => {
const firstUser = userMessage(
[
@@ -122,6 +236,25 @@ test.describe("session timeline projection", () => {
await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible()
})
test("renders interruption independently when the turn is not compacted", async ({ page }) => {
const user = userMessage()
const before = assistantMessage([{ id: "prt_before", type: "text", text: "Before" }], {
id: "msg_1001_before",
error: { type: "MessageAbortedError", message: "Stopped" },
})
const after = assistantMessage([{ id: "prt_after", type: "text", text: "After" }], {
id: "msg_1002_after",
created: 1700000003000,
})
await setupTimeline(page, { messages: [user, before, after] })
await expect(page.getByText("Interrupted", { exact: true })).toBeVisible()
const rows = await page
.locator('[data-timeline-row="AssistantPart"], [data-timeline-row="TurnDivider"]')
.evaluateAll((elements) => elements.map((element) => element.getAttribute("data-timeline-row")))
expect(rows).toEqual(["AssistantPart", "TurnDivider", "AssistantPart"])
})
test("renders aliased and long custom model notices", async ({ page }) => {
const shortName = "GPT-5.4 nano"
const longName = "Company Gateway Extra Long Context Model for Narrow Timeline Layouts"
@@ -158,8 +291,77 @@ test.describe("session timeline projection", () => {
await expect(longNotice.locator("[title]")).toHaveAttribute("title", `Switched to ${longName}`)
await expect.poll(() => longNotice.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
})
test("renders user image, file attachment, file reference, and agent reference", async ({ page }) => {
const text = "Use @explore with @src/a.ts and inspect the attachments"
const parts: PartSeed<"user">[] = [
userText(text, { id: "prt_user_rich" }),
{
id: "prt_user_image",
type: "file",
mime: "image/png",
filename: "pixel.png",
url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
},
{
id: "prt_user_attachment",
type: "file",
mime: "application/json",
filename: "tsconfig.json",
url: "data:application/json;base64,e30=",
},
{
id: "prt_user_reference",
type: "file",
mime: "text/plain",
filename: "a.ts",
url: "src/a.ts",
source: { type: "file", path: "src/a.ts", text: { value: "@src/a.ts", start: 18, end: 27 } },
},
{
id: "prt_user_agent",
type: "agent",
name: "explore",
source: { value: "@explore", start: 4, end: 12 },
},
]
await setupTimeline(page, { messages: [userMessage(parts), assistantMessage()] })
await expect(page.getByAltText("pixel.png")).toBeVisible()
await expect(page.getByText("tsconfig.json")).toBeVisible()
await expect(page.getByText("@src/a.ts", { exact: true })).toBeVisible()
await expect(page.getByText("@explore", { exact: true })).toBeVisible()
})
})
function editPart(id: string) {
return toolPart(
id,
"edit",
"completed",
{ path: "src/a.ts", oldString: "export const value = 1", newString: "export const value = 2" },
{
metadata: {
files: [patchFile("src/a.ts", "modified")],
},
},
)
}
function patchPart(id: string) {
return toolPart(
id,
"patch",
"completed",
{ patchText: "Update the projected files" },
{
metadata: {
files: [patchFile("src/a.ts", "modified")],
},
},
)
}
function patchFile(file: string, status: "added" | "modified" | "deleted") {
return {
file,
@@ -7,25 +7,33 @@ import {
renderedPartID,
setupTimeline,
shell,
toolPart,
status,
textPart,
toolPart,
userMessage,
} from "../performance/timeline-stability/fixture"
test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => {
const textID = "prt_event_order_text"
const assistant = assistantMessage([textPart(textID, "Partial")], { completed: false })
const timeline = await setupTimeline(page, { messages: [userMessage(), assistant] })
await timeline.send(status("busy"), 100)
await timeline.send(status("idle"), 100)
await timeline.send(partUpdated(textPart(textID, "Final after early idle")), 120)
await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 250)
test("groups every collapsed tool until visible text separates the stack", async ({ page }) => {
const parts = [
toolPart("prt_boundary_01_read", "read", "completed", { path: "src/a.ts" }),
textPart("prt_boundary_02_text", "Boundary text"),
toolPart("prt_boundary_03_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }),
toolPart("prt_boundary_04_grep", "grep", "completed", { path: ".", pattern: "stable" }),
shell("prt_boundary_05_shell", "completed", "done"),
toolPart("prt_boundary_06_list", "list", "completed", { path: "src" }),
]
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(textID)}"]`)).toContainText(
"Final after early idle",
await expect(page.locator('[data-timeline-part-ids="prt_boundary_01_read"]')).toBeVisible()
const group = page.locator(
'[data-timeline-part-ids="prt_boundary_03_glob,prt_boundary_04_grep,prt_boundary_05_shell,prt_boundary_06_list"]',
)
await expect(group).toBeVisible()
await expect(group.getByRole("button")).toHaveAccessibleName("Used Glob, Grep, Shell, List")
await expect(group.locator('[data-component="tag"]')).toHaveText("4")
await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(3)
await expect(page.locator('[data-timeline-spacing="content"]')).toHaveCount(2)
await expect(page.locator('[data-timeline-spacing="content"]').nth(0)).toHaveCSS("padding-top", "16px")
})
test("expands a mixed collapsed tool stack without expanding its individual calls", async ({ page }) => {
@@ -124,3 +132,18 @@ test("keeps failed search calls and their error cards inside the collapsed stack
"Search timed out after 30 seconds",
)
})
test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => {
const textID = "prt_event_order_text"
const assistant = assistantMessage([textPart(textID, "Partial")], { completed: false })
const timeline = await setupTimeline(page, { messages: [userMessage(), assistant] })
await timeline.send(status("busy"), 100)
await timeline.send(status("idle"), 100)
await timeline.send(partUpdated(textPart(textID, "Final after early idle")), 120)
await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 250)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(textID)}"]`)).toContainText(
"Final after early idle",
)
})
@@ -7,6 +7,31 @@ import {
userMessage,
} from "../performance/timeline-stability/fixture"
test("renders every tool error outcome without leaking hidden tools", async ({ page }) => {
const ordinary = ["shell", "edit", "write", "patch", "webfetch", "websearch", "subagent", "skill", "mcp_probe"]
const parts = ordinary.map((tool, index) =>
toolPart(`prt_error_${index}`, tool, "error", errorInput(tool), { error: `${tool} failed visibly` }),
)
parts.push(
toolPart("prt_question_dismissed", "question", "error", questionInput(), {
error: "The user dismissed this question",
}),
toolPart("prt_question_error", "question", "error", questionInput(), { error: "Question transport failed" }),
toolPart("prt_todo_error", "todowrite", "error", { todos: [] }, { error: "Hidden todo failure" }),
)
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
const group = page.locator(`[data-timeline-part-ids="${ordinary.map((_, index) => `prt_error_${index}`).join(",")}"]`)
await expect(group.locator('[data-component="tag"]')).toHaveText(String(ordinary.length))
await group.getByRole("button").click()
await expect(page.locator('[data-kind="tool-error-card"]')).toHaveCount(ordinary.length + 1)
await expect(page.getByText(/dismissed/i)).toBeVisible()
await expect(page.locator('[data-timeline-part-id="prt_todo_error"]')).toHaveCount(0)
for (let index = 0; index < ordinary.length; index++) {
await expect(page.locator(`[data-timeline-part-id="prt_error_${index}"]`)).toBeVisible()
}
})
test("transitions shell and question through running error outcomes", async ({ page }) => {
const shellID = "prt_transition_error_shell"
const questionID = "prt_transition_error_question"
@@ -113,6 +138,62 @@ test("preserves surviving grouped patch state when its first patch fails", async
.toBeGreaterThanOrEqual(-0.5)
})
test("labels all web search provider variants", async ({ page }) => {
const parts = [
toolPart(
"prt_search_parallel",
"websearch",
"completed",
{ query: "parallel" },
{ metadata: { provider: "parallel" } },
),
toolPart("prt_search_exa", "websearch", "completed", { query: "exa" }, { metadata: { provider: "exa" } }),
toolPart("prt_search_generic", "websearch", "completed", { query: "generic" }),
]
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
await page.getByRole("button", { name: "Used Parallel Web Search, Exa Web Search, Web Search" }).click()
const tools = page.locator('[data-component="context-tool-group-list"]')
await expect(tools.getByRole("button", { name: /Parallel Web Search/ })).toBeVisible()
await expect(tools.getByRole("button", { name: /Exa Web Search/ })).toBeVisible()
await expect(tools.getByRole("button", { name: /^Web Search/ })).toBeVisible()
})
test("labels completed searches with result counts", async ({ page }) => {
const glob = "prt_glob_count"
const grep = "prt_grep_count"
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(glob, "glob", "completed", { path: ".", pattern: "**/*.ts" }, { metadata: { count: 1 } }),
toolPart(grep, "grep", "completed", { path: ".", pattern: "value" }, { metadata: { matches: 12 } }),
]),
],
})
const group = page.locator(`[data-timeline-part-ids="${glob},${grep}"]`)
await group.locator('[data-slot="collapsible-trigger"]').click()
const rows = group.locator('[data-component="context-tool-group-list"] [data-component="tool-trigger"]')
await expect(rows.filter({ hasText: "Glob" })).toContainText("(1 match)")
await expect(rows.filter({ hasText: "Grep" })).toContainText("(12 matches)")
})
test("labels read tools from their path input", async ({ page }) => {
const id = "prt_read_path"
await setupTimeline(page, {
messages: [userMessage(), assistantMessage([toolPart(id, "read", "completed", { path: "src/a.ts" })])],
})
const group = page.locator(`[data-timeline-part-ids="${id}"]`)
await group.locator('[data-slot="collapsible-trigger"]').click()
await expect(
group
.locator('[data-component="context-tool-group-list"] [data-component="tool-trigger"]')
.filter({ hasText: "Read" }),
).toContainText("a.ts")
})
test("groups instruction files loaded by the same read", async ({ page }) => {
const id = "prt_read_instructions"
await setupTimeline(page, {
@@ -140,6 +221,36 @@ test("groups instruction files loaded by the same read", async ({ page }) => {
await expect(loaded.locator('[data-slot="tool-loaded-kind"]')).toHaveCount(0)
})
test("labels skill tools from IDs and result metadata", async ({ page }) => {
const pending = "prt_skill_id"
const completed = "prt_skill_name"
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(pending, "skill", "running", { id: "frontend-design" }),
toolPart(completed, "skill", "completed", { id: "opencode" }, { metadata: { name: "OpenCode" } }),
]),
],
})
const group = page.locator(`[data-timeline-part-ids="${pending},${completed}"]`)
await expect(group.getByRole("button")).toHaveAccessibleName("Used Skill")
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
await group.getByRole("button").click()
const loaded = group.locator('[data-component="tool-loaded-item"]')
await expect(loaded).toHaveCount(1)
await expect(loaded).toHaveAttribute("aria-label", "Loaded frontend-design, OpenCode skills")
await expect(loaded).toHaveCSS("line-height", "16px")
await expect(loaded.locator('[data-slot="tool-loaded-label"]')).toHaveText("Loaded")
await expect(loaded.locator('[data-slot="tool-loaded-kind"]')).toHaveText("skills")
const names = loaded.locator('[data-component="text-shimmer"]')
await expect(names).toHaveCount(2)
await expect(names.nth(0)).toHaveAttribute("aria-label", "frontend-design")
await expect(names.nth(1)).toHaveAttribute("aria-label", "OpenCode")
})
test("groups only consecutive successful skill tools", async ({ page }) => {
const parts = [
toolPart("prt_skill_first", "skill", "completed", { id: "ocpr" }),
@@ -162,3 +273,14 @@ test("groups only consecutive successful skill tools", async ({ page }) => {
function questionInput() {
return { questions: [{ header: "Stability", question: "Keep it stable?", options: [] }] }
}
function errorInput(tool: string) {
if (tool === "shell") return { command: "exit 1" }
if (["edit", "write"].includes(tool)) return { path: "src/error.ts", content: "" }
if (tool === "patch") return { patchText: "Update src/error.ts" }
if (tool === "webfetch") return { url: "https://example.com" }
if (tool === "websearch") return { query: "failure" }
if (tool === "subagent") return { description: "Fail subagent", agent: "explore", prompt: "Inspect the failure." }
if (tool === "skill") return { name: "failure" }
return { target: "failure" }
}
@@ -87,7 +87,6 @@ test("clears the terminal line with Command+Delete", async ({ page }) => {
const terminal = page.locator('[data-component="terminal"]')
await page.keyboard.press("Control+Backquote")
await expect(terminal.locator("textarea")).toHaveCount(1)
await expect.poll(() => sendPtyOutput).toBeDefined()
await page.keyboard.press("Meta+Backspace")
@@ -127,6 +127,7 @@ test("animates review and terminal panels while caching hidden terminal content"
await expectStackedGeometry(page)
await expectPanelGapHeld(page)
await resetTerminalTopMotion(page)
await resetTerminalBottomMotion(page)
await resetTerminalAnchorGaps(page)
await resetPanelGaps(page)
@@ -138,6 +139,7 @@ test("animates review and terminal panels while caching hidden terminal content"
await expect(panel).toBeVisible()
await expectHeightMotions(page, "session-side-region", 2)
await expectHeightMotions(page, "session-side-terminal-region", 2)
await expectTerminalTopMotion(page)
await expectTerminalBottomFixed(page)
await expectTerminalTopAnchored(page)
await expectPanelGapHeld(page)
@@ -223,6 +225,7 @@ type MotionProbe = {
terminalAnchorGaps: number[]
resetAnchorOnMotion: boolean
panelGaps: number[]
terminalTops: number[]
terminalBottoms: number[]
heights: string[]
animations: string[]
@@ -240,6 +243,7 @@ async function installMotionProbe(page: Page) {
terminalAnchorGaps: [],
resetAnchorOnMotion: false,
panelGaps: [],
terminalTops: [],
terminalBottoms: [],
heights: [],
animations: [],
@@ -266,6 +270,7 @@ async function installMotionProbe(page: Page) {
const terminalContent = document.querySelector<HTMLElement>('[data-slot="terminal-panel-content"]')
const panelGap = document.querySelector<HTMLElement>('[data-slot="session-side-panel-gap"]')
if (!terminal || !terminalContent) return
probe.terminalTops.push(terminal.getBoundingClientRect().top)
probe.terminalBottoms.push(terminal.getBoundingClientRect().bottom)
probe.terminalContentSizes.push({
width: terminalContent.getBoundingClientRect().width,
@@ -441,6 +446,13 @@ async function expectStackPainted(page: Page) {
expect(Math.max(...gaps.map((gap) => gap.terminalSurface)), JSON.stringify(gaps)).toBeLessThanOrEqual(1)
}
async function resetTerminalTopMotion(page: Page) {
await page.evaluate(() => {
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
if (probe) probe.terminalTops = []
})
}
async function resetTerminalBottomMotion(page: Page) {
await page.evaluate(() => {
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
@@ -504,6 +516,17 @@ async function expectTerminalContentCachedSize(page: Page) {
expect(Math.min(...sizes.map((size) => size.height))).toBeGreaterThan(100)
}
async function expectTerminalTopMotion(page: Page) {
const tops = await page.evaluate(
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.terminalTops.map(Math.round) ?? [],
)
const unique = [...new Set(tops)]
const range = Math.max(...unique) - Math.min(...unique)
const maxDelta = Math.max(...unique.slice(1).map((value, index) => Math.abs(value - unique[index])))
expect(unique.length, JSON.stringify(unique)).toBeGreaterThan(6)
expect(maxDelta, JSON.stringify({ unique, range, maxDelta })).toBeLessThan(range * 0.3)
}
async function expectHeightMotions(page: Page, slot: string, count: number) {
await expect
.poll(() =>
+1 -1
View File
@@ -7,5 +7,5 @@
"rootDir": "..",
"types": ["node", "bun"]
},
"include": ["./**/*.ts", "./**/*.tsx", "../component-tests/**/*.ts", "../src/types.ts"]
"include": ["./**/*.ts", "./**/*.tsx", "../src/types.ts"]
}
-2
View File
@@ -26,8 +26,6 @@
"test:unit:watch": "bun test --conditions=solid --watch --preload ./happydom.ts ./src",
"test:e2e": "playwright test",
"test:e2e:local": "playwright test",
"test:components": "playwright test --config playwright.components.config.ts",
"test:components:ui": "playwright test --config playwright.components.config.ts --ui",
"test:e2e:ui": "playwright test --ui",
"test:e2e:report": "playwright show-report e2e/playwright-report",
"test:service-worker": "bun run build && playwright test --config e2e/service-worker/playwright.config.ts",
@@ -1,4 +0,0 @@
import { fileURLToPath } from "node:url"
import { componentConfig } from "../storybook/playwright/config"
export default componentConfig(fileURLToPath(new URL(".", import.meta.url)))
+1 -7
View File
@@ -21,12 +21,6 @@
"@/*": ["./src/*"]
}
},
"include": [
"src",
"component-tests",
"playwright.components.config.ts",
"../storybook/playwright/*.ts",
"package.json"
],
"include": ["src", "package.json"],
"exclude": ["dist", "ts-dist"]
}
@@ -4,14 +4,11 @@ import { Service } from "@opencode-ai/client/effect/service"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
import { ServerConnection } from "../../../services/server-connection"
export default Runtime.handler(
Commands.commands.service.commands.restart,
Effect.fn("cli.service.restart")(function* () {
const options = yield* ServiceConfig.options()
// Keep this explicit: automatic service replacement must preserve terminals.
yield* ServerConnection.shutdownPersistentPty(options).pipe(Effect.ignore)
yield* Service.stop(options)
const transport = yield* Service.ensure(options)
process.stdout.write(transport.url + EOL)
-109
View File
@@ -514,115 +514,6 @@ test("event.subscribe exposes the Promise event stream wire projection", async (
expect(events[1]?.type === "session.model.selected" && events[1].created).toBe(1_717_171_717_000)
})
// Moved from packages/app/e2e/regression/session-timeline-transport.spec.ts
test("event.subscribe keeps one request open while delivering multiple events", async () => {
const requests: Request[] = []
const events = [
{ id: "evt_first", created: 1, type: "server.connected", data: {} },
{ id: "evt_second", created: 2, type: "server.connected", data: {} },
]
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
requests.push(input instanceof Request ? input : new Request(input, init))
return new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), {
headers: { "content-type": "text/event-stream" },
})
},
})
const received = []
for await (const event of client.event.subscribe()) received.push(event)
expect(received).toEqual(events)
expect(requests).toHaveLength(1)
})
// Moved from packages/app/e2e/regression/session-timeline-transport.spec.ts
test("event.subscribe delivers every event from one stream chunk", async () => {
const events = Array.from({ length: 4 }, (_, index) => ({
id: `evt_burst_${index}`,
created: index,
type: "server.connected",
data: {},
}))
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async () =>
new Response(new TextEncoder().encode(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("")), {
headers: { "content-type": "text/event-stream" },
}),
})
const received = []
for await (const event of client.event.subscribe()) received.push(event)
expect(received).toEqual(events)
expect(new Set(received.map((event) => event.id)).size).toBe(4)
})
// Moved from packages/app/e2e/regression/session-timeline-transport.spec.ts
test("event.subscribe parses split JSON and a split multibyte code point", async () => {
const event = {
id: "evt_split",
created: 1,
type: "server.connected",
data: { text: "split snowman \u2603\u2603\u2603" },
}
const encoded = new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`)
const multibyte = encoded.indexOf(new TextEncoder().encode("\u2603")[0]!)
const boundaries = [9, multibyte + 1, multibyte + 2, encoded.length]
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async () =>
new Response(
new ReadableStream({
start(controller) {
boundaries.forEach((end, index) =>
controller.enqueue(encoded.slice(index ? boundaries[index - 1] : 0, end)),
)
controller.close()
},
}),
{ headers: { "content-type": "text/event-stream" } },
),
})
await expect(client.event.subscribe()[Symbol.asyncIterator]().next()).resolves.toEqual({ done: false, value: event })
})
// Moved from packages/app/e2e/regression/session-timeline-transport.spec.ts
test("event.subscribe ignores server heartbeat comments", async () => {
const event = { id: "evt_sentinel", created: 1, type: "server.connected", data: {} }
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async () =>
new Response(`: heartbeat\n\ndata: ${JSON.stringify(event)}\n\n: heartbeat\n\n`, {
headers: { "content-type": "text/event-stream" },
}),
})
const received = []
for await (const item of client.event.subscribe()) received.push(item)
expect(received).toEqual([event])
})
// Moved from packages/app/e2e/regression/session-timeline-transport.spec.ts
test("event transport passes through ordinary health requests", async () => {
const requests: string[] = []
const event = { id: "evt_connected", created: 1, type: "server.connected", data: {} }
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
requests.push(new URL(request.url).pathname)
if (new URL(request.url).pathname === "/api/event") {
return new Response(`data: ${JSON.stringify(event)}\n\n`, {
headers: { "content-type": "text/event-stream" },
})
}
return Response.json({ healthy: true, version: "2.0.0", pid: 1 })
},
})
await expect(client.event.subscribe()[Symbol.asyncIterator]().next()).resolves.toEqual({ done: false, value: event })
await expect(client.health.get()).resolves.toEqual({ healthy: true, version: "2.0.0", pid: 1 })
expect(requests).toEqual(["/api/event", "/api/health"])
})
test("event.subscribe terminates on malformed Promise SSE data", async () => {
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
@@ -80,11 +80,10 @@ export function importLegacyCredentials(tx: Parameters<DatabaseMigration.Migrati
}
: undefined,
})
const label = credential.type === "oauth" ? "OAuth" : "API key"
const now = Date.now()
yield* tx.run(sql`
INSERT INTO credential (id, integration_id, label, value, time_created, time_updated)
VALUES (${Credential.ID.create()}, ${integrationID}, ${label}, ${JSON.stringify(credential)}, ${now}, ${now})
VALUES (${Credential.ID.create()}, ${integrationID}, 'default', ${JSON.stringify(credential)}, ${now}, ${now})
`)
}
+94 -167
View File
@@ -1,41 +1,11 @@
export * as Job from "./job.js"
import { Array, Cause, Clock, Context, Deferred, Effect, Exit, Layer, Schema, Scope, SynchronizedRef } from "effect"
import { Cause, Clock, Context, Deferred, Effect, Exit, Layer, Scope, SynchronizedRef } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Identifier } from "./id/id.js"
import { KV } from "./kv.js"
import { SessionMessage } from "./session/message.js"
import { SessionSchema } from "./session/schema.js"
const Background = Schema.Struct({
id: Schema.String,
notificationID: SessionMessage.ID,
recovery: Schema.Union([
Schema.Struct({
kind: Schema.Literal("shell"),
sessionID: SessionSchema.ID,
shellID: Schema.String,
command: Schema.String,
}),
Schema.Struct({
kind: Schema.Literal("subagent"),
parentSessionID: SessionSchema.ID,
childSessionID: SessionSchema.ID,
agent: Schema.String,
description: Schema.String,
}),
]),
status: Schema.Literals(["running", "completed", "error", "cancelled"]),
output: Schema.optionalKey(Schema.String),
error: Schema.optionalKey(Schema.String),
})
export type Background = typeof Background.Type
export type Recovery = Background["recovery"]
export type Status = Background["status"]
const decodeBackground = Schema.decodeUnknownResult(Background)
const backgroundPrefix = "job.background/"
export type Status = "running" | "completed" | "error" | "cancelled"
export type Info = {
id: string
@@ -47,7 +17,6 @@ export type Info = {
output?: string
error?: string
metadata?: Record<string, unknown>
notificationID?: SessionMessage.ID
}
type Active = {
@@ -58,7 +27,6 @@ type Active = {
token: object
blockingSessions: Map<SessionSchema.ID, number>
isBackgrounded: boolean
recovery?: Recovery
}
type State = {
@@ -95,8 +63,6 @@ export type StartInput = {
type: string
title?: string
metadata?: Record<string, unknown>
recovery?: Recovery
notificationID?: SessionMessage.ID
run: Effect.Effect<string, unknown>
}
@@ -130,8 +96,6 @@ export interface Interface {
readonly background: (id: string) => Effect.Effect<Info | undefined>
readonly backgroundAll: (input: BackgroundAllInput) => Effect.Effect<Info[]>
readonly cancel: (id: string) => Effect.Effect<Info | undefined>
readonly pendingBackground: Effect.Effect<readonly Background[]>
readonly completeBackground: (notificationID: SessionMessage.ID) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Job") {}
@@ -162,57 +126,43 @@ function decrementSession(input: Map<SessionSchema.ID, number>, sessionID: Sessi
}
/**
* Makes one scoped, process-local registry. Explicitly recoverable background
* work also owns a durable notification marker until its notification is admitted.
* Makes one scoped, process-local registry. Entries are intentionally not
* durable: process restart or owner-scope closure loses status and interrupts
* live work. Persisted observation, restart recovery, and remote workers need a
* separate durable ownership slice rather than pretending this registry has
* those semantics.
*/
export const make = Effect.gen(function* () {
const kv = yield* KV.Service
const state: State = {
jobs: yield* SynchronizedRef.make(new Map()),
scope: yield* Scope.Scope,
}
const persistBackground = Effect.fnUntraced(function* (job: Active) {
if (!job.recovery || !job.info.notificationID) return
yield* kv.set(`${backgroundPrefix}${job.info.notificationID}`, {
id: job.info.id,
notificationID: job.info.notificationID,
recovery: job.recovery,
status: job.info.status,
...(job.info.output !== undefined ? { output: job.info.output } : {}),
...(job.info.error !== undefined ? { error: job.info.error } : {}),
})
})
const settle = Effect.fnUntraced(function* (id: string, token: object, exit: Exit.Exit<string, unknown>) {
const completed_at = yield* Clock.currentTimeMillis
const result = yield* SynchronizedRef.modifyEffect(
state.jobs,
Effect.fnUntraced(function* (jobs): Effect.fn.Return<readonly [FinishResult, Map<string, Active>]> {
const job = jobs.get(id)
if (!job) return [{}, jobs]
if (job.token !== token) return [{}, jobs]
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
const status: Exclude<Status, "running"> = Exit.isSuccess(exit)
? "completed"
: Cause.hasInterruptsOnly(exit.cause)
? "cancelled"
: "error"
const next = {
...job,
blockingSessions: new Map<SessionSchema.ID, number>(),
info: {
...job.info,
status,
completed_at,
...(Exit.isSuccess(exit) ? { output: exit.value } : {}),
...(Exit.isFailure(exit) ? { error: errorText(Cause.squash(exit.cause)) } : {}),
},
}
if (status !== "cancelled") yield* persistBackground(next)
return [{ info: snapshot(next), done: job.done, scope: job.scope }, new Map(jobs).set(id, next)]
}),
)
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [FinishResult, Map<string, Active>] => {
const job = jobs.get(id)
if (!job) return [{}, jobs]
if (job.token !== token) return [{}, jobs]
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
const status: Exclude<Status, "running"> = Exit.isSuccess(exit)
? "completed"
: Cause.hasInterruptsOnly(exit.cause)
? "cancelled"
: "error"
const next = {
...job,
blockingSessions: new Map<SessionSchema.ID, number>(),
info: {
...job.info,
status,
completed_at,
...(Exit.isSuccess(exit) ? { output: exit.value } : {}),
...(Exit.isFailure(exit) ? { error: errorText(Cause.squash(exit.cause)) } : {}),
},
}
return [{ info: snapshot(next), done: job.done, scope: job.scope }, new Map(jobs).set(id, next)]
})
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info).pipe(Effect.ignore)
if (result.scope) {
yield* Scope.close(result.scope, Exit.void).pipe(Effect.forkIn(state.scope, { startImmediately: true }))
@@ -220,6 +170,22 @@ export const make = Effect.gen(function* () {
return result.info
})
const fork = Effect.fnUntraced(function* (
scope: Scope.Scope,
id: string,
token: object,
run: Effect.Effect<string, unknown>,
) {
return yield* run.pipe(
Effect.matchCauseEffect({
onSuccess: (output) => settle(id, token, Exit.succeed(output)),
onFailure: (cause) => settle(id, token, Exit.failCause(cause)),
}),
Effect.asVoid,
Effect.forkIn(scope, { startImmediately: true }),
)
})
const get: Interface["get"] = Effect.fn("Job.get")(function* (id) {
const job = (yield* SynchronizedRef.get(state.jobs)).get(id)
if (!job) return undefined
@@ -235,10 +201,10 @@ export const make = Effect.gen(function* () {
const backgrounded = yield* Deferred.make<Info>()
const result = yield* SynchronizedRef.modifyEffect(
state.jobs,
Effect.fnUntraced(function* (jobs): Effect.fn.Return<readonly [StartResult, Map<string, Active>]> {
Effect.fnUntraced(function* (jobs) {
const existing = jobs.get(id)
if (existing?.info.status === "running") {
return [{ info: snapshot(existing) }, jobs]
return [{ info: snapshot(existing) }, jobs] as readonly [StartResult, Map<string, Active>]
}
const scope = yield* Scope.fork(state.scope, "parallel")
const token = {}
@@ -250,7 +216,6 @@ export const make = Effect.gen(function* () {
status: "running" as const,
started_at,
metadata: input.metadata,
...(input.notificationID ? { notificationID: input.notificationID } : {}),
},
done,
backgrounded,
@@ -258,18 +223,14 @@ export const make = Effect.gen(function* () {
token,
blockingSessions: new Map<SessionSchema.ID, number>(),
isBackgrounded: false,
recovery: input.recovery,
}
return [{ info: snapshot(job), scope, token }, new Map(jobs).set(id, job)]
return [{ info: snapshot(job), scope, token }, new Map(jobs).set(id, job)] as readonly [
StartResult,
Map<string, Active>,
]
}),
)
if ("scope" in result)
yield* restore(input.run).pipe(
Effect.exit,
Effect.flatMap((exit) => settle(id, result.token, exit)),
Effect.asVoid,
Effect.forkIn(result.scope, { startImmediately: true }),
)
if ("scope" in result) yield* fork(result.scope, id, result.token, restore(input.run))
return result.info
}),
)
@@ -320,31 +281,20 @@ export const make = Effect.gen(function* () {
).pipe(Effect.ensuring(removeBlock(input)))
})
const markBackground = Effect.fnUntraced(function* (job: Active) {
const next = {
...job,
isBackgrounded: true,
blockingSessions: new Map<SessionSchema.ID, number>(),
info: {
...job.info,
...(job.recovery ? { notificationID: job.info.notificationID ?? SessionMessage.ID.create() } : {}),
},
}
yield* persistBackground(next)
return next
})
const background: Interface["background"] = Effect.fn("Job.background")(function* (id) {
const result = yield* SynchronizedRef.modifyEffect(
const result = yield* SynchronizedRef.modify(
state.jobs,
Effect.fnUntraced(function* (jobs): Effect.fn.Return<readonly [BackgroundResult, Map<string, Active>]> {
(jobs): readonly [BackgroundResult, Map<string, Active>] => {
const job = jobs.get(id)
// Recoverable work may finish before the caller backgrounds it.
if (!job || (job.info.status !== "running" && !job.recovery)) return [{}, jobs]
if (!job || job.info.status !== "running") return [{}, jobs]
if (job.isBackgrounded) return [{ info: snapshot(job) }, jobs]
const next = yield* markBackground(job)
const next = {
...job,
isBackgrounded: true,
blockingSessions: new Map<SessionSchema.ID, number>(),
}
return [{ info: snapshot(next), backgrounded: job.backgrounded }, new Map(jobs).set(id, next)]
}),
},
)
if (result.info && result.backgrounded)
yield* Deferred.succeed(result.backgrounded, result.info).pipe(Effect.ignore)
@@ -352,83 +302,60 @@ export const make = Effect.gen(function* () {
})
const backgroundAll: Interface["backgroundAll"] = Effect.fn("Job.backgroundAll")(function* (input) {
const result = yield* SynchronizedRef.modifyEffect(
const result = yield* SynchronizedRef.modify(
state.jobs,
Effect.fnUntraced(function* (jobs): Effect.fn.Return<
readonly [Required<BackgroundResult>[], Map<string, Active>]
> {
const results: Required<BackgroundResult>[] = []
(jobs): readonly [BackgroundResult[], Map<string, Active>] => {
const results: BackgroundResult[] = []
const next = new Map(jobs)
for (const [id, job] of jobs) {
if (job.info.status !== "running") continue
if (job.isBackgrounded) continue
if (input.type !== undefined && job.info.type !== input.type) continue
if (!job.blockingSessions.has(input.sessionID)) continue
const updated = yield* markBackground(job)
const updated = {
...job,
isBackgrounded: true,
blockingSessions: new Map<SessionSchema.ID, number>(),
}
results.push({ info: snapshot(updated), backgrounded: job.backgrounded })
next.set(id, updated)
}
return [results, next]
}),
},
)
yield* Effect.forEach(result, (item) => Deferred.succeed(item.backgrounded, item.info), { discard: true })
return result.map((item) => item.info)
yield* Effect.forEach(
result,
(item) => (item.info && item.backgrounded ? Deferred.succeed(item.backgrounded, item.info) : Effect.void),
{ discard: true },
)
return result.flatMap((item) => (item.info ? [item.info] : []))
})
const cancel: Interface["cancel"] = Effect.fn("Job.cancel")(function* (id) {
const completed_at = yield* Clock.currentTimeMillis
const result = yield* SynchronizedRef.modifyEffect(
state.jobs,
Effect.fnUntraced(function* (jobs): Effect.fn.Return<readonly [FinishResult, Map<string, Active>]> {
const job = jobs.get(id)
if (!job) return [{}, jobs]
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
const next = {
...job,
blockingSessions: new Map<SessionSchema.ID, number>(),
info: {
...job.info,
status: "cancelled" as const,
completed_at,
},
}
yield* persistBackground(next)
return [{ info: snapshot(next), done: job.done, scope: job.scope }, new Map(jobs).set(id, next)]
}),
)
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [FinishResult, Map<string, Active>] => {
const job = jobs.get(id)
if (!job) return [{}, jobs]
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
const next = {
...job,
blockingSessions: new Map<SessionSchema.ID, number>(),
info: {
...job.info,
status: "cancelled" as const,
completed_at,
},
}
return [{ info: snapshot(next), done: job.done, scope: job.scope }, new Map(jobs).set(id, next)]
})
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info).pipe(Effect.ignore)
if (result.scope) yield* Scope.close(result.scope, Exit.void)
return result.info
})
const pendingBackground: Interface["pendingBackground"] = Effect.gen(function* () {
const recovered: Background[] = []
let after: string | undefined
do {
const page = yield* kv.scan({ prefix: backgroundPrefix, after })
recovered.push(...Array.filterMap(page.entries, (entry) => decodeBackground(entry.value)))
after = page.next
} while (after)
return recovered
}).pipe(Effect.withSpan("Job.pendingBackground"))
const completeBackground: Interface["completeBackground"] = Effect.fn("Job.completeBackground")((notificationID) =>
kv.remove(`${backgroundPrefix}${notificationID}`),
)
return Service.of({
get,
start,
wait,
block,
background,
backgroundAll,
cancel,
pendingBackground,
completeBackground,
})
return Service.of({ get, start, wait, block, background, backgroundAll, cancel })
})
const layer = Layer.effect(Service, make)
export const node = makeGlobalNode({ service: Service, layer, deps: [KV.node] })
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
+8 -51
View File
@@ -56,35 +56,6 @@ import { AbsolutePath } from "./schema.js"
export { LocationServiceMap } from "./location-service-map.js"
/**
* Engine tier: the tags consumed from OUTSIDE the graph by the drain and by
* session operations, plus the registries that form the configuration surface.
* Everything else the engine needs (SessionContext, ModelRequest, Permission,
* ModelResolver, ...) is internal wiring reached through dependency closure
* during compile, where replacements can substitute capability sources.
* `locationServiceNodes` below stays the composed full graph — its list order
* is semantic (compile provide-merges in order), so the tier is named
* alongside, not split out.
*/
const sessionEngineNodes = [
// drain entry (execution.ts runs the runner; its layer wires the spine internally)
SessionRunnerLLM.node,
// prompt admission (session.ts attachment resize + skill mentions) and readiness
PluginSupervisor.node,
Image.node,
Skill.node,
// configuration surface: populated from values instead of discovery
Tool.node,
Agent.node,
Catalog.node,
] as const satisfies readonly Node.LocationNode<unknown, unknown>[]
export const sessionEngineGroup = LayerNode.group<typeof sessionEngineNodes>(sessionEngineNodes)
/** What a session drain and its operations require. `LocationServices` is a superset. */
export type SessionEngine = LayerNode.Output<typeof sessionEngineGroup>
export type SessionEngineError = LayerNode.Error<typeof sessionEngineGroup>
const locationServiceNodes = [
Location.node,
Environment.node,
@@ -141,27 +112,6 @@ export const locationServices = LayerNode.group<typeof locationServiceNodes>(loc
export type LocationServices = LayerNode.Output<typeof locationServices>
export type LocationError = LayerNode.Error<typeof locationServices>
// Compile-time guard: the engine tier must remain a subset of the full graph.
const _sessionEngineIsSubset: [SessionEngine] extends [LocationServices] ? true : never = true
void _sessionEngineIsSubset
/**
* Compile a Location graph with its global nodes hoisted out. Replacements
* must be applied during hoist, not afterward: replacements can introduce new
* tagged dependencies (Location.boundNode depends on Project), and the hoist
* walk is the only pass that can still slice those back out. Callers must
* thread the application root's replacements through so hoisted globals
* compile to the same Layer references the root built and memoization dedupes
* them instead of constructing second instances.
*/
export function compileWithHoistedGlobals<A, E>(
root: LayerNode.Node<A, E, LayerNode.Tag | undefined>,
replacements: LayerNode.Replacements,
): Layer.Layer<A, E> {
const sliced = LayerNode.hoist(root, Node.tags.values.global, replacements)
return LayerNode.compile(sliced.node).pipe(Layer.fresh, Layer.provide(LayerNode.compile(sliced.hoisted)))
}
export function buildLocationServiceMap(
replacements: LayerNode.Replacements = [],
): Layer.Layer<LocationServiceMap.Service> {
@@ -179,8 +129,14 @@ export function buildLocationServiceMap(
(ref: Location.Ref) => {
const startedAt = performance.now()
const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]])
// Apply replacements during hoist, not afterward: replacements can
// introduce new tagged dependencies (Location.boundNode depends on
// Project), and the hoist walk is the only pass that can still slice
// those back out.
const location = LayerNode.hoist(locationServices, Node.tags.values.global, allReplacements)
return compileWithHoistedGlobals(locationServices, allReplacements).pipe(
return LayerNode.compile(location.node).pipe(
Layer.fresh,
Layer.tap(() =>
Effect.logInfo("location services booted", {
directory: ref.directory,
@@ -188,6 +144,7 @@ export function buildLocationServiceMap(
durationMs: Math.round(performance.now() - startedAt),
}),
),
Layer.provide(LayerNode.compile(location.hoisted)),
)
},
{
-3
View File
@@ -60,9 +60,6 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/McpInstructions") {}
/** For environments without MCP: no server guidance to load. */
export const noop = Layer.succeed(Service, Service.of({ load: () => Effect.succeed(Instructions.empty) }))
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
+70 -91
View File
@@ -1,4 +1,4 @@
import { Cause, Context, Duration, Effect, Layer, Option, Schedule, Schema, Semaphore } from "effect"
import { Cause, Context, Duration, Effect, Fiber, Layer, Schedule, Schema, Semaphore } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { ModelsDev } from "@opencode-ai/schema/models-dev"
import { Money } from "@opencode-ai/schema/money"
@@ -10,7 +10,7 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
import { Model } from "./model.js"
import { Provider } from "./provider.js"
import { KV } from "./kv.js"
import { ModelsDevCache } from "./models-dev/cache.js"
import snapshotText from "./models-dev/snapshot.txt" with { type: "text" }
export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
@@ -539,13 +539,6 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Mo
const CatalogJson = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown))
const decodeCatalog = (text: string) =>
Schema.decodeUnknownEffect(CatalogJson)(text).pipe(Effect.map((catalog) => catalog as Record<string, SourceProvider>))
const Cache = Schema.Struct({
updatedAt: Schema.Number,
// Digest of the raw body, persisted so refresh() can skip republishing a
// byte-identical catalog. Optional for entries written before it existed.
digest: Schema.optional(Schema.String),
body: CatalogJson,
})
const defaultSource = "https://models.opencode.ai"
// Bundled snapshot of https://models.opencode.ai/api.json, committed at
@@ -554,23 +547,18 @@ const defaultSource = "https://models.opencode.ai"
// isolate: the snapshot is a multi-MB module-level constant and one isolate can
// host many runtimes (Cloudflare colocates Durable Object instances), so
// per-runtime decoding would multiply the cost.
let bundledCache: readonly Snapshot[] | undefined
let bundledCache: { data: readonly Snapshot[]; digest: string } | undefined
const bundledSnapshot = Effect.suspend(() =>
bundledCache
? Effect.succeed(bundledCache)
: decodeCatalog(snapshotText).pipe(
Effect.map((catalog) => {
bundledCache = normalize(catalog)
bundledCache = { data: normalize(catalog), digest: bodyDigest(snapshotText) }
return bundledCache
}),
),
)
function cacheKey(source: string) {
if (source === defaultSource) return "models-dev:catalog"
return `models-dev:catalog:${Hash.fast(source)}`
}
export function bodyDigest(text: string) {
return Hash.sha256(text)
}
@@ -582,7 +570,7 @@ export const layer = (options?: Options) =>
const fs = yield* FSUtil.Service
const bus = yield* Bus.Service
const app = yield* App.Metadata
const kv = yield* KV.Service
const cache = yield* ModelsDevCache.Service
const http = HttpClient.filterStatusOk(
(yield* HttpClient.HttpClient).pipe(
HttpClient.retryTransient({
@@ -596,21 +584,9 @@ export const layer = (options?: Options) =>
const source = options?.url || defaultSource
const fetch = options?.fetch ?? true
const userAgent = App.useragent(app)
const key = cacheKey(source)
const ttl = Duration.minutes(5)
const lock = Semaphore.makeUnsafe(1)
const loadFromCache = Effect.fnUntraced(function* () {
const value = yield* kv.get(key)
const cached = Schema.decodeUnknownOption(Cache)(value)
if (Option.isSome(cached))
return {
catalog: cached.value.body as Record<string, SourceProvider>,
updatedAt: cached.value.updatedAt,
digest: cached.value.digest,
}
if (value !== undefined) yield* kv.remove(key)
})
const state: { data?: readonly Snapshot[]; digest?: string; checkedAt: number } = { checkedAt: 0 }
const fetchApi = Effect.fn("ModelsDev.fetchApi")(function* () {
return yield* HttpClientRequest.get(`${source}/api.json`).pipe(
@@ -621,79 +597,82 @@ export const layer = (options?: Options) =>
)
})
const loadFromFile = options?.file
? fs.readJson(options.file).pipe(
Effect.map((input) => input as Record<string, SourceProvider>),
Effect.orElseSucceed(() => undefined),
// Persistence only seeds a runtime. Refresh never reloads this seed over
// a catalog that was successfully fetched but could not be saved.
// The service owns initialization so cancelling a reader cannot cancel it.
const initialized = yield* Effect.forkScoped(
Effect.gen(function* () {
yield* Effect.gen(function* () {
const stored = options?.file
? { body: yield* fs.readFileString(options.file), updatedAt: Date.now() }
: yield* cache.read(source)
if (!stored) return
const data = normalize(yield* decodeCatalog(stored.body))
Object.assign(state, { data, digest: bodyDigest(stored.body), checkedAt: stored.updatedAt })
}).pipe(
Effect.catchCauseIf(
(cause) => !Cause.hasInterruptsOnly(cause),
(cause) => Effect.logWarning("Failed to load models.dev catalog cache", { cause }),
),
)
: Effect.undefined
if (state.data) return
if (options?.snapshot !== false) {
Object.assign(state, yield* bundledSnapshot)
return
}
if (!fetch) state.data = []
}).pipe(Effect.orDie),
)
// The bundled snapshot is the boot-time floor for the catalog; the
// periodic fetch below still refreshes on top.
const loadSnapshot = options?.snapshot === false ? Effect.undefined : bundledSnapshot
// Best-effort: a cache-write failure must never kill catalog
// population. The payload has outgrown some KV backends' per-value
// limits (Durable Object SQLite caps values at 2 MB and api.json
// passed it in Aug 2026); a boot without a cache hit just refetches.
const writeCache = Effect.fn("ModelsDev.writeCache")(function* (text: string) {
yield* kv.set(key, { updatedAt: Date.now(), digest: bodyDigest(text), body: text }).pipe(
Effect.catchCauseIf(
(cause) => !Cause.hasInterruptsOnly(cause),
(cause) => Effect.logWarning("Failed to cache models.dev catalog", { cause }),
),
)
const update = Effect.fn("ModelsDev.update")(function* (force = false) {
const text = options?.file ? yield* fs.readFileString(options.file) : yield* fetchApi()
const digest = bodyDigest(text)
if (!force && state.data && state.digest === digest) {
state.checkedAt = Date.now()
return state.data
}
const data = normalize(yield* decodeCatalog(text))
Object.assign(state, { data, digest, checkedAt: Date.now() })
yield* bus.publish(ModelsDev.Event.Refreshed, {})
// Adopt and publish before attempting persistence. A missing or broken
// cache must not prevent live updates, including in filesystem-less runtimes.
if (!options?.file)
yield* cache.write(source, text).pipe(
Effect.catchCauseIf(
(cause) => !Cause.hasInterruptsOnly(cause),
(cause) => Effect.logWarning("Failed to cache models.dev catalog", { cause }),
),
)
return data
})
const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
const text = yield* fetchApi()
const catalog = yield* decodeCatalog(text)
yield* writeCache(text)
return catalog
const get = Effect.fn("ModelsDev.get")(function* () {
yield* Fiber.join(initialized)
if (state.data) return state.data
return yield* lock
.withPermit(
Effect.gen(function* () {
return state.data ?? (yield* update())
}),
)
.pipe(Effect.orDie)
})
const populate = Effect.gen(function* () {
const fromFile = yield* loadFromFile
if (fromFile) return normalize(fromFile)
const cached = options?.file ? undefined : yield* loadFromCache()
if (cached) return normalize(cached.catalog)
const bundled = yield* loadSnapshot
if (bundled) return bundled
if (!fetch) return []
const catalog = yield* lock.withPermit(
Effect.gen(function* () {
const stored = options?.file ? undefined : yield* loadFromCache()
if (stored) return stored.catalog
return yield* fetchAndWrite()
}),
)
return normalize(catalog)
}).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie)
const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity)
const get = (): Effect.Effect<readonly Snapshot[]> => cachedGet
const refresh = Effect.fn("ModelsDev.refresh")(function* (force = false) {
yield* lock
.withPermit(
Effect.gen(function* () {
const stored = yield* loadFromCache()
if (!force && stored && Date.now() - stored.updatedAt < Duration.toMillis(ttl)) return
const text = yield* fetchApi()
// models.dev rarely changes between polls; skip the cache write,
// invalidation, and Refreshed event for a byte-identical body so
// downstream catalog.updated listeners stay quiet.
if (!force && stored?.digest === bodyDigest(text)) return
yield* decodeCatalog(text)
yield* writeCache(text)
yield* invalidate
yield* bus.publish(ModelsDev.Event.Refreshed, {})
yield* Fiber.join(initialized)
if (!force && Date.now() - state.checkedAt < Duration.toMillis(ttl)) return
yield* update(force)
}),
)
.pipe(
Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause: cause })),
Effect.ignore,
Effect.orDie,
Effect.catchCauseIf(
(cause) => !Cause.hasInterruptsOnly(cause),
(cause) => Effect.logError("Failed to refresh models.dev", { cause }),
),
)
})
@@ -710,7 +689,7 @@ export function configured(options?: Options) {
return makeGlobalNode({
service: Service,
layer: layer(options),
deps: [FSUtil.node, Bus.node, App.node, KV.node, httpClient],
deps: [FSUtil.node, Bus.node, App.node, ModelsDevCache.node, httpClient],
})
}
+60
View File
@@ -0,0 +1,60 @@
export * as ModelsDevCache from "./cache.js"
import path from "path"
import { Context, Effect, FileSystem, Layer, Option } from "effect"
import type { PlatformError } from "effect/PlatformError"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
import { Global } from "@opencode-ai/util/global"
import { Hash } from "@opencode-ai/util/hash"
export interface Entry {
readonly body: string
readonly updatedAt: number
}
export interface Interface {
readonly read: (source: string) => Effect.Effect<Entry | undefined, PlatformError>
readonly write: (source: string, body: string) => Effect.Effect<void, PlatformError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ModelsDevCache") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const directory = path.join(global.cache, "models-dev")
const read = Effect.fn("ModelsDevCache.read")(
function* (source: string) {
const file = path.join(directory, `${Hash.fast(source)}.json`)
const body = yield* fs.readFileString(file)
const info = yield* fs.stat(file)
return { body, updatedAt: Option.getOrUndefined(info.mtime)?.getTime() ?? 0 }
},
Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined),
)
const write = Effect.fn("ModelsDevCache.write")(function* (source: string, body: string) {
yield* fs.makeDirectory(directory, { recursive: true })
const temporary = yield* fs.makeTempFileScoped({ directory, prefix: ".tmp-" })
yield* fs.writeFileString(temporary, body)
yield* fs.rename(temporary, path.join(directory, `${Hash.fast(source)}.json`))
}, Effect.scoped)
return Service.of({ read, write })
}),
)
export const node = makeGlobalNode({
service: Service,
layer,
deps: [LayerNodePlatform.filesystem, Global.node],
})
export const disabledLayer = Layer.succeed(
Service,
Service.of({ read: () => Effect.undefined, write: () => Effect.void }),
)
+1 -1
View File
@@ -384,7 +384,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
add: (tool) => draft.add(tool),
}),
)
.pipe(Effect.as({ dispose: Effect.void })),
.pipe(Effect.orDie, Effect.as({ dispose: Effect.void })),
hook: (name, callback) => hooks.register("tool", name, callback),
},
vcs: {
+1 -3
View File
@@ -29,7 +29,7 @@ export interface Interface {
| "wait"
| "context"
>
readonly job: Pick<Job.Interface, "start" | "wait" | "block" | "background" | "cancel" | "completeBackground">
readonly job: Pick<Job.Interface, "start" | "wait" | "block" | "background" | "cancel">
readonly location: {
readonly agent: {
readonly list: (
@@ -92,8 +92,6 @@ export const layerWithCell = (cell: Cell) =>
block: (input) => require(cell, (runtime) => runtime.job.block(input)),
background: (id) => require(cell, (runtime) => runtime.job.background(id)),
cancel: (id) => require(cell, (runtime) => runtime.job.cancel(id)),
completeBackground: (notificationID) =>
require(cell, (runtime) => runtime.job.completeBackground(notificationID)),
},
location: {
agent: {
@@ -1,6 +1,6 @@
export * as PluginSupervisor from "./supervisor-service.js"
import { Context, Effect, Layer } from "effect"
import { Context, Effect } from "effect"
/**
* Dependency-only supervisor seam. Keep this module free of implementation
@@ -12,6 +12,3 @@ export interface Interface {
}
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginSupervisor") {}
/** For values-constructed environments: no plugin generations exist, so flush settles immediately. */
export const noop = Layer.succeed(Service, Service.of({ flush: Effect.void }))
+1 -1
View File
@@ -1,5 +1,5 @@
export * as PluginSupervisor from "./supervisor.js"
export { noop, Service, type Interface } from "./supervisor-service.js"
export { Service, type Interface } from "./supervisor-service.js"
import { Event } from "@opencode-ai/schema/config"
import { Cause, Effect, Latch, Layer, Stream } from "effect"
-145
View File
@@ -1,145 +0,0 @@
export * as SessionEngine from "./session-engine.js"
import { Context, Effect, Layer, Scope } from "effect"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Agent } from "./agent.js"
import { Catalog } from "./catalog.js"
import { Location } from "./location.js"
import { McpInstructions } from "./mcp/instructions.js"
import { McpTool } from "./tool/mcp.js"
import { PluginSupervisor } from "./plugin/supervisor.js"
import { Session } from "./session.js"
import { SessionEngineBindings } from "./session/engine-bindings.js"
import { SessionRunnerModel } from "./session/runner/model.js"
import { SessionSchema } from "./session/schema.js"
import { Snapshot } from "./snapshot.js"
import { Tool } from "./tool.js"
import { compileWithHoistedGlobals, sessionEngineGroup, type SessionEngine, type SessionEngineError } from "./location-services.js"
import type { AbsolutePath } from "./schema.js"
/**
* Values-constructed session environment: the engine tier of the location
* graph, booted without discovery, plugins, or MCP. Capabilities arrive
* through the same draft APIs plugins use, so registry invariants (hook
* wiring, image normalization, permission gating) hold by construction.
*/
export interface Options {
readonly directory: AbsolutePath
/**
* Fixed model for every drain in this environment, bypassing catalog
* resolution (SessionRunnerModel.resolved is the values-side constructor).
* Omit to resolve through the populated catalog instead.
*/
readonly model?: SessionRunnerModel.Resolved
/** Capture filesystem snapshots around attempts. Defaults to false. */
readonly snapshots?: boolean
readonly tools?: (draft: Tool.Draft) => void
readonly agents?: (draft: Agent.Draft) => void
readonly catalog?: (draft: Catalog.Draft) => void
}
type PromptOptions = Omit<Parameters<Session.Interface["prompt"]>[0], "sessionID">
type SessionOptions = Omit<Parameters<Session.Interface["create"]>[0], "location" | "parentID">
export interface SessionHandle {
readonly id: SessionSchema.ID
readonly prompt: (input: PromptOptions) => ReturnType<Session.Interface["prompt"]>
readonly interrupt: (input?: { readonly continue?: boolean }) => Effect.Effect<boolean>
}
export interface Handle {
/**
* Ensure a durable session and bind it to this environment. Reusing a
* Session ID adopts the existing Session (creation args are ignored then),
* so reconnection after a restart is the same call with the same ID. The
* binding lives until the environment's scope closes; drains resolve the
* bound graph instead of the Session's Location graph.
*/
readonly session: (input?: SessionOptions) => Effect.Effect<SessionHandle, Session.NotFoundError>
}
export interface Interface {
readonly make: (options: Options) => Effect.Effect<Handle, SessionEngineError, Scope.Scope>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionEngine") {}
/**
* Captures the application root's MemoMap at construction (the same trick
* LayerMap.make uses), so each environment's hoisted global nodes dedupe
* against the running Database, Bus, and SessionStore instead of building
* second instances. The engine subtree itself builds fresh per environment.
*
* Like buildLocationServiceMap, the layer must receive the application
* root's replacements: hoisted globals otherwise compile their original
* implementations and a composed root (test harness, embedded host) would
* build second, differently-configured instances.
*/
const layerWith = (base: LayerNode.Replacements) =>
Layer.effect(
Service,
Effect.gen(function* () {
const memoMap = Layer.CurrentMemoMap.forkOrCreate(yield* Effect.context<never>())
const bindings = yield* SessionEngineBindings.Service
const sessions = yield* Session.Service
const make = Effect.fn("SessionEngine.make")(function* (options: Options) {
const scope = yield* Effect.scope
const location = Location.Ref.make({ directory: options.directory })
// Later entries win in the replacement map, so environment-specific
// substitutions override same-node entries from the application root.
const replacements: LayerNode.Replacements = [
...base,
[Location.node, Location.boundNode(location)],
[PluginSupervisor.node, PluginSupervisor.noop],
[McpTool.node, McpTool.noop],
[McpInstructions.node, McpInstructions.noop],
...(options.snapshots === true ? [] : [[Snapshot.node, Snapshot.noopLayer] as const]),
...(options.model === undefined
? []
: [[SessionRunnerModel.node, SessionRunnerModel.fixed(options.model)] as const]),
]
const context = yield* Layer.buildWithMemoMap(
compileWithHoistedGlobals(sessionEngineGroup, replacements),
memoMap,
scope,
)
const populate = Effect.gen(function* () {
const tools = options.tools
if (tools) yield* Tool.Service.use((service) => service.transform(tools))
const agents = options.agents
if (agents) yield* Agent.Service.use((service) => service.transform(agents))
const catalog = options.catalog
if (catalog) yield* Catalog.Service.use((service) => service.transform(catalog))
})
yield* populate.pipe(Effect.provide(context), Effect.provideService(Scope.Scope, scope))
const session = Effect.fn("SessionEngine.session")(function* (input?: SessionOptions) {
// Create-or-adopt: ID reuse returns the existing durable Session, and the
// binding outranks its recorded Location even if the directories differ.
const info = yield* sessions.create({ ...input, location })
// Bind in the environment's scope: teardown must unbind every session so
// drains fall back to the Location graph instead of a torn-down context.
yield* bindings.bind(info.id, context).pipe(Effect.provideService(Scope.Scope, scope))
return {
id: info.id,
prompt: (promptInput: PromptOptions) => sessions.prompt({ ...promptInput, sessionID: info.id }),
interrupt: (interruptInput?: { readonly continue?: boolean }) =>
sessions.interrupt(info.id, interruptInput),
} as const
})
return { session } as const
})
return Service.of({ make })
}),
)
/** Thread the application root's replacements through, mirroring buildLocationServiceMap. */
export const configured = (replacements: LayerNode.Replacements = []) =>
makeGlobalNode({ service: Service, layer: layerWith(replacements), deps: [SessionEngineBindings.node, Session.node] })
export const node = configured()
@@ -1,48 +0,0 @@
export * as SessionEngineBindings from "./engine-bindings.js"
import { Context, Effect, Layer, Scope } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import type { SessionEngine } from "../location-services.js"
import { SessionSchema } from "./schema.js"
/**
* Process-local map from Session ID to a values-constructed engine graph.
* Execution resolves a bound context before falling back to the Session's
* Location graph, so tier-2 sessions drain against caller-supplied
* capabilities while every other session is untouched.
*/
export interface Interface {
/** Bind until the enclosing scope closes. Rebinding the same ID replaces the previous binding. */
readonly bind: (
id: SessionSchema.ID,
context: Context.Context<SessionEngine>,
) => Effect.Effect<void, never, Scope.Scope>
readonly get: (id: SessionSchema.ID) => Context.Context<SessionEngine> | undefined
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionEngineBindings") {}
export const layer = Layer.sync(Service, () => {
// Entries wrap the context so release identity is per bind call: binding the
// same context twice from different scopes must not let the first release
// tear down the survivor's entry.
const map = new Map<SessionSchema.ID, { readonly context: Context.Context<SessionEngine> }>()
return Service.of({
bind: (id, context) =>
Effect.acquireRelease(
Effect.sync(() => {
const entry = { context }
map.set(id, entry)
return entry
}),
(entry) =>
Effect.sync(() => {
// A later rebind owns the entry now; do not tear it down.
if (map.get(id) === entry) map.delete(id)
}),
).pipe(Effect.asVoid),
get: (id) => map.get(id)?.context,
})
})
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
+14 -27
View File
@@ -3,10 +3,8 @@ export * as SessionExecution from "./execution.js"
import { Cause, Context, Effect, Exit, Layer } from "effect"
import { Bus } from "../bus.js"
import { Database } from "../database/database.js"
import { Job } from "../job.js"
import { LocationServiceMap } from "../location-service-map.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { SessionEngineBindings } from "./engine-bindings.js"
import { SessionEvent } from "./event.js"
import { SessionRunCoordinator } from "./run-coordinator.js"
import { SessionRunner } from "./runner/index.js"
@@ -53,9 +51,7 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const store = yield* SessionStore.Service
const locations = yield* LocationServiceMap.Service
const bindings = yield* SessionEngineBindings.Service
const bus = yield* Bus.Service
const jobs = yield* Job.Service
const db = (yield* Database.Service).db
const reportLifecycle = <A>(sessionID: SessionSchema.ID, effect: Effect.Effect<A>) =>
effect.pipe(
@@ -85,30 +81,22 @@ export const layer = Layer.effect(
continuation?: SessionRunner.Continuation,
promotable: SessionInbox.Promotable = "input",
): Effect.Effect<void, SessionRunner.RunError> {
const loop = (
force: boolean,
continuation?: SessionRunner.Continuation,
): Effect.Effect<void, SessionRunner.RunError, SessionRunner.Service> =>
SessionRunner.Service.use((runner) => runner.drain({ sessionID, force, continuation, promotable })).pipe(
Effect.flatMap((result) => (result._tag === "Complete" ? Effect.void : loop(false, result.continuation))),
)
return Effect.gen(function* () {
// The environment is resolved once and pinned for the whole busy period, so a
// binding change never switches environments between continuations. A bound
// values-constructed environment outranks the Session's Location graph and
// implies the Session exists, since binding follows durable creation.
const bound = bindings.get(sessionID)
if (bound) return yield* loop(force, continuation).pipe(Effect.provide(bound))
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
return yield* loop(force, continuation).pipe(Effect.provide(locations.get(session.location)))
}).pipe(
Effect.tapCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.void
: Effect.logError("Failed to drain Session", cause).pipe(Effect.annotateLogs({ sessionID })),
),
)
const result = yield* SessionRunner.Service.use((runner) =>
runner.drain({ sessionID, force, continuation, promotable }),
).pipe(
Effect.provide(locations.get(session.location)),
Effect.tapCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.void
: Effect.logError("Failed to drain Session", cause).pipe(Effect.annotateLogs({ sessionID })),
),
)
if (result._tag === "Complete") return
return yield* drain(sessionID, false, result.continuation, promotable)
})
}
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError, InterruptReason>({
started: (sessionID) =>
@@ -130,7 +118,6 @@ export const layer = Layer.effect(
if (outcome.type === "interrupted") {
// A user cancel releases the claim: the turn must not resurrect at the next
// boot. Shutdown interruption keeps it for restart continuity.
if (outcome.reason === "user") yield* jobs.cancel(sessionID)
yield* bus.publish(
SessionEvent.Execution.Interrupted,
{ sessionID, reason: outcome.reason },
@@ -180,7 +167,7 @@ export const layer = Layer.effect(
export const node = makeGlobalNode({
service: Service,
layer,
deps: [SessionStore.node, LocationServiceMap.node, SessionEngineBindings.node, Bus.node, Database.node, Job.node],
deps: [SessionStore.node, LocationServiceMap.node, Bus.node, Database.node],
})
/** Low-level compatibility layer for callers that only need durable Session recording. */
+17 -159
View File
@@ -3,8 +3,6 @@ export * as SessionRestart from "./restart.js"
import { Context, Effect, Layer } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "../../bus.js"
import { Job } from "../../job.js"
import { Session } from "../../session.js"
import { SessionEvent } from "../event.js"
import { SessionExecution } from "../execution.js"
import { SessionSchema } from "../schema.js"
@@ -47,9 +45,6 @@ export interface Interface {
* process: crash, SIGKILL, isolate eviction, and graceful restart all leave
* the same durable signature.
*
* Recovery is at-least-once: local coordination prevents concurrent drains,
* not repeated external side effects after a crash.
*
* The sweep assumes every orphaned claim's owner is dead. The managed-server
* protocol guarantees this: a successor is only spawned after the previous
* process is confirmed dead (client service `kill`/`evict` poll the PID), the
@@ -67,16 +62,14 @@ export const layer = (options?: Options) =>
const store = yield* SessionStore.Service
const execution = yield* SessionExecution.Service
const bus = yield* Bus.Service
const jobs = yield* Job.Service
const sessions = yield* Session.Service
const scope = yield* Effect.scope
const maxAttempts = options?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS
const prepareResume = Effect.fnUntraced(function* (sessionID: SessionSchema.ID) {
const resumeOne = Effect.fnUntraced(function* (sessionID: SessionSchema.ID) {
// Durable before the resume runs, so a crash inside the resumed turn is
// counted by the next sweep and the budget cannot be dodged.
const attempts = yield* store.countResume(sessionID)
if (attempts === undefined) return false
if (attempts === undefined) return // the Session was deleted since listing
if (attempts > maxAttempts) {
// Terminalize instead: the release hook clears the claim and resets the
// counter atomically with the terminal event.
@@ -85,166 +78,31 @@ export const layer = (options?: Options) =>
{ sessionID, error: RESUME_EXHAUSTED },
{ commit: () => store.release(sessionID) },
)
return false
return
}
yield* bus.publish(SessionEvent.Synthetic, {
sessionID,
text: CONTINUE_AFTER_SERVER_RESTART,
description: "Continuing after restart",
})
return true
})
const recoverShell = Effect.fnUntraced(function* (
background: Job.Background,
recovery: Extract<Job.Recovery, { kind: "shell" }>,
) {
const state = background.status === "running" ? "cancelled" : background.status
const text =
background.status === "running"
? "Command cancelled because the server restarted"
: state === "completed"
? (background.output ?? "Command completed")
: state === "error"
? (background.error ?? "Command failed")
: "Command cancelled"
yield* sessions
.synthetic({
id: background.notificationID,
sessionID: recovery.sessionID,
description: recovery.command,
text: `<shell id="${background.id}" state="${state}" command="${recovery.command}">\n${text}\n</shell>`,
metadata: {
source: "shell",
jobID: background.id,
shellID: recovery.shellID,
state,
},
resume: false,
})
.pipe(
Effect.catchTag("Session.NotFoundError", () => Effect.void),
Effect.orDie,
)
yield* jobs.completeBackground(background.notificationID)
})
const recoverSubagent = Effect.fnUntraced(function* (
background: Job.Background,
recovery: Extract<Job.Recovery, { kind: "subagent" }>,
suspended: ReadonlySet<SessionSchema.ID>,
) {
const child = yield* store.get(recovery.childSessionID)
if (!child || child.parentID !== recovery.parentSessionID || !(yield* store.get(recovery.parentSessionID))) {
yield* jobs.completeBackground(background.notificationID)
return
}
const notify = Effect.fnUntraced(function* (result: Pick<Job.Background, "status" | "output" | "error">) {
if (result.status === "running") return
const text =
result.status === "completed"
? (result.output ?? "Subagent completed without a text response.")
: result.status === "error"
? (result.error ?? "Subagent failed")
: "Subagent cancelled"
yield* sessions
.synthetic({
id: background.notificationID,
sessionID: recovery.parentSessionID,
...(suspended.has(recovery.parentSessionID) ? { resume: false } : {}),
description: recovery.description,
text: `<subagent sessionID="${recovery.childSessionID}" state="${result.status}" description="${recovery.description}">\n${text}\n</subagent>`,
metadata: {
source: "subagent",
childID: recovery.childSessionID,
agent: recovery.agent,
state: result.status,
},
})
.pipe(Effect.orDie)
yield* jobs.completeBackground(background.notificationID)
})
if (background.status !== "running") {
yield* notify(background)
return
}
if ((yield* execution.active).has(recovery.childSessionID)) return
if (!(yield* prepareResume(recovery.childSessionID))) {
yield* notify({ status: "error", error: RESUME_EXHAUSTED.message })
return
}
yield* jobs.start({
id: background.id,
type: "subagent",
title: recovery.description,
notificationID: background.notificationID,
recovery,
run: execution.resume(recovery.childSessionID).pipe(
Effect.andThen(store.context(recovery.childSessionID)),
Effect.map((messages) => {
const assistant = messages.findLast(
(message) =>
message.type === "assistant" && message.time.completed !== undefined && message.error === undefined,
)
if (assistant?.type !== "assistant") return "Subagent completed without a text response."
return (
assistant.content
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("") || "Subagent completed without a text response."
)
}),
),
})
yield* jobs.background(background.id)
yield* jobs.wait({ id: background.id }).pipe(
Effect.flatMap((result) => (result.info ? notify(result.info) : Effect.void)),
Effect.ignore,
Effect.forkIn(scope),
)
// Forked into the service scope so boot never waits on resumed turns;
// resuming an already-live Session joins its execution. Drain failures
// are logged and durably recorded by the execution layer.
yield* execution.resume(sessionID).pipe(Effect.ignore, Effect.forkIn(scope))
})
return Service.of({
resumeSuspendedSessions: Effect.gen(function* () {
// Child claims never drive recovery (children are not resumed), so a
// dead child's claim is noise no terminal will ever release. Clearing
// is safe even against a live child: claims are recovery markers, not
// locks, and children are excluded from that recovery.
yield* store.releaseChildClaims
const active = yield* execution.active
// Early notices wait for root recovery's accounting, including roots that exhaust their budget.
const suspended = new Set((yield* store.listSuspended()).filter((sessionID) => !active.has(sessionID)))
const pending = yield* jobs.pendingBackground
yield* store.releaseChildClaims(
pending.flatMap((background) =>
background.status === "running" && background.recovery.kind === "subagent"
? [background.recovery.childSessionID]
: [],
),
)
yield* Effect.forEach(
pending,
Effect.fnUntraced(function* (background) {
if ((yield* jobs.get(background.id))?.status === "running") return
const recovery = background.recovery
yield* recovery.kind === "shell"
? recoverShell(background, recovery)
: recoverSubagent(background, recovery, suspended)
}),
{ discard: true },
)
// Background completion can wake a parent, so inspect local ownership only after recovery.
const resumed = yield* execution.active
yield* Effect.forEach(
(yield* store.listSuspended()).filter((sessionID) => !resumed.has(sessionID)),
(sessionID) =>
execution
.resume(sessionID)
.pipe(Effect.ignore, Effect.forkIn(scope), Effect.when(prepareResume(sessionID))),
{ concurrency: "unbounded", discard: true },
)
// Async observers consult this set at delivery; later completions wake parents normally.
suspended.clear()
// Sessions already draining in this process keep their claim; resuming
// them would only inject a stray continuation into a live turn.
const orphaned = (yield* store.listSuspended()).filter((sessionID) => !active.has(sessionID))
yield* Effect.forEach(orphaned, resumeOne, { concurrency: "unbounded", discard: true })
}),
})
}),
@@ -253,5 +111,5 @@ export const layer = (options?: Options) =>
export const node = makeGlobalNode({
service: Service,
layer: layer(),
deps: [SessionStore.node, SessionExecution.node, Bus.node, Job.node, Session.node],
deps: [SessionStore.node, SessionExecution.node, Bus.node],
})
+141 -131
View File
@@ -78,143 +78,83 @@ const layer = Layer.effect(
readonly continuation?: Continuation
readonly promotable?: SessionInbox.Promotable
}) {
const sessionID = input.sessionID
let force = input.force
let continuing = input.continuation !== undefined
let step = input.continuation?.step ?? 1
let entering = true
let continuation = input.continuation
const promotable = input.promotable ?? "input"
if (!force && !continuing) {
const pending = yield* SessionInbox.nextPromotable(db, sessionID, "input")
if (
!pending ||
(pending.delivery === "queue" &&
promotable === "steer" &&
pending.type !== "compaction" &&
pending.type !== "move")
)
return DrainResult.Complete()
}
if (!force && !continuation && !(yield* eligible(input.sessionID, promotable))) return DrainResult.Complete()
yield* plugins.flush
yield* settleStaleToolCalls(sessionID)
const advanceToStep = Effect.fn("SessionRunner.advanceToStep")(() =>
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
while (true) {
// Location entry and idle boundaries allow queued controls, not necessarily queued prompts.
const pending = yield* SessionInbox.serialized(
sessionID,
Effect.gen(function* () {
const next = yield* SessionInbox.nextPromotable(
db,
sessionID,
entering || !continuing ? "input" : "steer",
)
if (next?.type === "compaction")
yield* bus.publishAll([
[SessionEvent.InboxDelivered, { sessionID, inboxID: next.id }],
[SessionEvent.Compaction.Started, { sessionID, reason: "manual", recent: "", inputID: next.id }],
])
if (next?.type === "move")
yield* restore(
Effect.gen(function* () {
yield* modelTransport.close(sessionID)
yield* bus.publishAll([
[SessionEvent.InboxDelivered, { sessionID, inboxID: next.id }],
[SessionEvent.Moved, { sessionID, ...next.payload }],
])
}),
)
return next
}),
)
if (!continuing && pending?.delivery !== "steer") {
entering = true
step = 1
}
if (pending?.type === "move")
return DrainResult.Moved({ continuation: !entering && continuing ? { step } : undefined })
if (pending?.type === "compaction") {
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
const compacted = yield* restore(
Effect.gen(function* () {
return yield* compaction.compactManual({
session,
messages: yield* store.context(sessionID),
inputID: pending.id,
started: true,
})
}),
).pipe(Effect.exit)
if (Exit.isFailure(compacted)) {
yield* bus.publish(SessionEvent.Compaction.Failed, {
sessionID,
reason: "manual",
error: Cause.hasInterruptsOnly(compacted.cause)
? { type: "aborted", message: "Compaction cancelled" }
: { type: "compaction.failed", message: Cause.pretty(compacted.cause) },
inputID: pending.id,
})
return yield* Effect.failCause(compacted.cause)
}
force = false
continue
}
if (!force && !continuing && (!pending || (pending.delivery === "queue" && promotable === "steer")))
return DrainResult.Complete()
return yield* restore(
Effect.gen(function* () {
const selected = yield* prepareContext(sessionID)
const promoted = yield* SessionInbox.promote(
db,
bus,
sessionID,
entering && !continuing ? promotable : "steer",
)
if (promoted > 0 && !selected.session.parentID && SessionTitle.isUntitled(selected.session))
yield* FiberMap.run(titles, sessionID, title.generate(sessionID).pipe(Effect.ignore), {
onlyIfMissing: true,
})
if (promoted > 0) step = 1
return { _tag: "Ready" as const, context: yield* context.load(selected) }
}),
)
}
}),
),
)
yield* settleStaleToolCalls(input.sessionID)
while (true) {
const next = yield* advanceToStep()
if (next._tag !== "Ready") return next
continuing = yield* runStep(next.context, step)
step++
// Scope gates input promotion, not a between-step control that is next in line.
if (yield* runPendingCompaction(input.sessionID, "input")) {
force = false
continue
}
if (yield* runPendingMove(input.sessionID, "input")) return DrainResult.Moved({})
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, promotable)))
return DrainResult.Complete()
const result = yield* runSteps(input.sessionID, continuation, promotable)
if (result._tag === "Moved") return result
force = false
entering = false
continuation = undefined
}
})
const prepareContext = Effect.fn("SessionRunner.prepareContext")(function* (sessionID: SessionSchema.ID) {
const selected = yield* context.select(sessionID)
// A blocked initial instruction baseline must leave admitted input pending.
yield* InstructionState.prepare(db, bus, selected.instructions, sessionID)
return selected
const eligible = Effect.fnUntraced(function* (sessionID: SessionSchema.ID, promotable: SessionInbox.Promotable) {
if (yield* SessionInbox.has(db, sessionID, promotable)) return true
if (promotable === "input") return false
const next = yield* SessionInbox.nextPromotable(db, sessionID, "input")
return next?.type === "compaction" || next?.type === "move"
})
/** Queued inputs wait until the current model work reaches idle; later Steps absorb only steers. */
const runSteps = Effect.fn("SessionRunner.runSteps")(function* (
sessionID: SessionSchema.ID,
continuation: Continuation | undefined,
drainPromotable: SessionInbox.Promotable,
) {
let promotable: SessionInbox.Promotable = continuation ? "steer" : drainPromotable
let step = continuation?.step ?? 1
let next = continuation
let first = true
while (true) {
if (yield* runPendingCompaction(sessionID, "steer")) continue
if (yield* runPendingMove(sessionID, "steer")) return DrainResult.Moved({ continuation: next })
if (!first && !next && !(yield* SessionInbox.has(db, sessionID, "steer"))) return DrainResult.Complete()
const result = yield* runStep(sessionID, promotable, step)
first = false
promotable = "steer"
step = result.step + 1
next = result.needsContinuation ? { step } : undefined
}
})
/** Owns logical Step policy; each attempt owns its streaming, tools, and durable settlement. */
const runStep = Effect.fn("SessionRunner.runStep")(function* (first: SessionContext.Loaded, step: number) {
const sessionID = first.session.id
const runStep = Effect.fn("SessionRunner.runStep")(function* (
sessionID: SessionSchema.ID,
promotable: SessionInbox.Promotable,
step: number,
) {
let assistantMessageID = SessionMessage.ID.create()
const retry = yield* Schedule.toStepWithSleep(SessionRunnerRetry.schedule(bus, sessionID))
let initial: SessionContext.Loaded | undefined = first
let currentPromotable: SessionInbox.Promotable | undefined = promotable
let currentStep = step
let recoverOverflow = true
let recoverContinuation = true
while (true) {
// Reuse boundary preparation once; retries refresh context without delivering more input.
const loaded = initial ?? (yield* prepareContext(sessionID).pipe(Effect.flatMap(context.load)))
initial = undefined
const selected = yield* context.select(sessionID)
// A blocked initial instruction baseline must leave admitted input pending.
yield* InstructionState.prepare(db, bus, selected.instructions, selected.session.id)
const promoted = currentPromotable
? yield* SessionInbox.promote(db, bus, selected.session.id, currentPromotable)
: 0
if (promoted > 0 && !selected.session.parentID && SessionTitle.isUntitled(selected.session))
yield* FiberMap.run(titles, sessionID, title.generate(sessionID).pipe(Effect.ignore), {
onlyIfMissing: true,
})
currentStep = promoted > 0 ? 1 : currentStep
currentPromotable = undefined
const loaded = yield* context.load(selected)
const compactionInput = { session: loaded.session, messages: loaded.messages, resolved: loaded.model }
if (compaction.required(compactionInput)) {
const compacted = yield* compaction.compact(compactionInput)
@@ -222,7 +162,7 @@ const layer = Layer.effect(
assistantMessageID = SessionMessage.ID.create()
continue
}
const stepLimitReached = loaded.agent.info.steps !== undefined && step >= loaded.agent.info.steps
const stepLimitReached = loaded.agent.info.steps !== undefined && currentStep >= loaded.agent.info.steps
const transcript = SessionModelRequest.baseTranscript({
agent: loaded.agent.info,
model: loaded.model,
@@ -257,7 +197,7 @@ const layer = Layer.effect(
: Effect.succeed(false),
),
})
if (outcome._tag === "Completed") return outcome.needsContinuation
if (outcome._tag === "Completed") return { needsContinuation: outcome.needsContinuation, step: currentStep }
if (outcome._tag === "Retry" || outcome._tag === "Continue") {
yield* retry({ cause: outcome.cause, error: outcome.error, assistantMessageID }).pipe(
Pull.catchDone(() =>
@@ -283,6 +223,77 @@ const layer = Layer.effect(
}
})
const runPendingCompaction = Effect.fn("SessionRunner.runPendingCompaction")(function* (
sessionID: SessionSchema.ID,
promotable: SessionInbox.Promotable,
) {
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const pending = yield* SessionInbox.serialized(
sessionID,
Effect.gen(function* () {
const selected = yield* SessionInbox.nextPromotable(db, sessionID, promotable)
if (selected?.type !== "compaction") return
yield* bus.publishAll([
[SessionEvent.InboxDelivered, { sessionID, inboxID: selected.id }],
[SessionEvent.Compaction.Started, { sessionID, reason: "manual", recent: "", inputID: selected.id }],
])
return selected
}),
)
if (pending?.type !== "compaction") return false
const session = yield* getSession(sessionID)
const compacted = yield* restore(
Effect.gen(function* () {
return yield* compaction.compactManual({
session,
messages: yield* store.context(sessionID),
inputID: pending.id,
started: true,
})
}),
).pipe(Effect.exit)
if (Exit.isSuccess(compacted)) return true
yield* bus.publish(SessionEvent.Compaction.Failed, {
sessionID,
reason: "manual",
error: Cause.hasInterruptsOnly(compacted.cause)
? { type: "aborted", message: "Compaction cancelled" }
: { type: "compaction.failed", message: Cause.pretty(compacted.cause) },
inputID: pending.id,
})
return yield* Effect.failCause(compacted.cause)
}),
)
})
const runPendingMove = Effect.fn("SessionRunner.runPendingMove")(function* (
sessionID: SessionSchema.ID,
promotable: SessionInbox.Promotable,
) {
return yield* SessionInbox.serialized(
sessionID,
Effect.gen(function* () {
const pending = yield* SessionInbox.nextPromotable(db, sessionID, promotable)
if (pending?.type !== "move") return false
yield* modelTransport.close(sessionID)
yield* bus.publishAll([
[SessionEvent.InboxDelivered, { sessionID, inboxID: pending.id }],
[
SessionEvent.Moved,
{
sessionID,
location: pending.payload.location,
projectID: pending.payload.projectID,
subpath: pending.payload.subpath,
},
],
])
return true
}),
)
})
const settleStaleToolCalls = Effect.fn("SessionRunner.settleStaleToolCalls")(function* (
sessionID: SessionSchema.ID,
) {
@@ -290,24 +301,23 @@ const layer = Layer.effect(
if (message.type !== "assistant") continue
for (const tool of message.content) {
if (tool.type !== "tool" || (tool.state.status !== "streaming" && tool.state.status !== "running")) continue
const metadata = tool.state.status === "running" ? tool.state.metadata : undefined
const childID =
tool.name === "subagent" && typeof metadata?.sessionID === "string" ? metadata.sessionID : undefined
yield* bus.publish(SessionEvent.Tool.Failed, {
sessionID,
assistantMessageID: message.id,
id: tool.id,
error: {
type: "aborted",
message: `Tool execution interrupted: ${tool.name}${childID ? ` (sessionID: ${childID})` : ""}`,
},
...(metadata && Object.keys(metadata).length > 0 ? { metadata } : {}),
error: { type: "aborted", message: `Tool execution interrupted: ${tool.name}` },
executed: tool.executed === true,
})
}
}
})
const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) {
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
return session
})
return Service.of({ drain })
}),
)
@@ -67,10 +67,6 @@ export const resolved = (
limit: options.limit,
})
/** Layer resolving every session to one fixed model, bypassing the catalog. Test or embedding seam. */
export const fixed = (resolved: Resolved) =>
Layer.succeed(Service, Service.of({ resolve: () => Effect.succeed(resolved) }))
const layer = Layer.effect(
Service,
Effect.gen(function* () {
@@ -327,10 +327,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
sessionID: input.sessionID,
assistantMessageID,
id,
error:
tool.name === "subagent" && error.type === "aborted" && typeof tool.progress?.sessionID === "string"
? { ...error, message: `${error.message} (sessionID: ${tool.progress.sessionID})` }
: error,
error,
...failureSnapshot(tool, metadata),
executed: tool.providerExecuted,
})
+14 -20
View File
@@ -1,6 +1,6 @@
export * as SessionStore from "./store.js"
import { and, eq, isNotNull, isNull, notInArray, sql } from "drizzle-orm"
import { and, eq, isNotNull, isNull, sql } from "drizzle-orm"
import { Context, Effect, Layer } from "effect"
import { Database } from "../database/database.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
@@ -18,8 +18,9 @@ export interface Interface {
messageID: SessionMessage.ID,
) => Effect.Effect<{ readonly sessionID: Session.ID; readonly message: SessionMessage.Info } | undefined>
/**
* Top-level Sessions holding an execution claim. Recoverable background
* children are resumed separately through their durable Job records.
* Top-level Sessions holding an execution claim. Child (subagent) Sessions
* are excluded: a resumed parent re-runs its tool call and spawns fresh
* children, so resuming orphaned children would duplicate their work.
*/
readonly listSuspended: () => Effect.Effect<ReadonlyArray<Session.ID>>
/**
@@ -32,10 +33,11 @@ export interface Interface {
/** Releases the claim and resets resume accounting. Terminal events call this on commit. */
readonly release: (sessionID: Session.ID) => Effect.Effect<void>
/**
* Clears orphaned child claims except children owned by recoverable
* background subagent jobs.
* Clears orphaned child (subagent) claims. Children are never resumed
* independently, so a dead child's claim is noise no terminal will ever
* release.
*/
readonly releaseChildClaims: (recoverable: ReadonlyArray<Session.ID>) => Effect.Effect<void>
readonly releaseChildClaims: Effect.Effect<void>
/**
* Durably counts one more resume of an orphaned claim, returning the new
* total — or undefined when the Session no longer exists.
@@ -101,20 +103,12 @@ const layer = Layer.effect(
.run()
.pipe(Effect.orDie)
}),
releaseChildClaims: Effect.fn("SessionStore.releaseChildClaims")((recoverable) =>
db
.update(SessionTable)
.set({ time_suspended: null, resume_attempts: 0, time_updated: sql`${SessionTable.time_updated}` })
.where(
and(
isNotNull(SessionTable.time_suspended),
isNotNull(SessionTable.parent_id),
recoverable.length > 0 ? notInArray(SessionTable.id, Array.from(recoverable)) : undefined,
),
)
.run()
.pipe(Effect.orDie, Effect.asVoid),
),
releaseChildClaims: db
.update(SessionTable)
.set({ time_suspended: null, resume_attempts: 0, time_updated: sql`${SessionTable.time_updated}` })
.where(and(isNotNull(SessionTable.time_suspended), isNotNull(SessionTable.parent_id)))
.run()
.pipe(Effect.orDie, Effect.asVoid, Effect.withSpan("SessionStore.releaseChildClaims")),
countResume: Effect.fn("SessionStore.countResume")(function* (sessionID) {
const row = yield* db
.update(SessionTable)
+2 -2
View File
@@ -134,8 +134,8 @@ const layer = () =>
Effect.gen(function* () {
for (const session of sessions.values()) {
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
// Teardown interrupts pending commands; it is not a terminal command failure.
yield* Deferred.interrupt(session.done)
// Unblock waiters still pending at teardown; succeed is a no-op once already resolved.
yield* Deferred.fail(session.done, new NotFoundError({ id: Shell.ID.make(session.info.id) }))
}
sessions.clear()
exitOrder.length = 0
+35 -34
View File
@@ -22,12 +22,10 @@ export class RegistrationError extends Schema.TaggedError<RegistrationError>()("
message: Schema.String,
}) {}
export interface Draft {
readonly add: (tool: Tool.Info) => void
}
export interface Interface {
readonly transform: (callback: (draft: Draft) => void) => Effect.Effect<void, never, Scope.Scope>
readonly transform: (
callback: (draft: { readonly add: (tool: Tool.Info) => void }) => void,
) => Effect.Effect<void, RegistrationError, Scope.Scope>
readonly snapshot: (permissions?: Permission.Ruleset) => Effect.Effect<Snapshot>
}
@@ -142,35 +140,45 @@ const layer = Layer.effect(
const transform: Interface["transform"] = Effect.fn("Tool.transform")(function* (callback) {
const tools: Array<Tool.Info> = []
yield* Effect.sync(() => callback({ add: (tool) => tools.push(tool) }))
const valid = yield* Effect.filter(normalizedEntries(tools), (entry) =>
Effect.gen(function* () {
if (entry.tool.options?.namespace !== undefined) yield* validateNamespace(entry.tool.options.namespace)
yield* validateName(normalizedName(entry.tool))
if (entry.tool.options?.codemode === false && entry.key === "execute")
return yield* new RegistrationError({
name: entry.key,
message: 'Tool name "execute" is reserved for CodeMode',
})
yield* Effect.try({
yield* Effect.forEach(
tools.flatMap((tool) => (tool.options?.namespace === undefined ? [] : [tool.options.namespace])),
validateNamespace,
{ discard: true },
)
const entries = normalizedEntries(tools)
yield* Effect.forEach(entries, (entry) => validateName(normalizedName(entry.tool)), { discard: true })
const collision = entries.find(
(entry, index) => entries.findIndex((candidate) => candidate.key === entry.key) !== index,
)
if (collision)
return yield* Effect.fail(
new RegistrationError({
name: collision.key,
message: `Duplicate normalized tool name: ${collision.key}`,
}),
)
const reserved = entries.find((entry) => entry.tool.options?.codemode === false && entry.key === "execute")
if (reserved)
return yield* Effect.fail(
new RegistrationError({
name: reserved.key,
message: 'Tool name "execute" is reserved for CodeMode',
}),
)
if (entries.length === 0) return
yield* Effect.forEach(
entries,
(entry) =>
Effect.try({
try: () => ToolDefinition.make(definition(entry.tool)),
catch: (error) =>
new RegistrationError({
name: entry.key,
message: `Invalid tool definition ${entry.key}: ${schemaMakeError(error)}`,
}),
})
return true
}).pipe(Effect.catchTag("Tool.RegistrationError", (error) => skipRegistration(entry.tool, error))),
}),
{ discard: true },
)
// Reject every ambiguous entry rather than choosing a winner.
const entries = yield* Effect.filter(valid, (entry) => {
if (!valid.some((candidate) => candidate !== entry && candidate.key === entry.key)) return Effect.succeed(true)
return skipRegistration(
entry.tool,
new RegistrationError({ name: entry.key, message: `Duplicate normalized tool name: ${entry.key}` }),
)
})
if (entries.length === 0) return
yield* Effect.uninterruptible(
lock.withPermit(
Effect.gen(function* () {
@@ -262,13 +270,6 @@ function schemaMakeError(error: unknown) {
return error instanceof Error ? error.message : String(error)
}
const skipRegistration = (tool: Tool.Info, error: RegistrationError) =>
Effect.logError("Skipping invalid tool registration", {
name: tool.name,
namespace: tool.options?.namespace,
error: error.message,
}).pipe(Effect.as(false))
const validateName = (name: string) =>
/^[A-Za-z0-9_-]{1,64}$/.test(name)
? Effect.void
+1 -4
View File
@@ -23,9 +23,6 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/McpTool") {}
/** For environments without MCP: registration settles immediately. */
export const noop = Layer.succeed(Service, Service.of({ flush: Effect.void }))
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
@@ -118,7 +115,7 @@ export const layer = Layer.effect(
})
}
})
.pipe(Scope.provide(next))
.pipe(Scope.provide(next), Effect.orDie)
if (current) yield* Scope.close(current, Exit.void)
current = next
}),
+51 -46
View File
@@ -115,45 +115,56 @@ export const Plugin = {
const permission = yield* Permission.Service
const config = yield* Config.Service
const notifyWhenDone = Effect.fn("ShellTool.notifyWhenDone")(
function* (
sessionID: SessionSchema.ID,
id: string,
shellID: string,
command: string,
settled: Deferred.Deferred<Output>,
) {
const info = (yield* runtime.job.wait({ id })).info
if (!info || info.status === "running") return
const output = info.status === "completed" ? yield* Deferred.await(settled) : undefined
const text = output
? resultMessages(output).join("\n\n")
: info.status === "error"
? (info.error ?? "Command failed")
: "Command cancelled"
yield* runtime.session.synthetic({
...(info.notificationID ? { id: info.notificationID } : {}),
sessionID,
text: `<shell id="${id}" state="${info.status}" command="${command}">\n${text}\n</shell>`,
description: command,
metadata: {
source: "shell",
jobID: id,
shellID,
state: info.status,
...(output
? {
truncated: output.truncated,
...(output.exit !== undefined ? { exit: output.exit } : {}),
...(output.timeout !== undefined ? { timeout: output.timeout } : {}),
}
: {}),
},
})
if (info.notificationID) yield* runtime.job.completeBackground(info.notificationID)
},
Effect.forkIn(scope, { startImmediately: true }),
)
const notifyWhenDone = Effect.fn("ShellTool.notifyWhenDone")(function* (
sessionID: SessionSchema.ID,
id: string,
shellID: string,
command: string,
settled: Deferred.Deferred<Output>,
) {
yield* runtime.job.wait({ id: id }).pipe(
Effect.flatMap((result) =>
Effect.gen(function* () {
const info = result.info
if (!info) return
const state =
info.status === "completed"
? "completed"
: info.status === "error"
? "error"
: info.status === "cancelled"
? "cancelled"
: undefined
if (state === undefined) return
const output = state === "completed" ? yield* Deferred.await(settled) : undefined
const text = output
? resultMessages(output).join("\n\n")
: state === "error"
? (info.error ?? "Command failed")
: "Command cancelled"
yield* runtime.session.synthetic({
sessionID,
text: `<shell id="${id}" state="${state}" command="${command}">\n${text}\n</shell>`,
description: command,
metadata: {
source: "shell",
jobID: id,
shellID,
state,
...(output
? {
truncated: output.truncated,
...(output.exit !== undefined ? { exit: output.exit } : {}),
...(output.timeout !== undefined ? { timeout: output.timeout } : {}),
}
: {}),
},
})
}),
),
Effect.forkIn(scope, { startImmediately: true }),
)
})
yield* ctx.tool
.transform((draft) =>
@@ -275,7 +286,7 @@ export const Plugin = {
const settled = yield* Deferred.make<Output>()
const run = settleShell().pipe(
Effect.tap((output) => Deferred.succeed(settled, output)),
Effect.map((output) => resultMessages(output).join("\n\n")),
Effect.map((output) => output.output),
Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)),
)
const job = yield* runtime.job.start({
@@ -283,12 +294,6 @@ export const Plugin = {
type: name,
title: info.command,
metadata: { sessionID: context.sessionID, shellID: info.id },
recovery: {
kind: "shell",
sessionID: context.sessionID,
shellID: info.id,
command: info.command,
},
run,
})
+39 -27
View File
@@ -78,6 +78,22 @@ export const Plugin = {
return text.length > 0 ? text : NO_TEXT
})
const injectCompletion = Effect.fn("SubagentTool.injectCompletion")(function* (
parentID: SessionSchema.ID,
childID: SessionSchema.ID,
agent: string,
description: string,
state: "completed" | "error" | "cancelled",
text: string,
) {
yield* runtime.session.synthetic({
sessionID: parentID,
text: `<subagent sessionID="${childID}" state="${state}" description="${description}">\n${text}\n</subagent>`,
description,
metadata: { source: "subagent", childID, agent, state },
})
})
const notifyWhenDone = Effect.fn("SubagentTool.notifyWhenDone")(function* (
parentID: SessionSchema.ID,
childID: SessionSchema.ID,
@@ -88,24 +104,23 @@ export const Plugin = {
const key = `${childID}:${startedAt}`
if (notifications.has(key)) return
notifications.add(key)
yield* Effect.gen(function* () {
const info = (yield* runtime.job.wait({ id: childID })).info
if (!info || info.status === "running") return
const text =
info.status === "completed"
? (info.output ?? NO_TEXT)
: info.status === "error"
? (info.error ?? "Subagent failed")
: "Subagent cancelled"
yield* runtime.session.synthetic({
...(info.notificationID ? { id: info.notificationID } : {}),
sessionID: parentID,
text: `<subagent sessionID="${childID}" state="${info.status}" description="${description}">\n${text}\n</subagent>`,
description,
metadata: { source: "subagent", childID, agent, state: info.status },
})
if (info.notificationID) yield* runtime.job.completeBackground(info.notificationID)
}).pipe(
yield* runtime.job.wait({ id: childID }).pipe(
Effect.flatMap((result) => {
if (result.info?.status === "completed")
return injectCompletion(parentID, childID, agent, description, "completed", result.info.output ?? NO_TEXT)
if (result.info?.status === "error")
return injectCompletion(
parentID,
childID,
agent,
description,
"error",
result.info.error ?? "Subagent failed",
)
if (result.info?.status === "cancelled")
return injectCompletion(parentID, childID, agent, description, "cancelled", "Subagent cancelled")
return Effect.void
}),
Effect.ensuring(Effect.sync(() => notifications.delete(key))),
Effect.forkIn(scope, { startImmediately: true }),
)
@@ -224,7 +239,6 @@ export const Plugin = {
existing === undefined
? ["You are a subagent spawned by another session.", input.prompt].join("\n")
: input.prompt,
...(background && existing === undefined ? { resume: false } : {}),
})
.pipe(
Effect.mapError(
@@ -232,19 +246,17 @@ export const Plugin = {
),
)
const run = Effect.gen(function* () {
yield* runtime.session.resume(child.id)
return yield* latestAssistantText(child.id)
}).pipe(Effect.onInterrupt(() => runtime.session.interrupt(child.id)))
const info = yield* runtime.job.start({
id: child.id,
type: name,
title: input.description,
metadata: {},
recovery: {
kind: "subagent",
parentSessionID: context.sessionID,
childSessionID: child.id,
agent: agent.name,
description: input.description,
},
run: runtime.session.resume(child.id).pipe(Effect.andThen(latestAssistantText(child.id))),
run,
})
if (background) {
+2 -27
View File
@@ -385,9 +385,6 @@ describe("DatabaseMigration", () => {
const content = JSON.stringify({
openai: { type: "oauth", refresh: "refresh", access: "access", expires: 123, accountId: "account" },
anthropic: { type: "api", key: "legacy-key", metadata: { region: "us" } },
google: { type: "api", key: "google-key", metadata: { region: "us" } },
"github-copilot": { type: "oauth", refresh: "refresh", access: "access", expires: 123 },
"custom-provider": { type: "api", key: "custom-key" },
"https://example.com/": { type: "wellknown", key: "TOKEN", token: "wellknown-key" },
invalid: { type: "unknown" },
})
@@ -405,7 +402,6 @@ describe("DatabaseMigration", () => {
yield* db.run(sql`DELETE FROM migration WHERE id = ${legacyCredentialsMigration.id}`)
yield* DatabaseMigration.applyOnly(db, [legacyCredentialsMigration])
yield* DatabaseMigration.applyOnly(db, [legacyCredentialsMigration])
expect(yield* db.all(sql`SELECT integration_id, label, value FROM credential ORDER BY integration_id`)).toEqual(
[
@@ -414,35 +410,14 @@ describe("DatabaseMigration", () => {
label: "Existing",
value: JSON.stringify({ type: "key", key: "current-key" }),
},
{
integration_id: "custom-provider",
label: "API key",
value: JSON.stringify({ type: "key", key: "custom-key" }),
},
{
integration_id: "github-copilot",
label: "OAuth",
value: JSON.stringify({
type: "oauth",
methodID: "device",
refresh: "refresh",
access: "access",
expires: 123,
}),
},
{
integration_id: "google",
label: "API key",
value: JSON.stringify({ type: "key", key: "google-key", metadata: { region: "us" } }),
},
{
integration_id: "https://example.com",
label: "API key",
label: "default",
value: JSON.stringify({ type: "key", key: "wellknown-key" }),
},
{
integration_id: "openai",
label: "OAuth",
label: "default",
value: JSON.stringify({
type: "oauth",
methodID: "chatgpt-browser",
+1 -174
View File
@@ -1,13 +1,11 @@
import { describe, expect } from "bun:test"
import { Job } from "@opencode-ai/core/job"
import { KV } from "@opencode-ai/core/kv"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Deferred, Effect, Exit, Fiber, Scope } from "effect"
import { SessionSchema } from "@opencode-ai/core/session/schema"
import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Job.node, KV.node])))
const it = testEffect(AppNodeBuilder.build(Job.node))
describe("Job", () => {
it.live("tracks process-local work through explicit observation", () =>
@@ -147,177 +145,6 @@ describe("Job", () => {
}),
)
it.live("retains background ownership and terminal output until notification acknowledgment", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
const latch = yield* Deferred.make<void>()
const recovery = {
kind: "shell" as const,
sessionID: SessionSchema.ID.make("ses_background_shell"),
shellID: "shell_background",
command: "echo done",
}
const job = yield* jobs.start({ type: "shell", recovery, run: Deferred.await(latch).pipe(Effect.as("done")) })
expect((yield* jobs.pendingBackground).find((item) => item.id === job.id)).toBeUndefined()
const background = yield* jobs.background(job.id)
const running = (yield* jobs.pendingBackground).find((item) => item.id === job.id)
expect(running).toMatchObject({ id: job.id, recovery, status: "running" })
expect(running?.notificationID).toStartWith("msg_")
expect(background?.notificationID).toBe(running?.notificationID)
yield* Deferred.succeed(latch, undefined)
yield* jobs.wait({ id: job.id })
const completed = (yield* jobs.pendingBackground).find((item) => item.id === job.id)
expect(completed).toMatchObject({
id: job.id,
notificationID: running?.notificationID,
recovery,
status: "completed",
output: "done",
})
if (!completed) return yield* Effect.die("background marker missing")
yield* jobs.completeBackground(completed.notificationID)
expect((yield* jobs.pendingBackground).find((item) => item.id === job.id)).toBeUndefined()
}),
)
it.live("persists backgroundAll ownership before releasing a blocked subagent", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
const parentSessionID = SessionSchema.ID.make("ses_background_parent")
const latch = yield* Deferred.make<void>()
const recovery = {
kind: "subagent" as const,
parentSessionID,
childSessionID: SessionSchema.ID.make("ses_background_child"),
agent: "explore",
description: "Explore background recovery",
}
const job = yield* jobs.start({ type: "subagent", recovery, run: Deferred.await(latch).pipe(Effect.as("done")) })
const waiting = yield* jobs
.block({ id: job.id, sessionID: parentSessionID })
.pipe(Effect.forkIn(yield* Scope.Scope, { startImmediately: true }))
yield* jobs.backgroundAll({ sessionID: parentSessionID })
expect(yield* Fiber.join(waiting)).toMatchObject({ type: "backgrounded", info: { id: job.id } })
const marker = (yield* jobs.pendingBackground).find((item) => item.id === job.id)
expect(marker).toMatchObject({ id: job.id, recovery, status: "running" })
if (!marker) return yield* Effect.die("background marker missing")
yield* jobs.cancel(job.id)
expect((yield* jobs.pendingBackground).find((item) => item.id === job.id)).toMatchObject({
notificationID: marker.notificationID,
status: "cancelled",
})
yield* jobs.completeBackground(marker.notificationID)
}),
)
it.live("retains terminal errors for recovery until notification acknowledgment", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
const latch = yield* Deferred.make<void>()
const job = yield* jobs.start({
type: "shell",
recovery: {
kind: "shell",
sessionID: SessionSchema.ID.make("ses_background_error"),
shellID: "shell_error",
command: "exit 1",
},
run: Deferred.await(latch).pipe(Effect.andThen(Effect.fail(new Error("shell failed")))),
})
yield* jobs.background(job.id)
yield* Deferred.succeed(latch, undefined)
yield* jobs.wait({ id: job.id })
const marker = (yield* jobs.pendingBackground).find((item) => item.id === job.id)
expect(marker).toMatchObject({ id: job.id, status: "error", error: "shell failed" })
if (!marker) return yield* Effect.die("background marker missing")
yield* jobs.completeBackground(marker.notificationID)
}),
)
it.live("durably backgrounds recoverable work that has already failed", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
const job = yield* jobs.start({
type: "shell",
recovery: {
kind: "shell",
sessionID: SessionSchema.ID.make("ses_immediate_error"),
shellID: "shell_immediate_error",
command: "exit 1",
},
run: Effect.fail(new Error("shell failed")),
})
expect((yield* jobs.wait({ id: job.id })).info?.status).toBe("error")
const background = yield* jobs.background(job.id)
expect(background?.notificationID).toStartWith("msg_")
expect(yield* jobs.pendingBackground).toMatchObject([
{ id: job.id, notificationID: background?.notificationID, status: "error", error: "shell failed" },
])
}),
)
it.live("recovers a background marker after its process-local registry closes", () =>
Effect.gen(function* () {
const scope = yield* Scope.make()
const previous = yield* Job.make.pipe(Scope.provide(scope))
const job = yield* previous.start({
type: "shell",
recovery: {
kind: "shell",
sessionID: SessionSchema.ID.make("ses_background_restart"),
shellID: "shell_restart",
command: "sleep 60",
},
run: Effect.never,
})
yield* previous.background(job.id)
yield* Scope.close(scope, Exit.void)
const current = yield* Job.make
const marker = (yield* current.pendingBackground).find((item) => item.id === job.id)
expect(marker).toMatchObject({ id: job.id, status: "running" })
if (!marker) return yield* Effect.die("background marker missing")
yield* current.completeBackground(marker.notificationID)
}),
)
it.live("preserves running background ownership when its work is interrupted", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
const interrupted = yield* Deferred.make<void>()
const job = yield* jobs.start({
type: "subagent",
recovery: {
kind: "subagent",
parentSessionID: SessionSchema.ID.make("ses_interrupted_parent"),
childSessionID: SessionSchema.ID.make("ses_interrupted_child"),
agent: "explore",
description: "Continue after shutdown",
},
run: Deferred.await(interrupted).pipe(Effect.andThen(Effect.interrupt)),
})
yield* jobs.background(job.id)
yield* Deferred.succeed(interrupted, undefined)
yield* jobs.wait({ id: job.id })
const marker = (yield* jobs.pendingBackground).find((item) => item.id === job.id)
expect(marker).toMatchObject({ id: job.id, status: "running" })
if (!marker) return yield* Effect.die("background marker missing")
yield* jobs.completeBackground(marker.notificationID)
}),
)
it.live("interrupts live work without promising settlement after the owning process-local scope closes", () =>
Effect.gen(function* () {
const scope = yield* Scope.make()
+1 -81
View File
@@ -35,7 +35,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { McpTool } from "@opencode-ai/core/tool/mcp"
import { Tool } from "@opencode-ai/core/tool"
import { DateTime, Deferred, Effect, Exit, Fiber, Layer, PubSub, Ref, Schedule, Schema, Sink, Stream } from "effect"
import { DateTime, Deferred, Effect, Exit, Fiber, Layer, PubSub, Schedule, Schema, Sink, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { ExitCode, makeHandle, ProcessId } from "effect/unstable/process/ChildProcessSpawner"
import { Image } from "@opencode-ai/core/image"
@@ -1193,86 +1193,6 @@ test("serializes concurrent MCP lifecycle operations", async () => {
)
})
testEffect(Layer.empty).live("isolates invalid MCP tools and keeps catalog updates alive", () =>
Effect.gen(function* () {
const tool = (server: string, name: string) =>
new MCP.Tool({
server: MCP.ServerName.make(server),
name,
codemode: false,
inputSchema: { type: "object", properties: {} },
})
const healthy = [tool("demo", "search"), tool("other", "lookup")]
const namespace = tool("x".repeat(65), "lookup")
const catalog = yield* Ref.make([tool("demo", "x".repeat(65)), ...healthy, namespace])
yield* Effect.gen(function* () {
const registry = yield* Tool.Service
const registration = yield* McpTool.Service
const bus = yield* Bus.Service
yield* registration.flush
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([
"demo_search",
"other_lookup",
"execute",
])
yield* Ref.set(catalog, [tool("demo", "y".repeat(65)), ...healthy, tool("demo", "added"), namespace])
yield* bus.publish(McpEvent.ToolsChanged, { server: "demo" })
yield* waitForTool(registry, "demo_added")
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([
"demo_added",
"demo_search",
"other_lookup",
"execute",
])
yield* Effect.forEach(["demo_search", "other_lookup"], (name) =>
executeTool(registry, {
sessionID: Session.ID.make("ses_mcp_invalid_catalog"),
...toolIdentity,
call: { type: "tool-call", id: `call_${name}`, name, input: {} },
}).pipe(Effect.tap((result) => Effect.sync(() => expect(result).toMatchObject({ status: "completed" })))),
)
yield* Ref.set(catalog, [tool("demo", "status"), ...healthy, tool("demo", "added"), tool("repaired", "lookup")])
yield* bus.publish(McpEvent.ToolsChanged, { server: "demo" })
yield* waitForTool(registry, "demo_status")
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([
"demo_added",
"demo_search",
"demo_status",
"other_lookup",
"repaired_lookup",
"execute",
])
}).pipe(
Effect.provide(
Layer.fresh(
AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node, Bus.node]), [
[
MCP.node,
Layer.mock(MCP.Service, {
tools: () => Ref.get(catalog),
callTool: (input) =>
Effect.succeed(
new MCP.ToolResult({
server: MCP.ServerName.make(input.server),
tool: input.name,
isError: false,
content: [{ type: "text", text: "healthy" }],
}),
),
}),
],
[Permission.node, Layer.mock(Permission.Service, { assert: () => Effect.void })],
[Image.node, imagePassthrough],
]),
),
),
)
}),
)
it.effect("advertises MCP output schemas to Code Mode", () =>
Effect.gen(function* () {
const registry = yield* Tool.Service
+111
View File
@@ -0,0 +1,111 @@
import path from "path"
import { expect } from "bun:test"
import { Deferred, Effect, Fiber, FileSystem, Layer } from "effect"
import { ModelsDevCache } from "@opencode-ai/core/models-dev/cache"
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { Hash } from "@opencode-ai/util/hash"
import { tempGlobalLayer } from "./fixture/global"
import { testEffect } from "./lib/effect"
const source = "https://models.opencode.ai"
const it = testEffect(
LayerNode.compile(LayerNode.group([ModelsDevCache.node, LayerNodePlatform.filesystem, Global.node]), [
[Global.node, tempGlobalLayer],
]),
)
it.live("returns undefined for a missing catalog", () =>
Effect.gen(function* () {
const cache = yield* ModelsDevCache.Service
expect(yield* cache.read(source)).toBeUndefined()
}),
)
it.live("persists raw catalog bodies larger than 2 MB with the file mtime", () =>
Effect.gen(function* () {
const cache = yield* ModelsDevCache.Service
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const body = ` {\n "payload": "${"x".repeat(2 * 1024 * 1024)}"\n}\n`
const file = path.join(global.cache, "models-dev", `${Hash.fast(source)}.json`)
const modified = new Date("2026-01-01T00:00:00Z")
yield* cache.write(source, body)
expect(yield* fs.readFileString(file)).toBe(body)
yield* fs.utimes(file, modified, modified)
expect(yield* cache.read(source)).toEqual({ body, updatedAt: modified.getTime() })
}),
)
it.live("isolates catalogs by source including the default source", () =>
Effect.gen(function* () {
const cache = yield* ModelsDevCache.Service
const custom = "https://models.example.com"
yield* cache.write(source, "default catalog")
expect(yield* cache.read(custom)).toBeUndefined()
yield* cache.write(custom, "custom catalog")
expect((yield* cache.read(source))?.body).toBe("default catalog")
expect((yield* cache.read(custom))?.body).toBe("custom catalog")
}),
)
it.live("replaces an existing catalog without leaving temporary files", () =>
Effect.gen(function* () {
const cache = yield* ModelsDevCache.Service
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
yield* cache.write(source, "old catalog")
yield* cache.write(source, "new catalog")
expect((yield* cache.read(source))?.body).toBe("new catalog")
expect(yield* fs.readDirectory(path.join(global.cache, "models-dev"))).toEqual([`${Hash.fast(source)}.json`])
}),
)
it.live("cleans up temporary files and preserves platform errors when replacement fails", () =>
Effect.gen(function* () {
const cache = yield* ModelsDevCache.Service
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const directory = path.join(global.cache, "models-dev")
const file = path.join(directory, `${Hash.fast(source)}.json`)
yield* fs.makeDirectory(file, { recursive: true })
const error = yield* cache.write(source, "new catalog").pipe(Effect.flip)
expect(error._tag).toBe("PlatformError")
expect(yield* fs.readDirectory(directory)).toEqual([`${Hash.fast(source)}.json`])
expect((yield* fs.stat(file)).type).toBe("Directory")
expect((yield* cache.read(source).pipe(Effect.flip))._tag).toBe("PlatformError")
}),
)
it.live("keeps the old catalog readable and cleans up an interrupted replacement", () =>
Effect.gen(function* () {
const cache = yield* ModelsDevCache.Service
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const staged = yield* Deferred.make<string>()
yield* cache.write(source, "old catalog")
// Pause only the commit; staging and cleanup still use the real filesystem.
const writer = yield* ModelsDevCache.Service.pipe(
Effect.flatMap((service) => service.write(source, "new catalog")),
Effect.provide(Layer.fresh(ModelsDevCache.layer)),
Effect.provideService(FileSystem.FileSystem, {
...fs,
rename: (file) => Deferred.succeed(staged, file).pipe(Effect.andThen(Effect.never)),
}),
Effect.forkScoped,
)
const temporary = yield* Deferred.await(staged)
expect(yield* fs.readFileString(temporary)).toBe("new catalog")
expect((yield* cache.read(source))?.body).toBe("old catalog")
yield* Fiber.interrupt(writer)
expect((yield* cache.read(source))?.body).toBe("old catalog")
expect(yield* fs.readDirectory(path.join(global.cache, "models-dev"))).toEqual([`${Hash.fast(source)}.json`])
}),
)
+285 -61
View File
@@ -1,18 +1,20 @@
import { describe, expect, test } from "bun:test"
import { Money } from "@opencode-ai/schema/money"
import { Effect, Fiber, Layer, Ref, Scope, Stream } from "effect"
import path from "path"
import { Deferred, Effect, Fiber, Layer, Ref, Scope, Stream } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { KV } from "@opencode-ai/core/kv"
import { Model } from "@opencode-ai/core/model"
import { bodyDigest, ModelsDev } from "@opencode-ai/core/models-dev"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { ModelsDevCache } from "@opencode-ai/core/models-dev/cache"
import { Provider } from "@opencode-ai/core/provider"
import { tmpdir } from "./fixture/tmpdir"
import { it } from "./lib/effect"
const cacheKey = "models-dev:catalog"
const source = "https://models.opencode.ai"
test("normalizes permissive interleaved values to compatibility", () => {
expect(Model.compatibility("reasoning_text")).toEqual({ reasoningField: "reasoning_text" })
@@ -166,41 +168,40 @@ const makeMockClient = (state: Ref.Ref<MockState>) =>
)
interface MockCache {
readonly values: Map<string, KV.Value>
readonly values: Map<string, ModelsDevCache.Entry>
}
const makeMockKV = (cache: MockCache) =>
Layer.mock(KV.Service, {
get: (key) => Effect.sync(() => cache.values.get(key)),
set: (key, value) => Effect.sync(() => cache.values.set(key, value)).pipe(Effect.asVoid),
remove: (key) => Effect.sync(() => cache.values.delete(key)).pipe(Effect.asVoid),
const makeMockCache = (cache: MockCache) =>
Layer.succeed(ModelsDevCache.Service, {
read: (source) => Effect.sync(() => cache.values.get(source)),
write: (source, body) =>
Effect.sync(() => cache.values.set(source, { updatedAt: Date.now(), body })).pipe(Effect.asVoid),
})
const buildLayer = (state: Ref.Ref<MockState>, cache: MockCache, options: ModelsDev.Options = { fetch: false }) =>
// Layer.fresh is required because the ModelsDev implementation is a module-level Layer constant,
// and Effect.provide uses a process-global MemoMap by default — without fresh,
// every test would reuse the cachedInvalidateWithTTL state from the first run.
const buildLayer = (
state: Ref.Ref<MockState>,
cache: MockCache,
options: ModelsDev.Options = { fetch: false },
persistence = makeMockCache(cache),
) =>
Layer.fresh(
AppNodeBuilder.build(LayerNode.group([ModelsDev.node, Bus.node]), [
[ModelsDev.node, ModelsDev.configured(options)],
[LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
[KV.node, makeMockKV(cache)],
[ModelsDevCache.node, persistence],
]),
)
// Mirrors production KV backends whose writes die as defects (e.g. Durable
// Object SQLite rejecting values over its 2 MB cap with EffectDrizzleQueryError).
const makeFailingWriteKV = (cache: MockCache) =>
Layer.mock(KV.Service, {
get: (key) => Effect.sync(() => cache.values.get(key)),
set: () => Effect.die(new Error('Failed query: insert into "kv"')),
remove: (key) => Effect.sync(() => cache.values.delete(key)).pipe(Effect.asVoid),
const makeFailingWriteCache = (cache: MockCache) =>
Layer.succeed(ModelsDevCache.Service, {
read: (source) => Effect.sync(() => cache.values.get(source)),
write: () => Effect.die(new Error("Cache write failed")),
})
const makeCache = (): MockCache => ({ values: new Map() })
const writeCacheText = (cache: MockCache, text: string, updatedAt = Date.now()) =>
cache.values.set(cacheKey, { updatedAt, digest: bodyDigest(text), body: text })
cache.values.set(source, { updatedAt, body: text })
const writeCache = (cache: MockCache, data: object, updatedAt?: number) =>
writeCacheText(cache, JSON.stringify(data), updatedAt)
@@ -218,7 +219,7 @@ const initialState: MockState = {
}
describe("ModelsDev Service", () => {
it.live("get() returns normalized snapshots from KV when a cache entry exists", () =>
it.live("get() returns normalized snapshots from the persisted cache", () =>
Effect.gen(function* () {
const cache = makeCache()
writeCache(cache, fixture)
@@ -259,7 +260,7 @@ describe("ModelsDev Service", () => {
}),
)
it.live("get() returns empty catalog when KV is empty, fetch disabled, and the bundled snapshot is disabled", () =>
it.live("get() returns empty catalog when the cache, fetch, and bundled snapshot are unavailable", () =>
Effect.gen(function* () {
const cache = makeCache()
const state = yield* Ref.make(initialState)
@@ -272,7 +273,7 @@ describe("ModelsDev Service", () => {
}),
)
it.live("get() falls back to the bundled snapshot when KV is empty and fetch is disabled", () =>
it.live("get() falls back to the bundled snapshot when the cache is empty and fetch is disabled", () =>
Effect.gen(function* () {
const cache = makeCache()
const state = yield* Ref.make(initialState)
@@ -289,7 +290,7 @@ describe("ModelsDev Service", () => {
}),
)
it.live("get() recovers from a corrupted KV entry by fetching a fresh catalog", () =>
it.live("get() recovers from a corrupted cache by fetching a fresh catalog", () =>
Effect.gen(function* () {
const cache = makeCache()
writeCacheText(cache, "{")
@@ -297,31 +298,247 @@ describe("ModelsDev Service", () => {
const context = yield* Layer.build(buildLayer(state, cache, { fetch: true, snapshot: false }))
const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context))
expect(result).toEqual(fixture2Snapshot)
expect(cache.values.get(cacheKey)).toMatchObject({ body: JSON.stringify(fixture2) })
expect(cache.values.get(source)).toMatchObject({ body: JSON.stringify(fixture2) })
const final = yield* Ref.get(state)
expect(final.calls.length).toBe(1)
}),
)
it.live("get() still populates the catalog when the KV cache write fails", () =>
it.live("get() still populates the catalog when persistence fails", () =>
Effect.gen(function* () {
const cache = makeCache()
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
const layer = Layer.fresh(
AppNodeBuilder.build(ModelsDev.node, [
[ModelsDev.node, ModelsDev.configured({ fetch: true, snapshot: false })],
[LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
[KV.node, makeFailingWriteKV(cache)],
]),
)
const layer = buildLayer(state, cache, { fetch: true, snapshot: false }, makeFailingWriteCache(cache))
const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(layer))
expect(result).toEqual(fixture2Snapshot)
expect(cache.values.has(cacheKey)).toBe(false)
expect(cache.values.has(source)).toBe(false)
const final = yield* Ref.get(state)
expect(final.calls.length).toBe(1)
}),
)
for (const seeded of [false, true]) {
it.live(`refresh adopts and publishes the fetched catalog when persistence fails (seeded=${seeded})`, () =>
Effect.gen(function* () {
const cache = makeCache()
if (seeded) writeCache(cache, fixture)
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
yield* Effect.gen(function* () {
const models = yield* ModelsDev.Service
const bus = yield* Bus.Service
expect(yield* models.get()).not.toEqual(fixture2Snapshot)
const event = yield* bus.subscribe(ModelsDev.Event.Refreshed).pipe(
Stream.take(1),
Stream.runDrain,
Effect.andThen(() => models.get()),
Effect.forkScoped({ startImmediately: true }),
)
yield* models.refresh(true)
expect(yield* Fiber.join(event)).toEqual(fixture2Snapshot)
expect(yield* models.get()).toEqual(fixture2Snapshot)
yield* models.refresh()
expect((yield* Ref.get(state)).calls).toHaveLength(1)
}).pipe(Effect.provide(buildLayer(state, cache, { fetch: false }, makeFailingWriteCache(cache))))
expect(cache.values.get(source)?.body).toBe(seeded ? JSON.stringify(fixture) : undefined)
}),
)
}
it.live("a failed cache read falls back to the bundled snapshot without blocking refresh", () =>
Effect.gen(function* () {
const cache = makeCache()
const state = yield* Ref.make(initialState)
yield* Effect.gen(function* () {
const models = yield* ModelsDev.Service
expect((yield* models.get()).length).toBeGreaterThan(0)
yield* models.refresh(true)
expect(yield* models.get()).toEqual(fixtureSnapshot)
}).pipe(
Effect.provide(
buildLayer(
state,
cache,
{ fetch: false },
Layer.succeed(ModelsDevCache.Service, {
read: () => Effect.die(new Error("Cache read failed")),
write: () => Effect.void,
}),
),
),
)
expect((yield* Ref.get(state)).calls).toHaveLength(1)
}),
)
it.live("refresh publishes the live catalog while its cache write is still pending", () =>
Effect.gen(function* () {
const cache = makeCache()
writeCache(cache, fixture)
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
const writing = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
yield* Effect.gen(function* () {
const models = yield* ModelsDev.Service
const bus = yield* Bus.Service
expect(yield* models.get()).toEqual(fixtureSnapshot)
const event = yield* bus
.subscribe(ModelsDev.Event.Refreshed)
.pipe(Stream.take(1), Stream.runDrain, Effect.forkScoped({ startImmediately: true }))
const refresh = yield* models.refresh(true).pipe(Effect.forkScoped)
yield* Deferred.await(writing)
yield* Fiber.join(event).pipe(Effect.timeout("1 second"))
expect(yield* models.get()).toEqual(fixture2Snapshot)
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(refresh)
}).pipe(
Effect.provide(
buildLayer(
state,
cache,
{ fetch: false },
Layer.succeed(ModelsDevCache.Service, {
read: () => Effect.succeed(cache.values.get(source)),
write: () => Deferred.succeed(writing, undefined).pipe(Effect.andThen(Deferred.await(release))),
}),
),
),
)
}),
)
it.live("get() can use the bundled snapshot while the initial background fetch is pending", () =>
Effect.gen(function* () {
const reading = yield* Deferred.make<void>()
const releaseRead = yield* Deferred.make<void>()
const fetching = yield* Deferred.make<void>()
const releaseFetch = yield* Deferred.make<void>()
const layer = Layer.fresh(
AppNodeBuilder.build(ModelsDev.node, [
[ModelsDev.node, ModelsDev.configured({ fetch: true })],
[
ModelsDevCache.node,
Layer.succeed(ModelsDevCache.Service, {
read: () =>
Deferred.succeed(reading, undefined).pipe(
Effect.andThen(Deferred.await(releaseRead)),
Effect.as(undefined),
),
write: () => Effect.void,
}),
],
[
LayerNodePlatform.httpClient,
Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Deferred.succeed(fetching, undefined).pipe(
Effect.andThen(Deferred.await(releaseFetch)),
Effect.as(HttpClientResponse.fromWeb(request, new Response(JSON.stringify(fixture)))),
),
),
),
],
]),
)
yield* Effect.gen(function* () {
const models = yield* ModelsDev.Service
yield* Deferred.await(reading)
const get = yield* models.get().pipe(Effect.forkScoped({ startImmediately: true }))
yield* Deferred.succeed(releaseRead, undefined)
yield* Deferred.await(fetching)
expect((yield* Fiber.join(get).pipe(Effect.timeout("1 second"))).length).toBeGreaterThan(0)
yield* Deferred.succeed(releaseFetch, undefined)
}).pipe(Effect.provide(layer))
}),
)
it.live("cancelling a reader during initialization does not poison later reads or refreshes", () =>
Effect.gen(function* () {
const reading = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
yield* Effect.gen(function* () {
const models = yield* ModelsDev.Service
const first = yield* models.get().pipe(Effect.forkScoped({ startImmediately: true }))
yield* Deferred.await(reading)
yield* Fiber.interrupt(first)
yield* Deferred.succeed(release, undefined)
expect(yield* models.get()).toEqual(fixtureSnapshot)
yield* models.refresh(true)
expect(yield* models.get()).toEqual(fixture2Snapshot)
}).pipe(
Effect.provide(
buildLayer(
state,
makeCache(),
{ fetch: false },
Layer.succeed(ModelsDevCache.Service, {
read: () =>
Deferred.succeed(reading, undefined).pipe(
Effect.andThen(Deferred.await(release)),
Effect.as({ body: JSON.stringify(fixture), updatedAt: Date.now() }),
),
write: () => Effect.void,
}),
),
),
)
}),
)
it.live("custom source URLs do not read or overwrite the default source cache", () =>
Effect.gen(function* () {
const cache = makeCache()
writeCache(cache, fixture)
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
const result = yield* ModelsDev.Service.use((models) => models.get()).pipe(
Effect.provide(buildLayer(state, cache, { url: "https://catalog.example", fetch: true, snapshot: false })),
)
expect(result).toEqual(fixture2Snapshot)
expect(cache.values.get(source)?.body).toBe(JSON.stringify(fixture))
expect(cache.values.get("https://catalog.example")?.body).toBe(JSON.stringify(fixture2))
expect((yield* Ref.get(state)).calls[0]?.url).toBe("https://catalog.example/api.json")
}),
)
it.live("an explicit file remains authoritative and refresh rereads it without HTTP or cache access", () =>
Effect.gen(function* () {
const dir = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
)
const file = path.join(dir.path, "catalog.json")
yield* Effect.promise(() => Bun.write(file, JSON.stringify(fixture)))
const state = yield* Ref.make(initialState)
const cacheCalls: string[] = []
yield* Effect.gen(function* () {
const models = yield* ModelsDev.Service
expect(yield* models.get()).toEqual(fixtureSnapshot)
yield* Effect.promise(() => Bun.write(file, JSON.stringify(fixture2)))
yield* models.refresh(true)
expect(yield* models.get()).toEqual(fixture2Snapshot)
}).pipe(
Effect.provide(
buildLayer(
state,
makeCache(),
{ file, fetch: false },
Layer.succeed(ModelsDevCache.Service, {
read: () =>
Effect.sync(() => {
cacheCalls.push("read")
return undefined
}),
write: () => Effect.sync(() => void cacheCalls.push("write")),
}),
),
),
)
expect((yield* Ref.get(state)).calls).toEqual([])
expect(cacheCalls).toEqual([])
}),
)
it.live("uses the default models URL when the configured URL is empty", () =>
Effect.gen(function* () {
const cache = makeCache()
@@ -348,7 +565,7 @@ describe("ModelsDev Service", () => {
}),
)
it.live("get() caches across calls (later KV writes are ignored until invalidate)", () =>
it.live("get() retains the live catalog instead of rereading persistence", () =>
Effect.gen(function* () {
const cache = makeCache()
writeCache(cache, fixture)
@@ -387,7 +604,7 @@ describe("ModelsDev Service", () => {
)
expect(result.before).toEqual(fixtureSnapshot)
expect(result.after).toEqual(fixture2Snapshot)
expect(cache.values.get(cacheKey)).toMatchObject({ body: JSON.stringify(fixture2) })
expect(cache.values.get(source)).toMatchObject({ body: JSON.stringify(fixture2) })
const final = yield* Ref.get(state)
expect(final.calls.length).toBe(1)
expect(final.calls[0].url).toContain("/api.json")
@@ -395,7 +612,7 @@ describe("ModelsDev Service", () => {
}),
)
it.live("refresh(false) skips fetch when the KV entry is fresh", () =>
it.live("refresh(false) skips fetch when the persisted catalog is fresh", () =>
Effect.gen(function* () {
const cache = makeCache()
writeCache(cache, fixture, Date.now() - 1000)
@@ -410,7 +627,7 @@ describe("ModelsDev Service", () => {
}),
)
it.live("refresh(false) fetches when the KV entry is stale", () =>
it.live("refresh(false) fetches when the persisted catalog is stale", () =>
Effect.gen(function* () {
const cache = makeCache()
writeCache(cache, fixture, Date.now() - 10 * 60 * 1000)
@@ -447,7 +664,7 @@ describe("ModelsDev Service", () => {
Effect.gen(function* () {
const cache = makeCache()
writeCache(cache, fixture, Date.now() - 10 * 60 * 1000)
const seeded = structuredClone(cache.values.get(cacheKey))
const seeded = structuredClone(cache.values.get(source))
// The server serves a byte-identical body, so the refresh still hits
// the network but must not rewrite the cache or publish Refreshed.
const state = yield* Ref.make(initialState)
@@ -474,38 +691,24 @@ describe("ModelsDev Service", () => {
)
const final = yield* Ref.get(state)
expect(final.calls.length).toBe(1)
expect(cache.values.get(cacheKey)).toEqual(seeded)
expect(cache.values.get(source)).toEqual(seeded)
}),
)
it.live("refresh(false) republishes once for legacy cache entries without a digest", () =>
it.live("concurrent refreshes share the freshness check even when the body is unchanged", () =>
Effect.gen(function* () {
const cache = makeCache()
cache.values.set(cacheKey, { updatedAt: Date.now() - 10 * 60 * 1000, body: JSON.stringify(fixture) })
writeCache(cache, fixture, Date.now() - 10 * 60 * 1000)
const state = yield* Ref.make(initialState)
yield* provided(
state,
cache,
Effect.gen(function* () {
const svc = yield* ModelsDev.Service
const bus = yield* Bus.Service
const refreshed = yield* bus.subscribe(ModelsDev.Event.Refreshed).pipe(
Stream.take(1),
Stream.runCollect,
Effect.forkScoped,
Effect.flatMap((fiber) =>
Effect.gen(function* () {
yield* Effect.yieldNow
yield* svc.refresh(false)
return yield* Fiber.join(fiber)
}),
),
)
expect(refreshed.length).toBe(1)
yield* Effect.all([svc.refresh(), svc.refresh(), svc.refresh()], { concurrency: "unbounded" })
}),
)
// The rewritten entry now carries a digest, so later identical bodies stay quiet.
expect(cache.values.get(cacheKey)).toMatchObject({ digest: bodyDigest(JSON.stringify(fixture)) })
expect((yield* Ref.get(state)).calls).toHaveLength(1)
}),
)
@@ -529,4 +732,25 @@ describe("ModelsDev Service", () => {
expect(final.calls.length).toBeGreaterThanOrEqual(1)
}),
)
for (const body of ["{", JSON.stringify({ broken: {} })]) {
it.live(`refresh preserves the live and persisted catalog when the response is invalid: ${body}`, () =>
Effect.gen(function* () {
const cache = makeCache()
writeCache(cache, fixture)
const state = yield* Ref.make({ ...initialState, body })
yield* provided(
state,
cache,
Effect.gen(function* () {
const models = yield* ModelsDev.Service
const before = yield* models.get()
yield* models.refresh(true)
expect(yield* models.get()).toBe(before)
}),
)
expect(cache.values.get(source)?.body).toBe(JSON.stringify(fixture))
}),
)
}
})
+1 -2
View File
@@ -223,7 +223,6 @@ describe("Npm.add", () => {
).toBeTruthy()
})
// Several real Git installs and refreshes exceed Bun's default timeout on Windows.
test("refreshes mutable Git packages once per service lifetime and preserves pinned or cached installs", async () => {
await using tmp = await tmpdir()
const fixture = await createGitFixture(tmp.path)
@@ -263,7 +262,7 @@ describe("Npm.add", () => {
return yield* npm.add(mutable, { refresh: true })
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
expect(await Bun.file(path.join(offline.directory, "index.js")).text()).toContain('root: "second"')
}, 30_000)
})
})
describe("Npm.resolve", () => {
-41
View File
@@ -282,47 +282,6 @@ describe("Plugin", () => {
}),
)
it.effect("keeps plugins active when a tool registration is invalid", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const tools = yield* Tool.Service
const agents = yield* Agent.Service
yield* plugins.activate([
{
id: "partial-tools",
version: "1",
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.tool.transform((draft) => {
const tool = {
name: "healthy",
description: "Healthy tool",
input: Schema.Struct({}),
execute: () => Effect.succeed({ content: "ok" }),
options: { codemode: false },
}
draft.add({ ...tool, name: "invalid", options: { namespace: "invalid..namespace" } })
draft.add(tool)
})
yield* ctx.agent.transform((draft) =>
draft.update("configured", (agent) => {
agent.description = "setup continued"
}),
)
}),
},
])
expect(yield* plugins.list()).toEqual([
{ id: Plugin.ID.make("partial-tools"), source: { type: "builtin" }, status: "active", tui: false },
])
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("setup continued")
expect((yield* tools.snapshot()).definitions.map((tool) => tool.name)).toEqual(["healthy", "execute"])
yield* plugins.activate([])
expect((yield* tools.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
}),
)
it.effect("restores the previous plugin when its replacement fails", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
-114
View File
@@ -1,114 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect, Layer, Schema } from "effect"
import { mkdtemp } from "fs/promises"
import { tmpdir } from "os"
import path from "path"
import { LanguageModel } from "@opencode-ai/ai"
import * as OpenAIChat from "@opencode-ai/ai/protocols/openai-chat"
import { TestLLM } from "@opencode-ai/ai/testing"
import { Agent } from "@opencode-ai/core/agent"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionEngine } from "@opencode-ai/core/session-engine"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionStore } from "@opencode-ai/core/session/store"
import { testEffect } from "./lib/effect"
const testLLM = TestLLM.layer()
// The environment's engine graph compiles the scripted client from the same
// Layer references the application root uses, so the shared MemoMap yields
// one TestLLM instance for both pushes and drains.
const scriptedClient = TestLLM.clientLayer.pipe(Layer.provide(testLLM))
const shared: LayerNode.Replacements = [
[Bus.node, Bus.configured({ persist: true })],
[LayerNodePlatform.llmClient, scriptedClient],
]
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
Database.node,
Bus.node,
SessionProjector.node,
SessionStore.node,
SessionExecution.node,
Session.node,
SessionEngine.node,
]),
[...shared, [SessionEngine.node, SessionEngine.configured(shared)]],
).pipe(Layer.provideMerge(testLLM)),
)
const model = SessionRunnerModel.resolved(
LanguageModel.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route }),
{
capabilities: { tools: true, input: ["text"], output: ["text"] },
cost: [],
limit: { context: 200_000, output: 32_000 },
},
)
const executions: string[] = []
const echo = {
name: "echo",
description: "Echo text",
input: Schema.Struct({ text: Schema.String }),
output: Schema.Struct({ text: Schema.String }),
options: { codemode: false as const },
execute: ({ text }: { text: string }) =>
Effect.sync(() => {
executions.push(text)
return { output: { text }, content: text }
}),
}
describe("SessionEngine", () => {
it.effect("drains a durable session against a values-constructed environment", () =>
Effect.gen(function* () {
executions.length = 0
const directory = AbsolutePath.make(
yield* Effect.promise(() => mkdtemp(path.join(tmpdir(), "session-engine-"))),
)
const envs = yield* SessionEngine.Service
const env = yield* envs.make({
directory,
model,
agents: (draft) => {
draft.update(Agent.defaultID, () => {})
draft.default(Agent.defaultID)
},
tools: (draft) => draft.add(echo),
})
const session = yield* env.session()
yield* TestLLM.push(TestLLM.tool("call_1", "echo", { text: "hello" }), TestLLM.text("done", "out_1"))
yield* session.prompt({ text: "use echo", resume: false })
const sessions = yield* Session.Service
yield* sessions.resume(session.id)
// The values tool executed inside the real drain.
expect(executions).toEqual(["hello"])
// The drain produced durable assistant history containing the scripted reply.
const messages = yield* sessions.messages({ sessionID: session.id })
const assistant = messages.filter((message) => message.type === "assistant")
expect(assistant.length).toBeGreaterThan(0)
const text = assistant
.flatMap((message) => message.content)
.flatMap((part) => (part.type === "text" ? [part.text] : []))
.join("\n")
expect(text).toContain("done")
// Reconnect: the same call with the same ID adopts the existing Session.
const reconnected = yield* env.session({ id: session.id, title: "ignored on adoption" })
expect(reconnected.id).toBe(session.id)
expect((yield* sessions.messages({ sessionID: reconnected.id })).length).toBe(messages.length)
}),
)
})
+5 -621
View File
@@ -4,15 +4,12 @@ import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { Job } from "@opencode-ai/core/job"
import { KV } from "@opencode-ai/core/kv"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import type { LocationServices } from "@opencode-ai/core/location-services"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionEngineBindings } from "@opencode-ai/core/session/engine-bindings"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
import { UserInterruptedError } from "@opencode-ai/core/session/error"
@@ -26,9 +23,7 @@ import { Context, Deferred, Effect, Exit, Fiber, Layer, LayerMap, Scope } from "
import { eq } from "drizzle-orm"
import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionStore.node, Job.node, KV.node, Session.node])),
)
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionStore.node])))
describe("SessionExecution lifecycle", () => {
test("classifies success and typed failure terminals", () => {
@@ -65,13 +60,14 @@ describe("SessionExecution lifecycle", () => {
const idle = Session.ID.make("ses_recover_idle")
yield* seedSessions(database, [parent], { time_suspended: Date.now() })
yield* seedSessions(database, [idle])
// Children recover through background Job records, never through the root claim sweep.
// An orphaned child is never resumed: the resumed parent re-runs its
// tool call and spawns a fresh child instead.
yield* seedSessions(database, [child], { time_suspended: Date.now(), parent_id: parent })
expect(yield* store.listSuspended()).toEqual([parent])
// The sweep clears orphaned child claims outright; parents keep theirs.
yield* store.releaseChildClaims([])
yield* store.releaseChildClaims
expect(yield* claims(database)).toEqual({ [parent]: true, [child]: false, [idle]: false })
}),
)
@@ -151,66 +147,6 @@ describe("SessionExecution lifecycle", () => {
}),
)
it.effect("does not resume a user-cancelled background child whose notification was not admitted", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const parent = Session.ID.make("ses_cancelled_background_parent")
const child = Session.ID.make("ses_cancelled_background_child")
yield* seedSessions(database, [parent])
yield* seedSessions(database, [child], { parent_id: parent })
const running = yield* Deferred.make<void>()
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const jobs = yield* Job.make.pipe(Scope.provide(scope))
const context = yield* buildExecution(
scope,
() => Deferred.succeed(running, undefined).pipe(Effect.andThen(Effect.never)),
undefined,
jobs,
)
const execution = Context.get(context, SessionExecution.Service)
yield* jobs.start({
id: child,
type: "subagent",
recovery: {
kind: "subagent",
parentSessionID: parent,
childSessionID: child,
agent: "general",
description: "Cancelled inspection",
},
run: execution.resume(child).pipe(Effect.as("unused")),
})
yield* jobs.background(child)
yield* Deferred.await(running)
expect(yield* execution.interrupt(child)).toBeTrue()
yield* execution.awaitIdle(child)
expect((yield* jobs.wait({ id: child })).info?.status).toBe("cancelled")
expect(yield* jobs.pendingBackground).toMatchObject([{ id: child, status: "cancelled" }])
expect((yield* claims(database))[child]).toBe(false)
yield* Scope.close(scope, Exit.void)
const restartedScope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(restartedScope, Exit.void))
const restartedJobs = yield* Job.make.pipe(Scope.provide(restartedScope))
const drained: Session.ID[] = []
const restarted = yield* buildExecution(
restartedScope,
({ sessionID }) => Effect.sync(() => void drained.push(sessionID)),
undefined,
restartedJobs,
)
yield* Context.get(restarted, SessionRestart.Service).resumeSuspendedSessions
yield* Context.get(restarted, SessionExecution.Service).awaitIdle(parent)
expect(drained).toEqual([parent])
expect(yield* SessionInbox.list(database.db, parent)).toMatchObject([
{ payload: { text: expect.stringContaining("Subagent cancelled"), metadata: { state: "cancelled" } } },
])
expect(yield* restartedJobs.pendingBackground).toEqual([])
}),
)
it.effect("starts every claimed execution without waiting for earlier drains to finish", () =>
Effect.gen(function* () {
const database = yield* Database.Service
@@ -368,518 +304,6 @@ describe("SessionExecution lifecycle", () => {
)
})
describe("SessionRestart background recovery", () => {
it.effect("admits orphaned shell notices without waking and delivers them once on the next run", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const store = yield* SessionStore.Service
const jobs = yield* Job.Service
const bus = yield* Bus.Service
const parent = Session.ID.make("ses_background_recovery_parent")
const child = Session.ID.make("ses_background_recovery_child")
yield* seedSessions(database, [parent])
yield* seedSessions(database, [child], { parent_id: parent, time_suspended: Date.now() })
yield* seedBackground(jobs, parent, [
{ id: "call-background-shell", shellID: "sh_background_orphan", command: "sleep 60" },
])
yield* seedBackground(jobs, child, [{ id: "call-child-shell", shellID: "sh_child_orphan", command: "sleep 30" }])
expect(yield* store.listSuspended()).toEqual([])
expect(yield* jobs.pendingBackground).toHaveLength(2)
const drained: Session.ID[] = []
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const restarted = yield* Job.make.pipe(Effect.provideService(Scope.Scope, scope))
const context = yield* buildExecution(
scope,
({ sessionID }) =>
Effect.sync(() => void drained.push(sessionID)).pipe(
Effect.andThen(SessionInbox.promote(database.db, bus, sessionID, "steer")),
Effect.asVoid,
),
undefined,
restarted,
)
const restart = Context.get(context, SessionRestart.Service)
yield* restart.resumeSuspendedSessions
expect((yield* store.context(parent)).filter((message) => message.type === "synthetic")).toEqual([])
expect(yield* SessionInbox.list(database.db, parent)).toMatchObject([
{
type: "synthetic",
payload: {
description: "sleep 60",
text: expect.stringContaining("server restarted"),
metadata: {
source: "shell",
jobID: "call-background-shell",
shellID: "sh_background_orphan",
state: "cancelled",
},
},
},
])
expect(yield* SessionInbox.list(database.db, child)).toMatchObject([
{
type: "synthetic",
payload: {
metadata: {
source: "shell",
jobID: "call-child-shell",
shellID: "sh_child_orphan",
state: "cancelled",
},
},
},
])
expect(drained).toEqual([])
expect(yield* claims(database)).toEqual({ [parent]: false, [child]: false })
expect(yield* restarted.pendingBackground).toEqual([])
yield* restart.resumeSuspendedSessions
expect(yield* SessionInbox.list(database.db, parent)).toHaveLength(1)
expect(drained).toEqual([])
const execution = Context.get(context, SessionExecution.Service)
yield* execution.resume(parent)
expect(yield* SessionInbox.list(database.db, parent)).toEqual([])
expect((yield* store.context(parent)).filter((message) => message.type === "synthetic")).toHaveLength(1)
yield* execution.resume(parent)
expect((yield* store.context(parent)).filter((message) => message.type === "synthetic")).toHaveLength(1)
}),
)
it.effect("preserves locally running background work", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const store = yield* SessionStore.Service
const jobs = yield* Job.Service
const parent = Session.ID.make("ses_background_existing_parent")
yield* seedSessions(database, [parent])
yield* seedBackground(jobs, parent, [{ id: "call-running-shell", shellID: "sh_running", command: "sleep 60" }])
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const context = yield* buildExecution(scope, () => Effect.void)
const restart = Context.get(context, SessionRestart.Service)
yield* restart.resumeSuspendedSessions
expect((yield* store.context(parent)).filter((message) => message.type === "synthetic")).toEqual([])
expect(yield* jobs.get("call-running-shell")).toMatchObject({ status: "running" })
expect(yield* jobs.pendingBackground).toHaveLength(1)
}),
)
it.effect("preserves a silent shell failure persisted before its completion notification", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const jobs = yield* Job.Service
const sessionID = Session.ID.make("ses_background_completed_shell")
yield* seedSessions(database, [sessionID])
const complete = yield* Deferred.make<string>()
yield* jobs.start({
id: "call-completed-shell",
type: "shell",
recovery: {
kind: "shell",
sessionID,
shellID: "sh_completed",
command: "exit 7",
},
run: Deferred.await(complete),
})
yield* jobs.background("call-completed-shell")
yield* Deferred.succeed(complete, "(no output)\n\nCommand exited with code 7.")
yield* jobs.wait({ id: "call-completed-shell" })
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const restarted = yield* Job.make.pipe(Effect.provideService(Scope.Scope, scope))
const context = yield* buildExecution(scope, () => Effect.void, undefined, restarted)
yield* Context.get(context, SessionRestart.Service).resumeSuspendedSessions
expect(yield* SessionInbox.list(database.db, sessionID)).toMatchObject([
{
type: "synthetic",
payload: {
text: expect.stringContaining("(no output)\n\nCommand exited with code 7."),
metadata: { source: "shell", shellID: "sh_completed", state: "completed" },
},
},
])
expect(yield* restarted.pendingBackground).toEqual([])
}),
)
for (const delivered of [false, true]) {
it.effect(`does not duplicate a shell notification already ${delivered ? "delivered" : "admitted"}`, () =>
Effect.gen(function* () {
const database = yield* Database.Service
const bus = yield* Bus.Service
const jobs = yield* Job.Service
const sessions = yield* Session.Service
const sessionID = Session.ID.make("ses_shell_notification_retry")
yield* seedSessions(database, [sessionID])
yield* seedBackground(jobs, sessionID, [
{ id: "call-shell-notified", shellID: "sh_notified", command: "echo done" },
])
const background = (yield* jobs.pendingBackground)[0]
if (!background) return yield* Effect.die("background record missing")
yield* sessions.synthetic({
id: background.notificationID,
sessionID,
text: "Command already completed",
metadata: { source: "shell", shellID: "sh_notified", state: "completed" },
resume: false,
})
if (delivered) yield* SessionInbox.promote(database.db, bus, sessionID, "steer")
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const restarted = yield* Job.make.pipe(Effect.provideService(Scope.Scope, scope))
const context = yield* buildExecution(scope, () => Effect.void, undefined, restarted)
yield* Context.get(context, SessionRestart.Service).resumeSuspendedSessions
expect(yield* restarted.pendingBackground).toEqual([])
expect(yield* SessionInbox.list(database.db, sessionID)).toHaveLength(delivered ? 0 : 1)
yield* SessionInbox.promote(database.db, bus, sessionID, "steer")
expect(yield* sessions.messages({ sessionID })).toMatchObject([
{
id: background.notificationID,
type: "synthetic",
text: "Command already completed",
metadata: { state: "completed" },
},
])
expect(yield* sessions.messages({ sessionID })).toHaveLength(1)
}),
)
}
it.effect("acknowledges recovery markers when their owning session is deleted", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const jobs = yield* Job.Service
const sessionID = Session.ID.make("ses_background_deleted")
yield* seedSessions(database, [sessionID])
yield* seedBackground(jobs, sessionID, [{ id: "call-deleted-shell", shellID: "sh_deleted", command: "sleep 60" }])
yield* database.db.delete(SessionTable).where(eq(SessionTable.id, sessionID)).run().pipe(Effect.orDie)
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const restarted = yield* Job.make.pipe(Effect.provideService(Scope.Scope, scope))
const context = yield* buildExecution(scope, () => Effect.void, undefined, restarted)
yield* Context.get(context, SessionRestart.Service).resumeSuspendedSessions
expect(yield* restarted.pendingBackground).toEqual([])
}),
)
it.effect("delivers cancellation at the resumed parent's next step", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const jobs = yield* Job.Service
const store = yield* SessionStore.Service
const bus = yield* Bus.Service
const parent = Session.ID.make("ses_background_claimed_parent")
yield* seedSessions(database, [parent], { time_suspended: Date.now() })
yield* seedBackground(jobs, parent, [{ id: "call-claimed-shell", shellID: "sh_claimed", command: "sleep 60" }])
const observed = yield* Deferred.make<string[]>()
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const restarted = yield* Job.make.pipe(Effect.provideService(Scope.Scope, scope))
const context = yield* buildExecution(
scope,
({ sessionID }) =>
SessionInbox.promote(database.db, bus, sessionID, "steer").pipe(
Effect.andThen(store.context(sessionID)),
Effect.orDie,
Effect.flatMap((messages) =>
Deferred.succeed(
observed,
messages.filter((message) => message.type === "synthetic").map((message) => message.text),
),
),
Effect.asVoid,
),
undefined,
restarted,
)
const execution = Context.get(context, SessionExecution.Service)
yield* Context.get(context, SessionRestart.Service).resumeSuspendedSessions
expect(yield* Deferred.await(observed)).toEqual([
"The server restarted while you were working. Continue from where you left off without repeating completed work.",
expect.stringContaining("Command cancelled because the server restarted"),
])
yield* execution.awaitIdle(parent)
expect(yield* SessionInbox.list(database.db, parent)).toEqual([])
expect((yield* claims(database))[parent]).toBe(false)
}),
)
it.effect("resumes a background subagent and notifies its parent exactly once", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const jobs = yield* Job.Service
const parent = Session.ID.make("ses_subagent_recovery_parent")
const child = Session.ID.make("ses_subagent_recovery_child")
const unrelated = Session.ID.make("ses_subagent_unrelated_child")
yield* seedSessions(database, [parent], { time_suspended: Date.now(), resume_attempts: 1 })
yield* seedSessions(database, [child, unrelated], { parent_id: parent, time_suspended: Date.now() })
yield* jobs.start({
id: child,
type: "subagent",
recovery: {
kind: "subagent",
parentSessionID: parent,
childSessionID: child,
agent: "explore",
description: "Inspect recovery",
},
run: Effect.never,
})
yield* jobs.background(child)
const resumed = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const parentResumed = yield* Deferred.make<void>()
const parentWoken = yield* Deferred.make<void>()
const drained: Session.ID[] = []
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const restarted = yield* Job.make.pipe(Effect.provideService(Scope.Scope, scope))
const context = yield* buildExecution(
scope,
({ sessionID }) =>
Effect.gen(function* () {
drained.push(sessionID)
if (sessionID === child) {
yield* Deferred.succeed(resumed, undefined)
yield* Deferred.await(release)
return
}
yield* Deferred.succeed(
drained.filter((id) => id === parent).length === 1 ? parentResumed : parentWoken,
undefined,
)
}),
undefined,
restarted,
)
const restart = Context.get(context, SessionRestart.Service)
const execution = Context.get(context, SessionExecution.Service)
yield* restart.resumeSuspendedSessions
yield* Deferred.await(resumed)
yield* Deferred.await(parentResumed)
yield* execution.awaitIdle(parent)
yield* restart.resumeSuspendedSessions
expect(drained.toSorted()).toEqual([child, parent].toSorted())
expect(yield* claims(database)).toEqual({ [parent]: false, [child]: true, [unrelated]: false })
expect(yield* attempts(database, child)).toBe(1)
expect(yield* restarted.get(child)).toMatchObject({ status: "running" })
yield* Deferred.succeed(release, undefined)
yield* Deferred.await(parentWoken)
expect(drained.filter((id) => id === child)).toHaveLength(1)
expect(drained.filter((id) => id === parent)).toHaveLength(2)
expect(yield* SessionInbox.list(database.db, parent)).toMatchObject([
{
payload: {
description: "Inspect recovery",
metadata: { source: "subagent", childID: child, agent: "explore", state: "completed" },
},
},
])
expect(yield* restarted.pendingBackground).toEqual([])
yield* restart.resumeSuspendedSessions
expect(yield* SessionInbox.list(database.db, parent)).toHaveLength(1)
}),
)
it.effect("delivers a subagent result persisted before restart without rerunning the child", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const jobs = yield* Job.Service
const parent = Session.ID.make("ses_subagent_completed_parent")
const child = Session.ID.make("ses_subagent_completed_child")
yield* seedSessions(database, [parent])
yield* seedSessions(database, [child], { parent_id: parent })
const complete = yield* Deferred.make<string>()
yield* jobs.start({
id: child,
type: "subagent",
recovery: {
kind: "subagent",
parentSessionID: parent,
childSessionID: child,
agent: "explore",
description: "Completed inspection",
},
run: Deferred.await(complete),
})
yield* jobs.background(child)
yield* Deferred.succeed(complete, "Recovered result")
yield* jobs.wait({ id: child })
const parentWoken = yield* Deferred.make<void>()
const drained: Session.ID[] = []
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const restarted = yield* Job.make.pipe(Effect.provideService(Scope.Scope, scope))
const context = yield* buildExecution(
scope,
({ sessionID }) =>
Effect.sync(() => void drained.push(sessionID)).pipe(
Effect.andThen(Deferred.succeed(parentWoken, undefined)),
),
undefined,
restarted,
)
yield* Context.get(context, SessionRestart.Service).resumeSuspendedSessions
yield* Deferred.await(parentWoken)
expect(drained).toEqual([parent])
expect(yield* SessionInbox.list(database.db, parent)).toMatchObject([
{ payload: { text: expect.stringContaining("Recovered result"), metadata: { state: "completed" } } },
])
expect(yield* restarted.pendingBackground).toEqual([])
}),
)
for (const resumeAttempts of [1, 2]) {
it.effect(`honors a suspended parent's restart budget after ${resumeAttempts} attempts before notifying it`, () =>
Effect.gen(function* () {
const database = yield* Database.Service
const bus = yield* Bus.Service
const jobs = yield* Job.Service
const parent = Session.ID.make("ses_subagent_budget_parent")
const children = [
Session.ID.make("ses_subagent_budget_child_1"),
Session.ID.make("ses_subagent_budget_child_2"),
]
yield* seedSessions(database, [parent], { time_suspended: Date.now(), resume_attempts: resumeAttempts })
yield* seedSessions(database, children, { parent_id: parent })
const complete = yield* Deferred.make<string>()
for (const child of children) {
yield* jobs.start({
id: child,
type: "subagent",
recovery: {
kind: "subagent",
parentSessionID: parent,
childSessionID: child,
agent: "explore",
description: "Completed inspection",
},
run: Deferred.await(complete),
})
yield* jobs.background(child)
}
yield* Deferred.succeed(complete, "Recovered result")
yield* Effect.forEach(children, (id) => jobs.wait({ id }), { discard: true })
const draining = yield* Deferred.make<number | undefined>()
const release = yield* Deferred.make<void>()
const drained: Session.ID[] = []
const continued: Session.ID[] = []
yield* bus.project(SessionEvent.Synthetic, (event) =>
Effect.sync(() => void continued.push(event.data.sessionID)),
)
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const restarted = yield* Job.make.pipe(Scope.provide(scope))
const context = yield* buildExecution(
scope,
({ sessionID }) =>
Effect.gen(function* () {
drained.push(sessionID)
yield* Deferred.succeed(draining, yield* attempts(database, sessionID))
yield* Deferred.await(release)
}),
{ maxAttempts: 2 },
restarted,
)
const restart = Context.get(context, SessionRestart.Service)
const execution = Context.get(context, SessionExecution.Service)
yield* restart.resumeSuspendedSessions
if (resumeAttempts < 2) {
expect(yield* Deferred.await(draining)).toBe(2)
expect(drained).toEqual([parent])
expect(continued).toEqual([parent])
yield* Deferred.succeed(release, undefined)
yield* execution.awaitIdle(parent)
}
if (resumeAttempts === 2) {
expect(drained).toEqual([])
expect(continued).toEqual([])
}
expect((yield* claims(database))[parent]).toBe(false)
expect(yield* attempts(database, parent)).toBe(0)
expect(yield* SessionInbox.list(database.db, parent)).toHaveLength(2)
expect(yield* restarted.pendingBackground).toEqual([])
yield* restart.resumeSuspendedSessions
expect(drained).toHaveLength(resumeAttempts < 2 ? 1 : 0)
}),
)
}
it.effect("terminalizes a recovered subagent that exhausts its resume budget", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const jobs = yield* Job.Service
const parent = Session.ID.make("ses_subagent_exhausted_parent")
const child = Session.ID.make("ses_subagent_exhausted_child")
yield* seedSessions(database, [parent])
yield* seedSessions(database, [child], { parent_id: parent, time_suspended: Date.now(), resume_attempts: 2 })
yield* jobs.start({
id: child,
type: "subagent",
recovery: {
kind: "subagent",
parentSessionID: parent,
childSessionID: child,
agent: "explore",
description: "Exhausted inspection",
},
run: Effect.never,
})
yield* jobs.background(child)
const parentWoken = yield* Deferred.make<void>()
const drained: Session.ID[] = []
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const restarted = yield* Job.make.pipe(Effect.provideService(Scope.Scope, scope))
const context = yield* buildExecution(
scope,
({ sessionID }) =>
Effect.sync(() => void drained.push(sessionID)).pipe(
Effect.andThen(Deferred.succeed(parentWoken, undefined)),
),
{ maxAttempts: 2 },
restarted,
)
yield* Context.get(context, SessionRestart.Service).resumeSuspendedSessions
yield* Deferred.await(parentWoken)
expect(drained).toEqual([parent])
expect((yield* claims(database))[child]).toBe(false)
expect(yield* SessionInbox.list(database.db, parent)).toMatchObject([
{
payload: {
text: expect.stringContaining("will not be resumed automatically"),
metadata: { source: "subagent", childID: child, state: "error" },
},
},
])
expect(yield* restarted.pendingBackground).toEqual([])
}),
)
})
describe("SessionExecution interrupt continuation", () => {
it.effect("resumes only steering input after an interrupt with continue", () =>
Effect.gen(function* () {
@@ -1022,27 +446,6 @@ describe("SessionExecution interrupt continuation", () => {
)
})
function seedBackground(
jobs: Job.Interface,
sessionID: Session.ID,
background: ReadonlyArray<{ readonly id: string; readonly shellID: string; readonly command: string }>,
) {
return Effect.forEach(
background,
(job) =>
Effect.gen(function* () {
yield* jobs.start({
id: job.id,
type: "shell",
recovery: { kind: "shell", sessionID, shellID: job.shellID, command: job.command },
run: Effect.never,
})
yield* jobs.background(job.id)
}),
{ discard: true },
)
}
/** Plain deliveries seed user prompts; objects seed control items. */
function seedInbox(
database: Database.Service["Service"],
@@ -1128,27 +531,11 @@ function buildExecution(
scope: Scope.Closeable,
drain: (input: Parameters<SessionRunner.Interface["drain"]>[0]) => Effect.Effect<void, SessionRunner.RunError>,
options?: SessionRestart.Options,
overrideJobs?: Job.Interface,
) {
return Effect.gen(function* () {
const database = yield* Database.Service
const bus = yield* Bus.Service
const store = yield* SessionStore.Service
const jobs = overrideJobs ?? (yield* Job.Service)
const sessions = yield* Session.Service
const sessionLayer = Layer.effect(
Session.Service,
Effect.gen(function* () {
const execution = yield* SessionExecution.Service
return Session.Service.of({
...sessions,
synthetic: (input) =>
sessions
.synthetic({ ...input, resume: false })
.pipe(Effect.tap(() => (input.resume === false ? Effect.void : execution.wake(input.sessionID)))),
})
}),
)
const runner = Layer.succeed(
SessionRunner.Service,
SessionRunner.Service.of({
@@ -1166,13 +553,10 @@ function buildExecution(
)
return yield* Layer.buildWithScope(
SessionRestart.layer(options).pipe(
Layer.provideMerge(sessionLayer),
Layer.provideMerge(Layer.fresh(SessionExecution.layer)),
Layer.provideMerge(SessionExecution.layer),
Layer.provide(Layer.succeed(Database.Service, database)),
Layer.provide(Layer.succeed(Bus.Service, bus)),
Layer.provide(Layer.succeed(SessionStore.Service, store)),
Layer.provide(Layer.succeed(Job.Service, jobs)),
Layer.provide(SessionEngineBindings.layer),
Layer.provide(locations),
),
scope,
@@ -128,35 +128,6 @@ test("interrupted progress metadata remains in the terminal failure snapshot", a
})
})
test("interrupted subagent failures expose their existing child session to the model", async () => {
const { published, publisher } = capture("anthropic", { interruptProgress: true })
const subagent = LLMEvent.toolCall({
id: "call-subagent",
name: "subagent",
input: { agent: "general", description: "Recover child", prompt: "Continue working" },
})
await Effect.runPromise(publisher.publish(subagent))
await Effect.runPromiseExit(publisher.progress(subagent.id, { sessionID: "ses_existing_child", status: "running" }))
await Effect.runPromise(publisher.failUnsettledTools({ type: "aborted", message: "Tool execution interrupted" }))
expect(published.find((event) => event.type === "session.tool.failed.2")?.data).toMatchObject({
error: { type: "aborted", message: "Tool execution interrupted (sessionID: ses_existing_child)" },
metadata: { sessionID: "ses_existing_child", status: "running" },
})
})
test("interrupted non-subagent failures do not expose their progress session IDs", async () => {
const { published, publisher } = capture()
await Effect.runPromise(publisher.publish(call))
await Effect.runPromise(publisher.progress(call.id, { sessionID: "ses_private", status: "running" }))
await Effect.runPromise(publisher.failUnsettledTools({ type: "aborted", message: "Tool execution interrupted" }))
expect(published.find((event) => event.type === "session.tool.failed.2")?.data).toMatchObject({
error: { type: "aborted", message: "Tool execution interrupted" },
metadata: { sessionID: "ses_private", status: "running" },
})
})
test("local failure metadata completes the progress snapshot", async () => {
const { published, publisher } = capture()
await Effect.runPromise(publisher.publish(call))
@@ -10,7 +10,7 @@ import { Tool } from "@opencode-ai/core/tool"
import type { Info } from "@opencode-ai/schema/tool"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { executeTool, toolDefinitions } from "./lib/tool"
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Logger, Schema, SchemaGetter, SchemaIssue, Scope } from "effect"
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Schema, SchemaGetter, SchemaIssue, Scope } from "effect"
import { z } from "zod"
import { testEffect } from "./lib/effect"
@@ -71,49 +71,30 @@ const transform = (service: Tool.Interface, tools: Readonly<Record<string, Info>
)
describe("Tool", () => {
it.effect("logs and skips invalid dotted namespaces", () => {
const output: unknown[] = []
const logger = Logger.map(Logger.formatStructured, (entry) => {
output.push(entry.message)
})
return Effect.gen(function* () {
const service = yield* Tool.Service
yield* transform(service, { echo: make() }, { namespace: "slack..admin" })
expect(output).toEqual([
[
"Skipping invalid tool registration",
{ name: "echo", namespace: "slack..admin", error: 'Invalid tool namespace: "slack..admin"' },
],
])
const snapshot = yield* service.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["execute"])
expect(snapshot.codeModeCatalog).toEqual([])
}).pipe(Effect.provide(Logger.layer([logger])))
})
it.effect("skips invalid, reserved, and colliding names without dropping healthy tools", () =>
it.effect("rejects invalid dotted namespaces", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
yield* transform(
service,
{
before: make(),
"": make(),
["x".repeat(65)]: make(),
"echo.tool": make(),
echo_tool: make(),
execute: make(),
after: make(),
},
{ codemode: false },
const error = yield* transform(service, { echo: make() }, { namespace: "slack..admin" }).pipe(Effect.flip)
expect(error).toBeInstanceOf(Tool.RegistrationError)
expect(error.message).toBe('Invalid tool namespace: "slack..admin"')
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
}),
)
it.effect("rejects invalid and colliding normalized names", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
for (const name of ["", "x".repeat(65)]) {
const invalid = yield* transform(service, { [name]: make() }, { codemode: false }).pipe(Effect.flip)
expect(invalid.message).toBe(`Invalid tool name: ${name}`)
}
const collision = yield* transform(service, { "echo.tool": make(), echo_tool: make() }, { codemode: false }).pipe(
Effect.flip,
)
const snapshot = yield* service.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["after", "before", "execute"])
expect((yield* snapshot.execute(call("before"))).output).toEqual({ text: "before" })
expect((yield* snapshot.execute(call("after"))).output).toEqual({ text: "after" })
expect((yield* snapshot.execute(call("echo_tool")).pipe(Effect.flip)).message).toBe("Unknown tool: echo_tool")
expect(snapshot.codeModeCatalog).toEqual([])
expect(collision.message).toBe("Duplicate normalized tool name: echo_tool")
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
}),
)
@@ -178,72 +159,40 @@ describe("Tool", () => {
}),
)
it.effect("keeps healthy tools when another namespace is invalid", () =>
it.effect("validates a registration batch before installing any tools", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
yield* service.transform((draft) => {
draft.add({ ...make(), name: "first", options: { codemode: false } })
draft.add({ ...make(), name: "second", options: { namespace: "invalid..namespace", codemode: false } })
draft.add({ ...make(), name: "second", options: { namespace: "invalid__namespace" } })
})
const error = yield* service
.transform((draft) => {
draft.add({ ...make(), name: "first", options: { codemode: false } })
draft.add({ ...make(), name: "second", options: { namespace: "invalid..namespace", codemode: false } })
})
.pipe(Effect.flip)
const snapshot = yield* service.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["first", "execute"])
expect(snapshot.codeModeCatalog?.map((tool) => tool.path)).toEqual(["invalid__namespace.second"])
expect(error).toBeInstanceOf(Tool.RegistrationError)
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
}),
)
it.effect("logs invalid tool definitions without dropping healthy tools", () => {
const output: unknown[] = []
const logger = Logger.map(Logger.formatStructured, (entry) => {
output.push(entry.message)
})
return Effect.gen(function* () {
const service = yield* Tool.Service
yield* service.transform((draft) => {
draft.add({ ...make(), name: "healthy", options: { codemode: false } })
draft.add({
name: "phone_type",
input: Schema.Struct({}),
execute: () => Effect.succeed({ content: "ok" }),
options: { codemode: false },
} as unknown as Info)
draft.add({ ...make(), name: "codemode" })
})
expect(output).toEqual([
[
"Skipping invalid tool registration",
{
name: "phone_type",
namespace: undefined,
error: expect.stringContaining('Expected string\n at ["description"]'),
},
],
])
const snapshot = yield* service.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["healthy", "execute"])
expect(snapshot.codeModeCatalog?.map((tool) => tool.path)).toEqual(["codemode"])
expect((yield* snapshot.execute(call("phone_type")).pipe(Effect.flip)).message).toBe("Unknown tool: phone_type")
}).pipe(Effect.provide(Logger.layer([logger])))
})
it.effect("skipped registrations leave existing tools and scoped cleanup intact", () =>
it.effect("rejects invalid tool definitions before installing any tools", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
yield* transform(service, { echo: constant("original") }, { codemode: false })
yield* Effect.scoped(
Effect.gen(function* () {
yield* service.transform((draft) => {
draft.add({ ...constant("invalid"), name: "echo", description: undefined } as unknown as Info)
draft.add({ ...make(), name: "temporary", options: { codemode: false } })
})
const snapshot = yield* service.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["echo", "temporary", "execute"])
expect((yield* snapshot.execute(call("echo"))).output).toEqual({ text: "original" })
}),
)
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["echo", "execute"])
const error = yield* service
.transform((draft) => {
draft.add({ ...make(), name: "healthy", options: { codemode: false } })
draft.add({
name: "phone_type",
input: Schema.Struct({}),
execute: () => Effect.succeed({ content: "ok" }),
options: { codemode: false },
} as unknown as Info)
})
.pipe(Effect.flip)
expect(error).toBeInstanceOf(Tool.RegistrationError)
expect(error.name).toBe("phone_type")
expect(error.message).toContain('Expected string\n at ["description"]')
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
}),
)
+1 -302
View File
@@ -79,7 +79,7 @@ import { Provider } from "@opencode-ai/core/provider"
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Queue, Schema, Scope, Stream } from "effect"
import { TestClock } from "effect/testing"
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { asc, desc, eq, sql } from "drizzle-orm"
import { asc, desc, eq } from "drizzle-orm"
import { testEffect } from "./lib/effect"
import { permissionLayer } from "./lib/permission"
import { agentHost, catalogHost, host } from "./plugin/host"
@@ -1444,49 +1444,6 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("delivers controls without preflighting unavailable initial instructions", () =>
Effect.gen(function* () {
const session = yield* setup
const database = yield* Database.Service
const bus = yield* Bus.Service
const runner = yield* SessionRunner.Service
systemUnavailable = true
let reads = 0
systemLoadHook = Effect.sync(() => {
reads++
})
const compaction = yield* SessionInbox.admitCompaction(database.db, bus, {
id: SessionMessage.ID.create(),
sessionID,
delivery: "queue",
})
yield* SessionInbox.admit(database.db, bus, {
id: SessionMessage.ID.create(),
sessionID,
item: {
type: "move",
payload: {
location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }),
projectID: Project.ID.global,
},
delivery: "queue",
},
})
expect(yield* runner.drain({ sessionID, force: false })).toEqual(SessionRunner.DrainResult.Moved({}))
expect(reads).toBe(0)
expect(requests).toHaveLength(0)
expect(yield* session.inbox(sessionID)).toEqual([])
expect((yield* session.get(sessionID)).location.directory).toBe(AbsolutePath.make("/moved"))
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
type: "compaction",
status: "failed",
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
})
}),
)
it.effect("delivers a queued move atomically at the idle boundary", () =>
Effect.gen(function* () {
const session = yield* setup
@@ -1597,56 +1554,6 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("runs a queued control on Location entry before a carried continuation", () =>
Effect.gen(function* () {
const session = yield* setup
const database = yield* Database.Service
const bus = yield* Bus.Service
const runner = yield* SessionRunner.Service
yield* admit(session, "Echo before moving")
yield* TestLLM.push(
TestLLM.tool("call-entry", "echo", { text: "moving" }),
TestLLM.text("Entry summary", "entry-summary"),
TestLLM.text("Continued", "entry-continuation"),
)
const stream = yield* TestLLM.gate
const run = yield* runner.drain({ sessionID, force: false }).pipe(Effect.forkChild)
yield* stream.started
const compaction = yield* SessionInbox.admitCompaction(database.db, bus, {
id: SessionMessage.ID.create(),
sessionID,
delivery: "queue",
})
yield* SessionInbox.admit(database.db, bus, {
id: SessionMessage.ID.create(),
sessionID,
item: {
type: "move",
payload: {
location: Location.Ref.make({ directory: AbsolutePath.make("/project") }),
projectID: Project.ID.global,
},
delivery: "steer",
},
})
yield* stream.release
const moved = yield* Fiber.join(run)
expect(moved).toEqual(SessionRunner.DrainResult.Moved({ continuation: { step: 2 } }))
expect(requests).toHaveLength(1)
expect(yield* SessionInbox.find(database.db, compaction.id)).toMatchObject({ id: compaction.id })
if (moved._tag !== "Moved") throw new Error("Expected a Location handoff")
// Location entry considers queued controls even when model work carries across the move.
yield* runner.drain({ sessionID, force: false, continuation: moved.continuation })
expect(requests).toHaveLength(3)
expect(userTexts(requests[1])[0]).toContain("Create a new anchored summary")
expect(userTexts(requests[2])[0]).toContain("<summary>\nEntry summary\n</summary>")
expect(yield* session.inbox(sessionID)).toEqual([])
}),
)
it.effect("seeds a fork with the parent's newest instruction values", () =>
Effect.gen(function* () {
const session = yield* setup
@@ -2655,74 +2562,6 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("refreshes preparation after overflow compaction without promoting new input", () =>
Effect.gen(function* () {
const session = yield* setupOverflowRecovery
const bus = yield* Bus.Service
let reads = 0
let resolutions = 0
systemLoadHook = Effect.sync(() => {
reads++
})
modelResolveHook = Effect.sync(() => {
resolutions++
})
yield* admit(session, "Continue")
yield* TestLLM.push(
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
TestLLM.text("Overflow summary", "overflow-summary"),
TestLLM.text("Recovered", "overflow-recovered"),
TestLLM.stop(),
TestLLM.stop(),
)
const first = yield* TestLLM.gate
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* first.started
expect(reads).toBe(1)
expect(resolutions).toBe(1)
expect(requests[0]?.model).toBe(recoveryModel)
const summary = yield* TestLLM.gate
yield* first.release
yield* summary.started
systemBaseline = "Changed during compaction"
yield* bus.publish(SessionEvent.ModelSelected, {
sessionID,
model: { id: ID.make("replacement"), providerID: Provider.ID.make("fake") },
})
const queued = yield* session.prompt({
sessionID,
text: "Queued during compaction",
delivery: "queue",
resume: false,
})
const steered = yield* admit(session, "Steered during compaction")
const retry = yield* TestLLM.gate
yield* summary.release
yield* retry.started
expect(reads).toBe(2)
expect(resolutions).toBe(2)
expect(requests).toHaveLength(3)
expect(requests[2]?.model).toBe(replacementModel)
expect(requests[2]?.system.map((part) => part.text)).toEqual([defaultSystem, "Initial context"])
expect(systemTexts(requests[2])).toContain("Changed during compaction")
expect(userTexts(requests[2])[0]).toContain("<summary>\nOverflow summary\n</summary>")
expect(userTexts(requests[2]).join("\n")).not.toContain("Queued during compaction")
expect(userTexts(requests[2]).join("\n")).not.toContain("Steered during compaction")
expect((yield* session.inbox(sessionID)).map((item) => item.id)).toEqual([queued.id, steered.id])
yield* retry.release
yield* Fiber.join(run)
expect(requests).toHaveLength(5)
expect(userTexts(requests[3])).toContain("Steered during compaction")
expect(userTexts(requests[3])).not.toContain("Queued during compaction")
expect(userTexts(requests[4])).toContain("Queued during compaction")
expect(yield* session.inbox(sessionID)).toEqual([])
}),
)
it.effect("does not recover provider context overflow when automatic compaction is disabled", () =>
Effect.gen(function* () {
const session = yield* setupOverflowRecovery
@@ -3481,71 +3320,6 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("keeps queued input parked when a steer is cancelled during preparation", () =>
Effect.gen(function* () {
const session = yield* setup
const runner = yield* SessionRunner.Service
yield* admit(session, "A")
yield* TestLLM.push(TestLLM.stop(), TestLLM.stop(), TestLLM.stop())
const stream = yield* TestLLM.gate
const run = yield* runner.drain({ sessionID, force: false }).pipe(Effect.forkChild)
yield* stream.started
yield* session.prompt({ sessionID, text: "B", delivery: "queue", resume: false })
const steer = yield* admit(session, "S")
systemLoadHook = Effect.gen(function* () {
systemLoadHook = Effect.void
yield* session.cancelInbox({ sessionID, inboxID: steer.id }).pipe(Effect.orDie)
})
yield* stream.release
yield* Fiber.join(run)
expect(requests.map(userTexts)).toEqual([["A"], ["A"], ["A", "B"]])
expect((yield* session.messages({ sessionID })).some((message) => message.id === steer.id)).toBe(false)
expect(yield* session.inbox(sessionID)).toEqual([])
}),
)
it.effect("dispatches a queued move when a steer is cancelled during preparation", () =>
Effect.gen(function* () {
const session = yield* setup
const runner = yield* SessionRunner.Service
const database = yield* Database.Service
const bus = yield* Bus.Service
const location = Location.Ref.make({ directory: AbsolutePath.make("/moved") })
yield* admit(session, "A")
yield* TestLLM.push(TestLLM.stop(), TestLLM.stop())
const stream = yield* TestLLM.gate
const run = yield* runner.drain({ sessionID, force: false }).pipe(Effect.forkChild)
yield* stream.started
yield* SessionInbox.admit(database.db, bus, {
id: SessionMessage.ID.create(),
sessionID,
item: {
type: "move",
payload: { location, projectID: Project.ID.global },
delivery: "queue",
},
})
const steer = yield* admit(session, "S")
systemLoadHook = Effect.gen(function* () {
systemLoadHook = Effect.void
yield* session.cancelInbox({ sessionID, inboxID: steer.id }).pipe(Effect.orDie)
})
yield* stream.release
expect({ result: yield* Fiber.join(run), location: (yield* session.get(sessionID)).location }).toEqual({
result: SessionRunner.DrainResult.Moved({}),
location,
})
expect(requests.map(userTexts)).toEqual([["A"], ["A"]])
expect(closedTransports).toEqual([sessionID])
expect(yield* recordedEventTypes(sessionID)).toContain(Bus.versionedType(SessionEvent.Moved.type, 1))
expect(yield* session.inbox(sessionID)).toEqual([])
}),
)
it.effect("preserves durable queued input for a later wake after interruption", () =>
Effect.gen(function* () {
const session = yield* setup
@@ -3866,81 +3640,6 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("preserves a stale subagent child session in its model-visible failure", () =>
Effect.gen(function* () {
const session = yield* setup
const bus = yield* Bus.Service
const database = yield* Database.Service
yield* admit(session, "Recover interrupted subagent")
yield* SessionInbox.promote(database.db, bus, sessionID, "steer")
const assistantMessageID = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID,
agent: Agent.ID.make("build"),
model: { id: ID.make("fake-model"), providerID: Provider.ID.make("fake") },
})
yield* bus.publish(SessionEvent.Tool.Input.Started, {
sessionID,
assistantMessageID,
id: "call-interrupted-subagent",
name: "subagent",
})
yield* bus.publish(SessionEvent.Tool.Input.Ended, {
sessionID,
assistantMessageID,
id: "call-interrupted-subagent",
text: '{"agent":"general"}',
})
yield* bus.publish(SessionEvent.Tool.Called, {
sessionID,
assistantMessageID,
id: "call-interrupted-subagent",
input: { agent: "general" },
executed: false,
})
yield* database.db
.update(SessionMessageTable)
.set({
data: sql`json_set(
${SessionMessageTable.data},
'$.content[0].state.metadata',
json('{"sessionID":"ses_existing_child","status":"running","internal":"private"}')
)`,
})
.where(eq(SessionMessageTable.id, assistantMessageID))
.run()
.pipe(Effect.orDie)
requests.length = 0
yield* TestLLM.push([])
yield* session.resume(sessionID)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Recover interrupted subagent" },
{
type: "assistant",
content: [
{
type: "tool",
id: "call-interrupted-subagent",
state: {
status: "error",
error: {
type: "aborted",
message: "Tool execution interrupted: subagent (sessionID: ses_existing_child)",
},
metadata: { sessionID: "ses_existing_child", status: "running", internal: "private" },
},
},
],
},
])
const modelResult = JSON.stringify(requests[0]?.messages.at(-1))
expect(modelResult).toContain("ses_existing_child")
expect(modelResult).not.toContain("private")
}),
)
it.effect("durably fails hosted tools left running by a prior process before continuing inline", () =>
Effect.gen(function* () {
const session = yield* setup
-42
View File
@@ -790,48 +790,6 @@ describe("ShellTool", () => {
),
)
it.live("persists a silent command that finishes before backgrounding", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const jobs = yield* Job.Service
const shell = yield* Shell.Service
const persisted = yield* Deferred.make<readonly Job.Background[]>()
yield* bus.project(SessionEvent.InboxEnqueued, (event) =>
event.data.sessionID === sessionID && event.data.item.type === "synthetic"
? jobs.pendingBackground.pipe(
Effect.flatMap((background) => Deferred.succeed(persisted, background)),
Effect.asVoid,
)
: Effect.void,
)
yield* executeTool(registry, {
...call({ command: "exit 7", background: true }, "call-background-silent-nonzero"),
// The command can finish while its initial progress update is being published.
progress: (update) =>
typeof update.shellID === "string"
? shell.wait(ShellSchema.ID.make(update.shellID)).pipe(Effect.orDie, Effect.asVoid)
: Effect.void,
})
expect(yield* Deferred.await(persisted)).toMatchObject([
{
id: "call-background-silent-nonzero",
status: "completed",
output: "(no output)\n\nCommand exited with code 7.",
},
])
}),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
)
it.live(
"updates and clears a running shell timeout",
() =>
+3
View File
@@ -8,6 +8,7 @@ import type { DurableObjectStorage } from "@opencode-ai/core/database/sqlite.wor
import { EnvironmentUnavailable } from "@opencode-ai/core/environment/unavailable"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { FileSystemSearch } from "@opencode-ai/core/filesystem/search"
import { ModelsDevCache } from "@opencode-ai/core/models-dev/cache"
import { Pty } from "@opencode-ai/core/pty"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { Vcs } from "@opencode-ai/core/vcs"
@@ -30,6 +31,7 @@ import type { ServerOptions } from "./options"
* backs them; Snapshot and Vcs degrade to no-op results.
* - Config is injected as a string (no filesystem); plugin discovery is
* precompiled-only, and stdio MCP reports the same no-plane failure as Shell.
* - The models.dev catalog is memory-only, with no local filesystem cache.
*
* Bundle with the `workerd` condition, e.g.
* `bun build src/workerd.ts --conditions=workerd --target=node`
@@ -81,6 +83,7 @@ export function replacements(options: Options): LayerNode.Replacements {
[Vcs.node, vcsLayer],
[FileSystem.node, fileSystemLayer],
[FileSystemSearch.node, fileSystemSearchLayer],
[ModelsDevCache.node, ModelsDevCache.disabledLayer],
[Pty.node, ptyLayer],
// Precompiled (internal and SDK) plugins only: no plugin-directory scan, npm
// install, or import of plugin code from disk.
+60 -1
View File
@@ -1,5 +1,11 @@
import { expect } from "bun:test"
import { Effect } from "effect"
import { Effect, FileSystem, Layer, Ref } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { ModelsDevCache } from "@opencode-ai/core/models-dev/cache"
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { makeDurableObjectStorage } from "../../core/test/fixture/durable-object-storage"
import { it } from "../../core/test/lib/effect"
import { ServerWorkerd } from "../src/workerd"
@@ -31,3 +37,56 @@ it.live("boots the workerd profile over durable object storage", () =>
expect(body).toMatchObject({ healthy: true, version: "workerd-test" })
}).pipe(Effect.scoped),
)
it.live("refreshes a memory-only catalog without a local filesystem", () =>
Effect.gen(function* () {
const name = yield* Ref.make("Acme One")
const replacements: LayerNode.Replacements = [
...ServerWorkerd.replacements({ storage: makeDurableObjectStorage() }),
[ModelsDev.node, ModelsDev.configured({ fetch: false, snapshot: false })],
[Global.node, Layer.succeed(Global.Service, Global.make())],
[LayerNodePlatform.filesystem, FileSystem.layerNoop({})],
[
LayerNodePlatform.httpClient,
Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.gen(function* () {
return HttpClientResponse.fromWeb(
request,
Response.json({
acme: {
id: "acme",
name: yield* Ref.get(name),
env: [],
npm: "@ai-sdk/openai-compatible",
models: {},
},
}),
)
}),
),
),
],
]
yield* Effect.gen(function* () {
const cache = yield* ModelsDevCache.Service
const models = yield* ModelsDev.Service
yield* cache.write("https://models.opencode.ai", "not persisted")
expect(yield* cache.read("https://models.opencode.ai")).toBeUndefined()
expect(yield* models.get()).toEqual([])
yield* models.refresh(true)
expect((yield* models.get()).map((provider) => provider.info.name)).toEqual(["Acme One"])
yield* Ref.set(name, "Acme Two")
yield* models.refresh(true)
expect((yield* models.get()).map((provider) => provider.info.name)).toEqual(["Acme Two"])
expect(yield* cache.read("https://models.opencode.ai")).toBeUndefined()
}).pipe(
Effect.provide(
Layer.fresh(LayerNode.compile(LayerNode.group([ModelsDev.node, ModelsDevCache.node]), replacements)),
),
)
}),
)
-2
View File
@@ -1,2 +0,0 @@
component-tests/test-results
component-tests/playwright-report
@@ -1,127 +0,0 @@
import { expect, story } from "../../storybook/playwright/story"
for (const expanded of [false, true]) {
// Moved from packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts
story(`preserves shell user intent from a ${expanded ? "expanded" : "collapsed"} default`, async ({ mount }) => {
const timeline = await mount("current-session-terminal-work--terminal-commands", { args: { expanded } })
const trigger = expanded
? timeline.locator('[data-timeline-part-id="tool_shell_lifecycle"] [data-slot="collapsible-trigger"]')
: timeline.getByRole("button", { name: "Used Shell", exact: true })
await expect(trigger).toHaveAttribute("aria-expanded", String(expanded))
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
await timeline.getByRole("button", { name: "Update output" }).click()
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
await timeline.getByRole("button", { name: "Append sibling" }).click()
await expect(timeline.getByText("Sibling content", { exact: true })).toBeVisible()
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
await timeline.getByRole("button", { name: "Mark session busy" }).click()
await timeline.getByRole("button", { name: "Mark session idle" }).click()
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
})
}
// Moved from packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts
story("transitions a streaming shell from writing through command execution", async ({ mount }) => {
const timeline = await mount("current-session-terminal-work--terminal-commands", { args: { streaming: true } })
const tool = timeline.locator('[data-timeline-part-id="tool_shell_lifecycle"]')
const title = tool.locator('[data-slot="basic-tool-tool-title"]')
const shimmer = title.locator('[data-component="text-shimmer"]')
const subtitle = tool.locator('[data-slot="basic-tool-tool-subtitle"]')
await expect(shimmer).toHaveAttribute("aria-label", "Shell")
await expect(shimmer).toHaveAttribute("data-active", "true")
await expect(subtitle).toHaveText("Writing command...")
await expect(subtitle.locator('[data-component="text-shimmer"]')).toHaveCount(0)
await expect(tool.locator('[data-component="shell-submessage"]')).toHaveCount(0)
await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveCSS("height", "28px")
await expect(tool.locator('[data-component="tool-trigger"]')).toHaveCSS("gap", "6px")
await expect(title).toHaveCSS("font-size", "13px")
await expect(title).toHaveCSS("font-family", /^Inter,/)
await expect(title).toHaveCSS("font-weight", "530")
await expect(title).toHaveCSS("line-height", "16px")
await expect(title).toHaveCSS("color", "rgb(22, 22, 22)")
await expect(subtitle).toHaveCSS("font-size", "13px")
await expect(subtitle).toHaveCSS("font-family", /^Inter,/)
await expect(subtitle).toHaveCSS("font-weight", "440")
await expect(subtitle).toHaveCSS("line-height", "16px")
await expect(subtitle).toHaveCSS("color", "rgb(92, 92, 92)")
await timeline.getByRole("button", { name: "Complete input" }).click()
await expect(shimmer).toHaveAttribute("data-active", "true")
await expect(subtitle).toHaveText("printf ready")
await expect(tool).not.toContainText("Writing command...")
await timeline.getByRole("button", { name: "Run command" }).click()
await expect(shimmer).toHaveAttribute("data-active", "true")
await expect(subtitle).toHaveText("printf ready")
await expect(tool).not.toContainText("Writing command...")
await timeline.getByRole("button", { name: "Complete command" }).click()
const summary = timeline.getByRole("button", { name: "Used Shell", exact: true })
await expect(summary).toHaveAttribute("aria-expanded", "false")
await summary.click()
await expect(subtitle).toHaveText("printf ready")
})
// Moved from packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts
story("shimmers and expands a running shell command", async ({ mount }) => {
const timeline = await mount("current-session-terminal-work--terminal-commands", { args: { streaming: true } })
await timeline.getByRole("button", { name: "Run command" }).click()
const tool = timeline.locator('[data-timeline-part-id="tool_shell_lifecycle"]')
const trigger = tool.locator('[data-slot="collapsible-trigger"]')
await expect(tool.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true")
await expect(tool).not.toContainText("Writing command...")
await expect(tool.locator('[data-component="shell-submessage"]')).toHaveText("printf ready")
await expect(tool.locator('[data-component="shell-submessage"] [data-component="text-shimmer"]')).toHaveCount(0)
await expect(trigger).toHaveCSS("height", "28px")
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await expect(tool.locator('[data-slot="bash-pre"]')).toContainText("still running")
})
// Moved from packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts
story("transitions thinking and hidden reasoning through busy to idle", async ({ mount }) => {
const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "hidden" } })
const reasoning = timeline.locator('[data-timeline-part-id="msg_hidden_reasoning_lifecycle:reasoning:0"]')
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await expect(timeline.getByText("Inspecting stability", { exact: true })).toBeVisible()
await expect(reasoning).toHaveCount(0)
await timeline.getByRole("button", { name: "Start shell" }).click()
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await expect(timeline.locator('[data-timeline-part-id="tool_hidden_reasoning_shell"]')).toBeVisible()
await timeline.getByRole("button", { name: "Finish session" }).click()
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(reasoning).toHaveCount(0)
})
// Moved from packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts
story("moves busy through retry and recovery to final idle content", async ({ mount }) => {
const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "retry" } })
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await expect(timeline.locator('[data-timeline-row="DiffSummary"]')).toHaveCount(0)
await timeline.getByRole("button", { name: "Retry request" }).click()
await expect(timeline.locator('[data-timeline-row="Retry"]')).toBeVisible()
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await timeline.getByRole("button", { name: "Recover request" }).click()
await expect(timeline.locator('[data-timeline-row="Retry"]')).toHaveCount(0)
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toBeVisible()
await timeline.getByRole("button", { name: "Finish response" }).click()
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(timeline.locator('[data-timeline-part-id="msg_retry_recovery_lifecycle:text:0"]')).toContainText(
"Recovered response",
)
})
for (const locale of ["de", "ar"] as const) {
// Moved from packages/app/e2e/regression/session-timeline-locale-projection.spec.ts
story(`projects localized tool names with an English fallback in ${locale}`, async ({ mount, page }) => {
const timeline = await mount("current-session-research-agents--agent-research", {
args: { scenario: "exploration" },
globals: { locale },
})
await timeline.getByRole("button", { name: "Complete read" }).click()
await timeline.getByRole("button", { name: "Complete glob" }).click()
const group = timeline.locator('[data-timeline-part-ids="tool_context_read,tool_context_glob"]')
await expect(group.getByRole("button")).toHaveAccessibleName(/^Used /)
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
await expect(page.locator("html")).toHaveAttribute("lang", locale)
})
}
@@ -1,61 +0,0 @@
import { expect, story } from "../../storybook/playwright/story"
// Moved from packages/app/e2e/regression/session-timeline-collapse-state.spec.ts
story("keeps a manually collapsed tool collapsed when later assistant content streams", async ({ mount }) => {
const timeline = await mount("current-session-file-changes--changing-files", { args: { scenario: "streaming" } })
const tool = timeline.locator('[data-timeline-part-id="tool_edit_status"]')
const trigger = tool.locator('[data-scope="apply-patch"] button')
await expect(trigger).toHaveAttribute("aria-expanded", "true")
await tool.evaluate((element) => ((element as HTMLElement).dataset.regressionMarker = "before-stream"))
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await timeline.getByRole("button", { name: "Stream sibling content" }).click()
await expect(timeline.getByText("Streaming added a later assistant text part.", { exact: true })).toBeVisible()
await expect(tool).toHaveAttribute("data-regression-marker", "before-stream")
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(tool.locator("xpath=ancestor::*[@data-timeline-row]")).toHaveAttribute(
"data-timeline-row",
"AssistantPart",
)
})
// Moved from packages/app/e2e/regression/session-timeline-projection.spec.ts
story("renders interruption independently when the turn is not compacted", async ({ mount }) => {
const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "interruption" } })
await expect(timeline.getByText("Interrupted", { exact: true })).toBeVisible()
await expect(timeline.getByText("Before", { exact: true })).toBeVisible()
await expect(timeline.getByText("After", { exact: true })).toBeVisible()
const rows = await timeline
.locator('[data-timeline-row="AssistantPart"], [data-timeline-row="TurnDivider"]')
.evaluateAll((elements) => elements.map((element) => element.getAttribute("data-timeline-row")))
expect(rows).toEqual(["AssistantPart", "TurnDivider", "AssistantPart"])
})
// Moved from packages/app/e2e/regression/session-timeline-projection.spec.ts
story("renders aliased and long custom model notices", async ({ mount, page }) => {
await page.setViewportSize({ width: 420, height: 700 })
const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "models" } })
const shortName = "GPT-5.4 nano"
const longName = "Company Gateway Extra Long Context Model for Narrow Timeline Layouts"
const short = timeline.locator('[data-slot="session-timeline-notice"]').filter({ hasText: shortName })
const long = timeline.locator('[data-slot="session-timeline-notice"]').filter({ hasText: longName })
await expect(short).toBeVisible()
await expect(short.getByText(`Switched to ${shortName}`, { exact: true })).toBeVisible()
await expect(short.locator('[data-slot="session-timeline-notice-variant"]')).toHaveText("xhigh")
await expect(timeline.getByText("fast-nano", { exact: true })).toHaveCount(0)
await expect(short.locator('[data-component="provider-icon"]')).toBeVisible()
await expect(long).toBeVisible()
await expect(long.locator('[data-component="provider-icon"]')).toBeVisible()
await expect(long.locator('[data-slot="session-timeline-notice-variant"]')).toHaveCount(0)
await expect(long.locator("[title]")).toHaveAttribute("title", `Switched to ${longName}`)
await expect.poll(() => long.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
})
// Moved from packages/app/e2e/regression/session-timeline-projection.spec.ts
story("renders user image, file attachment, file reference, and agent reference", async ({ mount }) => {
const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "attachments" } })
await expect(timeline.getByAltText("pixel.png")).toBeVisible()
await expect(timeline.getByText("tsconfig.json")).toBeVisible()
await expect(timeline.getByText("@src/a.ts", { exact: true })).toBeVisible()
await expect(timeline.getByText("@explore", { exact: true })).toBeVisible()
})
@@ -1,72 +0,0 @@
import { expect, story } from "../../storybook/playwright/story"
// Moved from packages/app/e2e/regression/session-timeline-notices.spec.ts
story("renders current protocol notices in CLI order", async ({ mount, page }) => {
const warnings: string[] = []
page.on("console", (message) => {
if (message.text().includes("computations created outside a `createRoot` or `render`"))
warnings.push(message.text())
})
const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "notices" } })
const notices = timeline.locator('[data-slot="session-timeline-notice"]')
await expect(notices).toHaveCount(4)
await expect(notices.nth(0)).toContainText("Agent · explore")
await expect(notices.nth(1)).toContainText("explore finished · Search code")
await expect(notices.nth(2)).toContainText("Continuing after restart")
await expect(notices.nth(3)).toContainText("Skill · Review")
await expect(notices).toHaveClass([/text-text-weak/, /text-text-weak/, /text-text-weak/, /text-text-weak/])
await expect(notices.locator(".text-text-strong")).toHaveCount(0)
expect(warnings).toEqual([])
})
// Moved from packages/app/e2e/regression/session-timeline-notices.spec.ts
story("renders a compaction summary while it streams and after completion", async ({ mount }) => {
const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "compaction" } })
const compaction = timeline.locator('[data-component="session-compaction-message"]')
await expect(compaction.getByText("Session compacted", { exact: true })).toBeVisible()
await timeline.getByRole("button", { name: "Stream summary" }).click()
await expect(compaction.getByRole("heading", { name: "Checkpoint" })).toBeVisible()
await expect(compaction).toContainText("Streamed implementation details.")
await timeline.getByRole("button", { name: "Complete summary" }).click()
await expect(compaction).toContainText("Final implementation details.")
await expect(compaction).not.toContainText("Streamed implementation details.")
})
// Moved from packages/app/e2e/regression/session-timeline-notices.spec.ts
story("updates running compactions to failed and cancelled boundaries", async ({ mount }) => {
const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "compaction" } })
await timeline.getByRole("button", { name: "Stream summary" }).click()
await timeline.getByRole("button", { name: "Fail compaction" }).click()
const compactions = timeline.locator('[data-component="session-compaction-message"]')
const failed = compactions.filter({ hasText: "The provider rejected the summary." })
await expect(failed.getByText("Session compacted", { exact: true })).toBeVisible()
await expect(failed.getByText("ProviderError: The provider rejected the summary.", { exact: true })).toBeVisible()
await expect(failed).not.toContainText("Streamed implementation details.")
await timeline.getByRole("button", { name: "Cancel next compaction" }).click()
await expect(compactions).toHaveCount(2)
const cancelled = compactions.filter({ hasNotText: "The provider rejected the summary." })
await expect(cancelled.getByText("Session compacted", { exact: true })).toBeVisible()
await expect(cancelled).not.toContainText("Cancellation detail should stay hidden.")
})
// Moved from packages/app/e2e/regression/session-timeline-notices.spec.ts
story("shows a delegating row while subagent input streams", async ({ mount }) => {
const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "delegation" } })
const delegating = timeline.locator('[data-component="task-tool-delegating"]')
await expect(delegating).toBeVisible()
const shimmer = delegating.locator('[data-component="text-shimmer"]')
await expect(shimmer).toHaveAttribute("aria-label", "Delegating agent...")
await expect(shimmer).toHaveCSS("line-height", "16px")
const icon = delegating.locator('[data-slot="icon-svg"]')
await expect(icon.locator('use[href="#opencode-v2-icon-subagent"]')).toBeVisible()
await expect(icon).toHaveCSS("color", "rgb(174, 174, 174)")
await expect(timeline.locator('[data-component="task-tool-card"]')).toHaveCount(0)
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
})
// Moved from packages/app/e2e/regression/session-timeline-notices.spec.ts
story("waits for completion before labeling requested background work", async ({ mount }) => {
const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "background" } })
await expect(timeline.locator('[data-component="task-tool-card"]')).toContainText("Inspect code")
await expect(timeline.locator('[data-component="task-tool-card"]')).not.toContainText("(background)")
})
@@ -1,50 +0,0 @@
import { expect, story } from "../../storybook/playwright/story"
// Moved from packages/app/e2e/regression/review-line-comment.spec.ts
story("opens the comment editor when code is clicked", async ({ mount }) => {
const root = await mount("components-session-review--interactive-comments")
const review = root.locator('[data-component="session-review"]')
await review.getByText("export const value = 'after'", { exact: true }).click()
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 2")
})
// Moved from packages/app/e2e/regression/review-line-comment.spec.ts
story("opens the comment editor when a line number is clicked", async ({ mount }) => {
const root = await mount("components-session-review--interactive-comments")
const review = root.locator('[data-component="session-review"]')
await expect(review.getByText("export const first = 1", { exact: true })).toBeVisible()
const number = review.locator('[data-column-number="1"]')
await expect(number).toHaveCount(1)
await number.click()
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
})
// Moved from packages/app/e2e/regression/review-line-comment.spec.ts
story("opens the comment editor for a line number range", async ({ mount }) => {
const root = await mount("components-session-review--interactive-comments")
const review = root.locator('[data-component="session-review"]')
const first = review.locator('[data-column-number="1"]')
const last = review.locator('[data-column-number="3"]')
await expect(first).toHaveCount(1)
await expect(last).toHaveCount(1)
await first.dragTo(last)
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on lines 1-3")
})
// Moved from packages/app/e2e/regression/review-line-comment.spec.ts
story("shows a comment button when a diff line is hovered", async ({ mount }) => {
const root = await mount("components-session-review--interactive-comments")
const review = root.locator('[data-component="session-review"]')
const line = review.getByText("export const first = 1", { exact: true })
const comment = review.getByRole("button", { name: "Comment", exact: true, includeHidden: true })
await expect(comment).toHaveCount(1)
await line.hover()
await expect(comment).toBeVisible()
await expect(comment).toHaveCSS("pointer-events", "auto")
await comment.dispatchEvent("click")
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
})
@@ -1,42 +0,0 @@
import { expect, story } from "../../storybook/playwright/story"
// Moved from packages/app/e2e/regression/session-timeline-notices.spec.ts
story("renders the moved location notice in its compact timeline style", async ({ mount, page }) => {
const directory = `/Users/usrnk1/Developer/opencode/${"nested-directory/".repeat(24)}session`
await page.setViewportSize({ width: 480, height: 720 })
const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "location" } })
const notice = timeline.locator('[data-slot="session-timeline-notice"][data-type="location-switched"]')
const label = notice.locator('[data-slot="session-timeline-notice-label"]')
const value = notice.locator('[data-slot="session-timeline-notice-value"]')
const tooltipTrigger = notice.locator('[data-component="tooltip-v2-trigger"]')
await expect(label).toHaveText("Moved to")
await expect(value).toHaveText(directory)
await expect(notice).not.toContainText("·")
await expect(notice.locator("svg")).toHaveCount(0)
await expect(notice).toHaveCSS("height", "28px")
await expect(notice).toHaveCSS("gap", "8px")
await expect(notice).toHaveCSS("padding-top", "4px")
await expect(notice).toHaveCSS("padding-bottom", "4px")
await expect(label).toHaveCSS("font-size", "13px")
await expect(label).toHaveCSS("font-weight", "530")
await expect(label).toHaveCSS("line-height", "16px")
await expect(label).toHaveCSS("color", "rgb(128, 128, 128)")
await expect(value).toHaveCSS("font-size", "13px")
await expect(value).toHaveCSS("font-weight", "440")
await expect(value).toHaveCSS("line-height", "16px")
await expect(value).toHaveCSS("color", "rgb(128, 128, 128)")
await expect(value).toHaveCSS("text-overflow", "ellipsis")
await expect(value).toHaveCSS("white-space", "nowrap")
await expect(value).toHaveAttribute("dir", "ltr")
await expect.poll(() => value.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true)
const tooltip = page.getByText("Session working directory changed", { exact: true })
await label.hover()
await expect(tooltip).toBeVisible()
await page.mouse.move(0, 0)
await expect(tooltip).toBeHidden()
await tooltipTrigger.focus()
await expect(tooltipTrigger).toBeFocused()
await expect(tooltip).toBeVisible()
})
@@ -1,74 +0,0 @@
import { expect, story } from "../../storybook/playwright/story"
const profiles = [
{ name: "summaries off no reasoning", summaries: false, reasoning: "none", tool: false, thinking: true, body: false },
{
name: "summaries off reasoning heading",
summaries: false,
reasoning: "heading",
tool: false,
thinking: true,
body: false,
heading: true,
},
{
name: "summaries off with visible tool",
summaries: false,
reasoning: "heading",
tool: true,
thinking: true,
body: false,
heading: true,
},
{ name: "summaries on no content", summaries: true, reasoning: "none", tool: false, thinking: true, body: false },
{
name: "summaries on blank reasoning",
summaries: true,
reasoning: "blank",
tool: false,
thinking: true,
body: false,
},
{
name: "summaries on visible reasoning",
summaries: true,
reasoning: "heading",
tool: false,
thinking: false,
body: true,
},
{
name: "summaries on visible tool no reasoning",
summaries: true,
reasoning: "none",
tool: true,
thinking: false,
body: false,
},
] as const
for (const profile of profiles) {
// Moved from packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts
story(`projects busy reasoning profile ${profile.name}`, async ({ mount }) => {
const timeline = await mount("current-session-timeline-rows--conversation", {
args: { scenario: "reasoning", summaries: profile.summaries, reasoning: profile.reasoning, tool: profile.tool },
})
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(profile.thinking ? 1 : 0)
await expect(timeline.locator('[data-timeline-part-id="msg_projection_assistant:reasoning:0"]')).toHaveCount(
profile.body ? 1 : 0,
)
if ("heading" in profile) {
await expect(timeline.getByText("Inspecting stability", { exact: true })).toBeVisible()
}
})
}
// Moved from packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts
story("does not infer reasoning visibility from provider identity", async ({ mount }) => {
const timeline = await mount("current-session-timeline-rows--conversation", {
args: { scenario: "reasoning", reasoning: "none", text: "No reasoning payload" },
})
await expect(timeline.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(timeline.locator('[data-timeline-part-id*="reasoning"]')).toHaveCount(0)
await expect(timeline.getByText("No reasoning payload", { exact: true })).toBeVisible()
})
@@ -1,65 +0,0 @@
import { expect, story } from "../../storybook/playwright/story"
story("renders streamed reasoning without starting the app", async ({ mount }) => {
const timeline = await mount("current-session-timeline-rows--streaming-reasoning-and-text")
await expect(timeline.locator('[data-component="session-timeline"]')).toBeVisible()
await expect(timeline.getByText("Checking the current contract", { exact: true })).toBeVisible()
})
// Moved from packages/app/e2e/regression/session-timeline-context-state.spec.ts
story("preserves a collapsed context group through count and status updates", async ({ mount }) => {
const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "exploration" } })
const group = timeline.locator('[data-timeline-part-ids="tool_context_read,tool_context_glob"]')
const trigger = group.locator('[data-slot="collapsible-trigger"]')
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await timeline.getByRole("button", { name: "Complete read" }).click()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await timeline.getByRole("button", { name: "Complete glob" }).click()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
})
// Moved from packages/app/e2e/regression/session-timeline-accessibility.spec.ts
story("space activates a focused timeline button instead of scrolling", async ({ mount, page }) => {
await page.emulateMedia({ reducedMotion: "reduce" })
await page.setViewportSize({ width: 800, height: 240 })
const timeline = await mount("current-session-terminal-work--terminal-commands", { args: { scenario: "collapsed" } })
await expect.poll(() => page.evaluate(() => document.documentElement.scrollHeight - innerHeight)).toBeGreaterThan(0)
const trigger = timeline.getByRole("button", { name: "Used Shell", exact: true })
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await trigger.focus()
const before = await page.evaluate(() => window.scrollY)
await trigger.press("Space")
await expect(trigger).toHaveAttribute("aria-expanded", "true")
expect(await page.evaluate(() => window.scrollY)).toBe(before)
})
// Moved from packages/app/e2e/regression/session-timeline-file-projection.spec.ts
story("renders a completed write through the production file component", async ({ mount }) => {
const timeline = await mount("current-session-file-changes--changing-files", { args: { scenario: "write" } })
await expect(
timeline.locator('[data-timeline-part-id="prt_file_projection_write"] [data-component="write-content"]'),
).toBeVisible()
})
// Moved from packages/app/e2e/regression/session-timeline-file-state.spec.ts
story("keeps patch file disclosures independent", async ({ mount }) => {
const timeline = await mount("current-session-file-changes--changing-files", { args: { scenario: "patch" } })
const wrapper = timeline.locator('[data-timeline-part-id="prt_nested_patch"]')
const modified = wrapper.locator('[data-scope="apply-patch"] [data-type="update"] button')
const added = wrapper.locator('[data-scope="apply-patch"] [data-type="add"] button')
const deleted = wrapper.locator('[data-scope="apply-patch"] [data-type="delete"] button')
await expect(wrapper.locator('[data-scope="apply-patch"] [aria-expanded="false"]')).toHaveCount(3)
await deleted.click()
await expect(deleted).toHaveAttribute("aria-expanded", "true")
await expect(modified).toHaveAttribute("aria-expanded", "false")
await modified.click()
await expect(modified).toHaveAttribute("aria-expanded", "true")
await deleted.click()
await expect(deleted).toHaveAttribute("aria-expanded", "false")
await expect(modified).toHaveAttribute("aria-expanded", "true")
await expect(added).toHaveAttribute("aria-expanded", "false")
await added.click()
await expect(added).toHaveAttribute("aria-expanded", "true")
await expect(modified).toHaveAttribute("aria-expanded", "true")
await expect(deleted).toHaveAttribute("aria-expanded", "false")
})
@@ -1,142 +0,0 @@
import { expect, story } from "../../storybook/playwright/story"
// Moved from packages/app/e2e/regression/session-timeline-projection.spec.ts
story("renders every admitted tool family and hides timeline-only exclusions", async ({ mount }) => {
const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "workflow" } })
const first = timeline.locator(
'[data-timeline-part-ids="tool_family_read,tool_family_glob,tool_family_grep,tool_family_list,tool_family_webfetch,tool_family_websearch,tool_family_subagent,tool_family_shell,tool_family_edit,tool_family_write,tool_family_patch"]',
)
const second = timeline.locator('[data-timeline-part-ids="tool_family_skill,tool_family_custom"]')
await expect(first).toBeVisible()
await expect(second).toBeVisible()
await first.getByRole("button").click()
await second.getByRole("button").click()
for (const id of [
"webfetch",
"websearch",
"subagent",
"shell",
"edit",
"write",
"patch",
"question",
"skill",
"custom",
]) {
await expect(timeline.locator(`[data-timeline-part-id="tool_family_${id}"]`), id).toBeVisible()
}
const patch = timeline.locator('[data-timeline-part-id="tool_family_patch"]')
await expect(patch.getByText("1 file", { exact: true })).toBeVisible()
await expect(patch.getByRole("button", { name: "Patch 1 file", exact: true })).toHaveCount(0)
await expect(patch.getByRole("button")).toHaveCount(1)
await expect(patch.locator('[data-scope="apply-patch"] button')).toHaveAttribute("aria-expanded", "false")
await expect(patch.locator('[data-slot="message-part-title-filename"]')).toHaveCount(0)
await expect(patch.locator('[data-slot="message-part-actions"]')).toHaveCount(0)
const edit = timeline.locator('[data-timeline-part-id="tool_family_edit"]')
await expect(edit).toContainText("Edit")
await expect(timeline.locator('[data-timeline-part-id="tool_family_todo"]')).toHaveCount(0)
})
// Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts
story("renders every tool error outcome without leaking hidden tools", async ({ mount }) => {
const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "failures" } })
const names = ["shell", "edit", "write", "patch", "webfetch", "websearch", "subagent", "skill", "mcp_probe"]
const group = timeline.locator(`[data-timeline-part-ids="${names.map((name) => `tool_error_${name}`).join(",")}"]`)
await expect(group.locator('[data-component="tag"]')).toHaveText(String(names.length))
await group.getByRole("button").click()
await expect(timeline.locator('[data-kind="tool-error-card"]')).toHaveCount(names.length + 1)
const dismissed = timeline.locator('[data-timeline-part-id="tool_error_question_dismissed"]')
await expect(dismissed.getByText(/dismissed/i)).toBeVisible()
await expect(dismissed).toContainText(/dismissed/i)
await expect(timeline.locator('[data-timeline-part-id="tool_error_todo"]')).toHaveCount(0)
for (const name of names) await expect(timeline.locator(`[data-timeline-part-id="tool_error_${name}"]`)).toBeVisible()
})
// Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts
story("transitions shell and question through running error outcomes", async ({ mount }) => {
const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "transition" } })
const shell = timeline.locator('[data-timeline-part-id="tool_transition_shell"]')
const question = timeline.locator('[data-timeline-part-id="tool_transition_question"]')
await expect(shell).toBeVisible()
await expect(question).toHaveCount(0)
await timeline.getByRole("button", { name: "Fail running tools" }).click()
await expect(shell.locator('[data-kind="tool-error-card"]')).toBeVisible()
await expect(shell).toContainText("Command exited 1")
await expect(question).toContainText(/dismissed/i)
})
// Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts
story("labels all web search provider variants", async ({ mount }) => {
const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "providers" } })
await timeline.getByRole("button", { name: "Used Parallel Web Search, Exa Web Search, Web Search" }).click()
const tools = timeline.locator('[data-component="context-tool-group-list"]')
await expect(tools.getByRole("button", { name: /Parallel Web Search/ })).toBeVisible()
await expect(tools.getByRole("button", { name: /Exa Web Search/ })).toBeVisible()
await expect(tools.getByRole("button", { name: /^Web Search/ })).toBeVisible()
})
// Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts
story("labels completed searches with result counts", async ({ mount }) => {
const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "results" } })
const group = timeline.locator('[data-timeline-part-ids="tool_label_glob,tool_label_grep,tool_label_read"]')
await group.locator('[data-slot="collapsible-trigger"]').click()
const rows = group.locator('[data-component="context-tool-group-list"] [data-component="tool-trigger"]')
await expect(rows.filter({ hasText: "Glob" })).toContainText("(1 match)")
await expect(rows.filter({ hasText: "Grep" })).toContainText("(12 matches)")
})
// Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts
story("labels read tools from their path input", async ({ mount }) => {
const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "read" } })
const group = timeline.locator('[data-timeline-part-ids="prt_read_path"]')
await group.locator('[data-slot="collapsible-trigger"]').click()
await expect(
group
.locator('[data-component="context-tool-group-list"] [data-component="tool-trigger"]')
.filter({ hasText: "Read" }),
).toContainText("a.ts")
})
// Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts
story("labels skill tools from IDs and result metadata", async ({ mount }) => {
const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "skills" } })
const group = timeline.locator('[data-timeline-part-ids="tool_skill_id,tool_skill_name"]')
await expect(group.getByRole("button")).toHaveAccessibleName("Used Skill")
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
await group.getByRole("button").click()
const loaded = group.locator('[data-component="tool-loaded-item"]')
await expect(loaded).toHaveCount(1)
await expect(loaded).toHaveAttribute("aria-label", "Loaded frontend-design, OpenCode skills")
await expect(loaded).toHaveCSS("line-height", "16px")
await expect(loaded.locator('[data-slot="tool-loaded-label"]')).toHaveText("Loaded")
await expect(loaded.locator('[data-slot="tool-loaded-kind"]')).toHaveText("skills")
const names = loaded.locator('[data-component="text-shimmer"]')
await expect(names).toHaveCount(2)
await expect(names.nth(0)).toHaveAttribute("aria-label", "frontend-design")
await expect(names.nth(1)).toHaveAttribute("aria-label", "OpenCode")
})
// Moved from packages/app/e2e/regression/session-timeline-reducer-projection.spec.ts
story("groups every collapsed tool until visible text separates the stack", async ({ mount }) => {
const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "steps" } })
await expect(timeline.locator('[data-timeline-part-ids="tool_boundary_read"]')).toBeVisible()
const group = timeline.locator(
'[data-timeline-part-ids="tool_boundary_glob,tool_boundary_grep,tool_boundary_shell,tool_boundary_list"]',
)
await expect(group).toBeVisible()
await expect(group.getByRole("button")).toHaveAccessibleName("Used Glob, Grep, Shell, List")
await expect(group.locator('[data-component="tag"]')).toHaveText("4")
await expect(timeline.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(3)
await expect(timeline.locator('[data-timeline-spacing="content"]')).toHaveCount(2)
await expect(timeline.locator('[data-timeline-spacing="content"]').nth(0)).toHaveCSS("padding-top", "16px")
})
// Moved from packages/app/e2e/regression/session-timeline-projection.spec.ts
story("combines adjacent edit calls and repeated files into one group", async ({ mount }) => {
const timeline = await mount("current-session-file-changes--changing-files", { args: { scenario: "repeated" } })
const group = timeline.locator('[data-timeline-part-ids="tool_grouped_edit_first,tool_grouped_edit_second"]')
await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toContainText("Edit")
await expect(group.getByText("1 file", { exact: true })).toBeVisible()
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["first.ts"])
await expect(group.locator('[data-scope="apply-patch"] button')).toHaveAttribute("aria-expanded", "true")
})
+1 -4
View File
@@ -47,13 +47,10 @@
"scripts": {
"generate:progress-indicator": "bun script/generate-session-progress-indicator.ts",
"typecheck": "tsgo -b",
"test": "bun test src --only-failures",
"test:components": "playwright test --config playwright.components.config.ts",
"test:components:ui": "playwright test --config playwright.components.config.ts --ui"
"test": "bun test src --only-failures"
},
"devDependencies": {
"@happy-dom/global-registrator": "20.0.11",
"@playwright/test": "catalog:",
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@types/luxon": "catalog:",
@@ -1,4 +0,0 @@
import { fileURLToPath } from "node:url"
import { componentConfig } from "../storybook/playwright/config"
export default componentConfig(fileURLToPath(new URL(".", import.meta.url)))
@@ -1,7 +1,6 @@
import { createStore } from "solid-js/store"
import { CurrentSessionProviders } from "../storybook/current-session-story"
import { editThenTestDocument, reviewDiffs } from "../storybook/current-session-fixtures"
import { SessionReview, type SessionReviewComment } from "./session-review"
import { SessionReview } from "./session-review"
function ReviewStory(props: { split?: boolean }) {
return (
@@ -45,35 +44,3 @@ export const UnifiedDark = {
globals: { theme: "dark" },
render: () => <ReviewStory />,
}
function InteractiveCommentsStory() {
const [state, setState] = createStore({ comments: [] as SessionReviewComment[] })
const file = "src/review.ts"
const diffs = [
{
file,
additions: 1,
deletions: 1,
status: "modified" as const,
patch:
"diff --git a/src/review.ts b/src/review.ts\n--- a/src/review.ts\n+++ b/src/review.ts\n@@ -1,3 +1,3 @@\n export const first = 1\n-export const value = 'before'\n+export const value = 'after'\n export const last = 3\n",
},
]
return (
<CurrentSessionProviders document={editThenTestDocument}>
<div class="mx-auto h-screen min-h-[620px] w-full max-w-[900px] overflow-hidden bg-background-base">
<SessionReview
title="Changes in this Session"
diffs={diffs}
open={[file]}
comments={state.comments}
onLineComment={(comment) =>
setState("comments", (comments) => [...comments, { id: `comment-${comments.length + 1}`, ...comment }])
}
/>
</div>
</CurrentSessionProviders>
)
}
export const InteractiveComments = { render: () => <InteractiveCommentsStory /> }
@@ -1,75 +0,0 @@
import type { JsonValue, SessionMessageAssistant, SessionMessageAssistantTool } from "@opencode-ai/client/promise"
import type { SessionDocument } from "../document"
import { CURRENT_SESSION_ID, STORY_MODEL, STORY_TIME, thinkingDocument } from "./current-session-fixtures"
export function storyTool(
id: string,
name: string,
status: "streaming" | "running" | "completed" | "error",
input: Record<string, JsonValue>,
options: { metadata?: Record<string, JsonValue>; output?: string; error?: string; raw?: string } = {},
): SessionMessageAssistantTool {
const state =
status === "streaming"
? { status, input: options.raw ?? JSON.stringify(input) }
: status === "running"
? { status, input, metadata: { ...options.metadata, ...(options.output ? { output: options.output } : {}) } }
: status === "error"
? {
status,
input,
error: { type: "ToolExecutionError", message: options.error ?? `${name} failed visibly` },
metadata: options.metadata,
}
: {
status,
input,
content: [{ type: "text" as const, text: options.output ?? "Complete" }] as [
{ type: "text"; text: string },
],
metadata: options.metadata,
}
return {
type: "tool",
id,
name,
state,
time: {
created: STORY_TIME,
...(status === "streaming" ? {} : { ran: STORY_TIME + 100 }),
...(status === "completed" || status === "error" ? { completed: STORY_TIME + 200 } : {}),
},
}
}
export function storyDocument(content: SessionMessageAssistant["content"], busy = false): SessionDocument {
return {
sessionID: CURRENT_SESSION_ID,
messages: [
...thinkingDocument.messages,
{
id: "msg_tool_projection_assistant",
type: "assistant",
agent: "build",
model: STORY_MODEL,
content,
time: { created: STORY_TIME, ...(busy ? {} : { completed: STORY_TIME + 300 }) },
},
],
status: { type: busy ? "busy" : "idle" },
diffs: [],
}
}
export function storyPatchFile(file: string, status: "modified" | "added" = "modified") {
return {
file,
status,
patch:
status === "added"
? "@@ -0,0 +1 @@\n+export const after = true"
: "@@ -1 +1 @@\n-export const before = true\n+export const after = true",
additions: 1,
deletions: status === "added" ? 0 : 1,
}
}
@@ -21,19 +21,8 @@ export function CurrentSessionProviders(props: { document: SessionDocument; chil
{ name: "test", color: "green" },
],
provider: {
all: new Map<string, { models: Record<string, { name: string }> }>([
["anthropic", { models: { "claude-sonnet-4": { name: "Claude Sonnet 4" } } }],
[
"company-gateway",
{
models: {
"fast-nano": { name: "GPT-5.4 nano" },
"long-context": { name: "Company Gateway Extra Long Context Model for Narrow Timeline Layouts" },
},
},
],
]),
connected: ["anthropic", "company-gateway"],
all: new Map([["anthropic", { models: { "claude-sonnet-4": { name: "Claude Sonnet 4" } } }]]),
connected: ["anthropic"],
default: { anthropic: "claude-sonnet-4" },
},
session: [
@@ -1,7 +1,4 @@
import { createTwoFilesPatch } from "diff"
import { createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { CurrentSessionProviders, CurrentSessionTimelineStory } from "../storybook/current-session-story"
import { CurrentSessionTimelineStory } from "../storybook/current-session-story"
import {
editThenTestDocument,
fileChangeLoadingDocument,
@@ -9,7 +6,6 @@ import {
multiFilePatchDocument,
writeFileDocument,
} from "../storybook/current-session-fixtures"
import { storyDocument, storyPatchFile, storyTool } from "../storybook/current-session-scenarios"
import { SessionTimeline } from "./session-timeline"
export default {
@@ -74,137 +70,6 @@ export const PatchedTwoFiles = {
),
}
const RepeatedEdits = {
render: () => (
<CurrentSessionTimelineStory
title="Repeated edits of one file"
description="Consecutive improvements to the same source file remain together in one expanded change."
document={storyDocument([
storyTool(
"tool_grouped_edit_first",
"edit",
"completed",
{ path: "src/first.ts", oldString: "one", newString: "two" },
{
metadata: { files: [storyPatchFile("src/first.ts")] },
},
),
storyTool(
"tool_grouped_edit_second",
"edit",
"completed",
{ path: "src/first.ts", oldString: "two", newString: "three" },
{
metadata: { files: [storyPatchFile("src/first.ts")] },
},
),
])}
editToolDefaultOpen
/>
),
}
function EditSiblingUpdateStory() {
const [state, setState] = createStore({ sibling: false })
const document = createMemo(() => ({
...editThenTestDocument,
status: { type: "busy" as const },
messages: editThenTestDocument.messages
.filter((message) => message.id === "msg_user_edit" || message.id === "msg_assistant_edit")
.map((message) => {
if (message.type !== "assistant") return message
return {
...message,
time: { created: message.time.created },
content: [
...message.content,
...(state.sibling ? [{ type: "text" as const, text: "Streaming added a later assistant text part." }] : []),
],
}
}),
}))
return (
<section class="mx-auto flex w-full max-w-[860px] flex-col gap-4 p-6">
<button type="button" onClick={() => setState("sibling", true)}>
Stream sibling content
</button>
<CurrentSessionProviders document={document()}>
<SessionTimeline document={document()} editToolDefaultOpen />
</CurrentSessionProviders>
</section>
)
}
const EditWithStreamedSibling = { render: () => <EditSiblingUpdateStory /> }
const ThreeFilePatch = {
render: () => {
const source = (changed: boolean) =>
Array.from({ length: 12 }, (_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`).join("")
const files = [
{ file: "src/a.ts", status: "modified" },
{ file: "src/b.ts", status: "added" },
{ file: "src/old.ts", status: "deleted" },
].map(({ file, status }) => ({
file,
status,
patch: createTwoFilesPatch(
`a/${file}`,
`b/${file}`,
status === "added" ? "" : source(false),
status === "deleted" ? "" : source(true),
),
additions: status === "deleted" ? 0 : 4,
deletions: status === "added" ? 0 : 3,
}))
return (
<CurrentSessionTimelineStory
title="Update, create, and remove files"
description="Each changed file can be opened and closed independently."
document={storyDocument([
storyTool(
"prt_nested_patch",
"patch",
"completed",
{ patchText: "Update three files" },
{ metadata: { files } },
),
])}
editToolDefaultOpen
/>
)
},
}
const WrittenSource = {
render: () => (
<CurrentSessionTimelineStory
title="Create a source file"
description="A completed TypeScript write renders its generated source."
document={storyDocument([
storyTool("prt_file_projection_write", "write", "completed", {
path: "src/write.ts",
content: "export const written = true\n",
}),
])}
editToolDefaultOpen
/>
),
}
const fileScenarios = {
repeated: RepeatedEdits,
streaming: EditWithStreamedSibling,
patch: ThreeFilePatch,
write: WrittenSource,
}
export const ChangingFiles = {
args: { scenario: "streaming" },
argTypes: { scenario: { control: "select", options: Object.keys(fileScenarios) } },
render: (args: { scenario: string }) => fileScenarios[args.scenario as keyof typeof fileScenarios].render(),
}
export const CreatedANewFile = {
render: () => (
<CurrentSessionTimelineStory
@@ -1,19 +1,10 @@
import type { JsonValue, SessionMessageAssistant, SessionMessageAssistantTool } from "@opencode-ai/client/promise"
import { createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import type { SessionDocument } from "../document"
import { CurrentSessionProviders, CurrentSessionTimelineStory } from "../storybook/current-session-story"
import { CurrentSessionTimelineStory } from "../storybook/current-session-story"
import {
CURRENT_SESSION_ID,
STORY_MODEL,
STORY_TIME,
inspectAndExplainDocument,
loadedResourcesDocument,
subagentDocument,
thinkingDocument,
webResearchDocument,
} from "../storybook/current-session-fixtures"
import { storyDocument, storyPatchFile, storyTool } from "../storybook/current-session-scenarios"
import { SessionTimeline } from "./session-timeline"
export default {
@@ -74,318 +65,3 @@ export const DelegateFocusedTasks = {
/>
),
}
function CodebaseExplorationStory() {
const [state, setState] = createStore({ read: false, glob: false })
const tool = (name: "read" | "glob", completed: boolean) => {
const input = name === "read" ? { path: "src/a.ts", offset: 0, limit: 120 } : { path: ".", pattern: "**/*.ts" }
return {
type: "tool",
id: `tool_context_${name}`,
name,
state: completed
? { status: "completed", input, content: [{ type: "text", text: "Complete" }], metadata: {} }
: { status: "running", input, metadata: {} },
time: {
created: STORY_TIME,
ran: STORY_TIME + 100,
...(completed ? { completed: STORY_TIME + 200 } : {}),
},
} satisfies SessionMessageAssistantTool
}
const document = createMemo(
() =>
({
sessionID: CURRENT_SESSION_ID,
messages: [
...thinkingDocument.messages,
{
id: "msg_codebase_exploration_assistant",
type: "assistant",
agent: "build",
model: STORY_MODEL,
content: [tool("read", state.read), tool("glob", state.glob)],
time: { created: STORY_TIME, ...(state.read && state.glob ? { completed: STORY_TIME + 300 } : {}) },
} satisfies SessionMessageAssistant,
],
status: { type: state.read && state.glob ? "idle" : "busy" },
diffs: [],
}) satisfies SessionDocument,
)
return (
<section class="mx-auto flex w-full max-w-[720px] flex-col gap-4 p-6">
<div class="flex gap-2">
<button type="button" onClick={() => setState("read", true)}>
Complete read
</button>
<button type="button" onClick={() => setState("glob", true)}>
Complete glob
</button>
</div>
<CurrentSessionProviders document={document()}>
<SessionTimeline document={document()} />
</CurrentSessionProviders>
</section>
)
}
const ExploreTheCodebase = { render: () => <CodebaseExplorationStory /> }
const CompareSearchProviders = {
render: () => (
<CurrentSessionTimelineStory
title="Compare web search providers"
description="Search results identify Parallel, Exa, and the generic provider clearly."
document={storyDocument([
storyTool(
"tool_search_parallel",
"websearch",
"completed",
{ query: "parallel" },
{ metadata: { provider: "parallel" } },
),
storyTool("tool_search_exa", "websearch", "completed", { query: "exa" }, { metadata: { provider: "exa" } }),
storyTool("tool_search_generic", "websearch", "completed", { query: "generic" }),
])}
/>
),
}
const SearchResultsAndFiles = {
render: () => (
<CurrentSessionTimelineStory
title="Search results and opened files"
description="Grouped file searches show their match counts and the filename that was inspected."
document={storyDocument([
storyTool(
"tool_label_glob",
"glob",
"completed",
{ path: ".", pattern: "**/*.ts" },
{ metadata: { count: 1 } },
),
storyTool(
"tool_label_grep",
"grep",
"completed",
{ path: ".", pattern: "value" },
{ metadata: { matches: 12 } },
),
storyTool("tool_label_read", "read", "completed", { path: "src/a.ts" }),
])}
/>
),
}
const ReadOneFile = {
render: () => (
<CurrentSessionTimelineStory
title="Read one source file"
description="A single read opens its own file label without neighboring tool calls."
document={storyDocument([storyTool("prt_read_path", "read", "completed", { path: "src/a.ts" })])}
/>
),
}
const LoadingSpecializedSkills = {
render: () => (
<CurrentSessionTimelineStory
title="Loading specialized skills"
description="Active and completed skills display their identifier or resolved name."
document={storyDocument([
storyTool("tool_skill_id", "skill", "running", { id: "frontend-design" }),
storyTool("tool_skill_name", "skill", "completed", { id: "opencode" }, { metadata: { name: "OpenCode" } }),
])}
/>
),
}
const ResearchAcrossSteps = {
render: () => (
<CurrentSessionTimelineStory
title="Research across separate steps"
description="An explanation and command naturally separate independent file-investigation groups."
document={storyDocument([
storyTool("tool_boundary_read", "read", "completed", { path: "src/a.ts" }),
{ type: "text", text: "Boundary text" },
storyTool("tool_boundary_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }),
storyTool("tool_boundary_grep", "grep", "completed", { path: ".", pattern: "stable" }),
storyTool("tool_boundary_shell", "shell", "completed", { command: "printf done" }, { output: "done" }),
storyTool("tool_boundary_list", "list", "completed", { path: "src" }),
])}
/>
),
}
const questions = { questions: [{ header: "Stability", question: "Keep it stable?", options: [] }] }
const CompleteAgentWorkflow = {
render: () => (
<CurrentSessionTimelineStory
title="Complete agent workflow"
description="A realistic investigation combines file research, web sources, delegation, commands, edits, and specialist guidance."
document={storyDocument([
storyTool("tool_family_read", "read", "completed", { path: "src/a.ts" }),
storyTool("tool_family_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }),
storyTool("tool_family_grep", "grep", "completed", { path: ".", pattern: "value" }),
storyTool("tool_family_list", "list", "completed", { path: "src" }),
storyTool("tool_family_webfetch", "webfetch", "completed", { url: "https://example.com" }),
storyTool("tool_family_websearch", "websearch", "completed", { query: "timeline stability" }),
storyTool("tool_family_subagent", "subagent", "completed", {
description: "Inspect timeline",
agent: "explore",
prompt: "Inspect the timeline implementation.",
}),
storyTool("tool_family_shell", "shell", "completed", { command: "printf stable" }, { output: "stable" }),
storyTool(
"tool_family_edit",
"edit",
"completed",
{ path: "src/a.ts", oldString: "before", newString: "after" },
{
metadata: { files: [storyPatchFile("src/a.ts")] },
},
),
storyTool("tool_family_write", "write", "completed", {
path: "src/new.ts",
content: "export const stable = true",
}),
storyTool(
"tool_family_patch",
"patch",
"completed",
{ patchText: "Update the projected files" },
{
metadata: { files: [storyPatchFile("src/a.ts")] },
},
),
storyTool("tool_family_todo", "todowrite", "completed", { todos: [{ content: "Hidden", status: "pending" }] }),
storyTool("tool_family_question", "question", "completed", questions, { metadata: { answers: [["Yes"]] } }),
storyTool("tool_family_skill", "skill", "completed", { name: "stability" }),
storyTool("tool_family_custom", "custom_mcp_tool", "completed", { target: "timeline" }),
])}
width="860px"
/>
),
}
const RecoverFromToolFailures = {
render: () => {
const names = ["shell", "edit", "write", "patch", "webfetch", "websearch", "subagent", "skill", "mcp_probe"]
const input = (name: string): Record<string, JsonValue> => {
if (name === "shell") return { command: "exit 1" }
if (name === "edit" || name === "write") return { path: "src/error.ts", content: "" }
if (name === "patch") return { patchText: "Update src/error.ts" }
if (name === "webfetch") return { url: "https://example.com" }
if (name === "websearch") return { query: "failure" }
if (name === "subagent") return { description: "Fail subagent", agent: "explore", prompt: "Inspect." }
if (name === "skill") return { name: "failure" }
return { target: "failure" }
}
return (
<CurrentSessionTimelineStory
title="Recover from failed work"
description="Commands, edits, searches, and delegated work explain their failures while dismissed questions stay recognizable."
document={storyDocument([
...names.map((name) => storyTool(`tool_error_${name}`, name, "error", input(name))),
storyTool("tool_error_question_dismissed", "question", "error", questions, {
error: "The user dismissed this question",
}),
storyTool("tool_error_question_transport", "question", "error", questions, {
error: "Question transport failed",
}),
storyTool("tool_error_todo", "todowrite", "error", { todos: [] }, { error: "Hidden todo failure" }),
])}
width="860px"
/>
)
},
}
function FailedCommandAndQuestionStory() {
const [state, setState] = createStore({ failed: false })
const document = createMemo(() =>
storyDocument(
[
storyTool(
"tool_transition_shell",
"shell",
state.failed ? "error" : "running",
{ command: "exit 1" },
{
error: "Command exited 1",
},
),
storyTool("tool_transition_question", "question", state.failed ? "error" : "running", questions, {
error: "The user dismissed this question",
}),
],
!state.failed,
),
)
return (
<section class="mx-auto flex w-full max-w-[720px] flex-col gap-4 p-6">
<button type="button" onClick={() => setState("failed", true)}>
Fail running tools
</button>
<CurrentSessionProviders document={document()}>
<SessionTimeline document={document()} shellToolDefaultOpen />
</CurrentSessionProviders>
</section>
)
}
const FailedCommandAndQuestion = { render: () => <FailedCommandAndQuestionStory /> }
const DelegatingAnAgent = {
render: () => (
<CurrentSessionTimelineStory
title="Delegating an agent"
description="The assistant shows its compact delegation status while preparing a focused task."
document={storyDocument([storyTool("tool_notice_delegation", "subagent", "streaming", {}, { raw: "" })], true)}
/>
),
}
const StartingBackgroundWork = {
render: () => (
<CurrentSessionTimelineStory
title="Starting background work"
description="A delegated task keeps its ordinary label until the background request completes."
document={storyDocument(
[
storyTool(
"tool_notice_background",
"subagent",
"running",
{ description: "Inspect code", background: true },
{
metadata: { status: "running" },
},
),
],
true,
)}
/>
),
}
const researchScenarios = {
workflow: CompleteAgentWorkflow,
exploration: ExploreTheCodebase,
providers: CompareSearchProviders,
results: SearchResultsAndFiles,
read: ReadOneFile,
skills: LoadingSpecializedSkills,
steps: ResearchAcrossSteps,
failures: RecoverFromToolFailures,
transition: FailedCommandAndQuestion,
delegation: DelegatingAnAgent,
background: StartingBackgroundWork,
}
export const AgentResearch = {
args: { scenario: "workflow" },
argTypes: { scenario: { control: "select", options: Object.keys(researchScenarios) } },
render: (args: { scenario: string }) => researchScenarios[args.scenario as keyof typeof researchScenarios].render(),
}
@@ -1,7 +1,4 @@
import type { SessionMessageAssistant } from "@opencode-ai/client/promise"
import { createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { CurrentSessionProviders, CurrentSessionTimelineStory } from "../storybook/current-session-story"
import { CurrentSessionTimelineStory } from "../storybook/current-session-story"
import {
executeCodeDocument,
expandedShellDocument,
@@ -12,7 +9,6 @@ import {
terminalPassedDocument,
terminalRunningDocument,
} from "../storybook/current-session-fixtures"
import { storyDocument, storyTool } from "../storybook/current-session-scenarios"
import { SessionTimeline } from "./session-timeline"
export default {
@@ -66,17 +62,6 @@ export const UserCommandCompleted = {
),
}
const CollapsedShell = {
render: () => (
<CurrentSessionTimelineStory
title="Collapsed completed shell"
description="A focused shell disclosure responds to the keyboard without scrolling its containing surface."
document={terminalPassedDocument}
width="720px"
/>
),
}
export const TestsPassed = {
render: () => (
<CurrentSessionTimelineStory
@@ -125,75 +110,6 @@ export const TestFailed = {
),
}
function InteractiveCommandStory(props: { expanded?: boolean; streaming?: boolean }) {
const [state, setState] = createStore({
phase: props.streaming ? "streaming" : "completed",
lines: 3,
sibling: false,
busy: false,
})
const document = createMemo(() => {
const phase = state.phase as "streaming" | "input" | "running" | "completed"
const command = phase === "streaming" ? "" : "printf ready"
const content: SessionMessageAssistant["content"] = [
storyTool("tool_shell_lifecycle", "shell", phase === "input" ? "streaming" : phase, command ? { command } : {}, {
output:
phase === "running"
? "still running"
: Array.from({ length: state.lines }, (_, index) => `line ${index + 1}`).join("\n"),
...(phase === "streaming" ? { raw: "" } : {}),
}),
...(state.sibling ? [{ type: "text" as const, text: "Sibling content" }] : []),
]
return {
...storyDocument(content, phase !== "completed"),
status: { type: phase !== "completed" || state.busy ? ("busy" as const) : ("idle" as const) },
}
})
return (
<section class="mx-auto flex w-full max-w-[720px] flex-col gap-4 p-6">
<div class="flex gap-3">
<button type="button" onClick={() => setState("phase", "input")}>
Complete input
</button>
<button type="button" onClick={() => setState("phase", "running")}>
Run command
</button>
<button type="button" onClick={() => setState("phase", "completed")}>
Complete command
</button>
<button type="button" onClick={() => setState("lines", 6)}>
Update output
</button>
<button type="button" onClick={() => setState("sibling", true)}>
Append sibling
</button>
<button type="button" onClick={() => setState("busy", true)}>
Mark session busy
</button>
<button type="button" onClick={() => setState("busy", false)}>
Mark session idle
</button>
</div>
<CurrentSessionProviders document={document()}>
<SessionTimeline document={document()} shellToolDefaultOpen={props.expanded} />
</CurrentSessionProviders>
</section>
)
}
const RunACommand = {
args: { expanded: false, streaming: false },
render: (args: { expanded: boolean; streaming: boolean }) => <InteractiveCommandStory {...args} />,
}
export const TerminalCommands = {
args: { scenario: "command", expanded: false, streaming: false },
argTypes: { scenario: { control: "select", options: ["command", "collapsed"] } },
render: (args: { scenario: string; expanded: boolean; streaming: boolean }) =>
args.scenario === "collapsed" ? CollapsedShell.render() : RunACommand.render(args),
}
export const FixedAndPassed = {
render: () => (
<CurrentSessionTimelineStory
@@ -1,13 +1,6 @@
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
import { createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import type { SessionDocument } from "../document"
import { SessionTimeline } from "./session-timeline"
import { CurrentSessionProviders, CurrentSessionTimelineStory } from "../storybook/current-session-story"
import { CurrentSessionTimelineStory } from "../storybook/current-session-story"
import {
CURRENT_SESSION_ID,
STORY_MODEL,
STORY_TIME,
attachmentsAndCommentsDocument,
attachmentsAndCommentsPresentation,
compactionCancelledDocument,
@@ -60,181 +53,6 @@ export const StreamingReasoningAndText = {
),
}
function AgentReasoningStory(props: { summaries: boolean; reasoning: string; tool: boolean; text: string }) {
const content = [
...(props.reasoning === "none"
? []
: [
{
type: "reasoning" as const,
text: props.reasoning === "blank" ? " " : "## Inspecting stability",
time: { created: STORY_TIME + 100 },
},
]),
...(props.tool
? [
{
type: "tool" as const,
id: "tool_reasoning_projection_skill",
name: "skill",
state: { status: "running" as const, input: { name: "inspect" }, metadata: {} },
time: { created: STORY_TIME + 200, ran: STORY_TIME + 250 },
},
]
: []),
...(props.text ? [{ type: "text" as const, text: props.text }] : []),
] satisfies SessionMessageAssistant["content"]
const document = {
sessionID: CURRENT_SESSION_ID,
messages: [
...thinkingDocument.messages,
{
id: "msg_projection_assistant",
type: "assistant",
agent: "build",
model: STORY_MODEL,
content,
time: { created: STORY_TIME },
},
],
status: { type: "busy" },
diffs: [],
} satisfies SessionDocument
return (
<section class="mx-auto w-full max-w-[720px] p-6">
<CurrentSessionProviders document={document}>
<SessionTimeline document={document} showReasoningSummaries={props.summaries} />
</CurrentSessionProviders>
</section>
)
}
const AgentReasoning = {
args: { summaries: true, reasoning: "heading", tool: false, text: "" },
argTypes: { reasoning: { control: "select", options: ["none", "blank", "heading"] } },
render: (args: { summaries: boolean; reasoning: string; tool: boolean; text: string }) => (
<AgentReasoningStory {...args} />
),
}
function HiddenReasoningStory() {
const [state, setState] = createStore({ phase: "thinking" })
const document = createMemo(() => {
const finished = state.phase === "idle"
const running = state.phase === "running"
return {
sessionID: CURRENT_SESSION_ID,
messages: [
...thinkingDocument.messages,
{
id: "msg_hidden_reasoning_lifecycle",
type: "assistant",
agent: "build",
model: STORY_MODEL,
content: [
{ type: "reasoning", text: "## Inspecting stability", time: { created: STORY_TIME + 100 } },
...(running || finished
? [
{
type: "tool" as const,
id: "tool_hidden_reasoning_shell",
name: "shell",
state: finished
? {
status: "completed" as const,
input: { command: "printf done" },
content: [{ type: "text" as const, text: "done" }],
metadata: {},
}
: { status: "running" as const, input: { command: "printf done" }, metadata: {} },
time: {
created: STORY_TIME + 200,
ran: STORY_TIME + 250,
...(finished ? { completed: STORY_TIME + 300 } : {}),
},
},
]
: []),
],
time: { created: STORY_TIME, ...(finished ? { completed: STORY_TIME + 400 } : {}) },
},
],
status: { type: finished ? "idle" : "busy" },
diffs: [],
} satisfies SessionDocument
})
return (
<section class="mx-auto flex w-full max-w-[720px] flex-col gap-4 p-6">
<div class="flex gap-3">
<button type="button" onClick={() => setState("phase", "running")}>
Start shell
</button>
<button type="button" onClick={() => setState("phase", "idle")}>
Finish session
</button>
</div>
<CurrentSessionProviders document={document()}>
<SessionTimeline document={document()} showReasoningSummaries={false} />
</CurrentSessionProviders>
</section>
)
}
const WorkingWithoutReasoningDetails = { render: () => <HiddenReasoningStory /> }
function RetryAndRecoverStory() {
const [state, setState] = createStore({ phase: "thinking" })
const document = createMemo(() => {
const retry = state.phase === "retry"
const finished = state.phase === "idle"
return {
sessionID: CURRENT_SESSION_ID,
messages: [
...thinkingDocument.messages,
{
id: "msg_retry_recovery_lifecycle",
type: "assistant",
agent: "build",
model: STORY_MODEL,
content: finished ? [{ type: "text" as const, text: "Recovered response" }] : [],
...(retry
? {
retry: {
attempt: 2,
at: 1_900_000_000_000,
error: { type: "ProviderRateLimitError", message: "Rate limit reached. Retrying with backoff." },
},
}
: {}),
time: { created: STORY_TIME, ...(finished ? { completed: STORY_TIME + 300 } : {}) },
},
],
status: { type: finished ? "idle" : "busy" },
diffs: [],
} satisfies SessionDocument
})
return (
<section class="mx-auto flex w-full max-w-[720px] flex-col gap-4 p-6">
<div class="flex gap-3">
<button type="button" onClick={() => setState("phase", "retry")}>
Retry request
</button>
<button type="button" onClick={() => setState("phase", "thinking")}>
Recover request
</button>
<button type="button" onClick={() => setState("phase", "idle")}>
Finish response
</button>
</div>
<CurrentSessionProviders document={document()}>
<SessionTimeline document={document()} />
</CurrentSessionProviders>
</section>
)
}
const RetryAndRecover = { render: () => <RetryAndRecoverStory /> }
export const ProviderRetry = {
render: () => (
<CurrentSessionTimelineStory
@@ -246,122 +64,6 @@ export const ProviderRetry = {
),
}
const noticeUser = { id: "msg_notice_user", type: "user", text: "Run it", time: { created: STORY_TIME } } as const
const noticeAssistant = {
id: "msg_notice_assistant",
type: "assistant",
agent: "build",
model: STORY_MODEL,
content: [{ type: "text", text: "Working" }],
time: { created: STORY_TIME + 1, completed: STORY_TIME + 2 },
} satisfies SessionMessageInfo
const AgentActivityNotices = {
render: () => (
<CurrentSessionTimelineStory
title="Agent activity and Session notices"
description="Agent changes, delegated work, restarted Sessions, and loaded skills appear in their original order."
document={{
sessionID: CURRENT_SESSION_ID,
status: { type: "idle" },
diffs: [],
messages: [
noticeUser,
{ id: "msg_notice_agent", type: "agent-switched", agent: "explore", time: { created: STORY_TIME + 1 } },
noticeAssistant,
{
id: "msg_notice_subagent",
type: "synthetic",
text: "done",
description: "Search code",
metadata: { source: "subagent", agent: "explore", state: "completed" },
time: { created: STORY_TIME + 3 },
},
{
id: "msg_notice_restart",
type: "synthetic",
text: "continue",
description: "Continuing after restart",
time: { created: STORY_TIME + 4 },
},
{
id: "msg_notice_skill",
type: "skill",
skill: "review",
name: "Review",
text: "instructions",
time: { created: STORY_TIME + 5 },
},
],
}}
width="720px"
/>
),
}
function CompactSessionStory() {
const [state, setState] = createStore({ phase: "running", summary: "", second: false })
const document = createMemo(() => {
const failed = state.phase === "failed"
const completed = state.phase === "completed"
const message = {
id: "msg_notice_compaction",
type: "compaction" as const,
status: failed ? ("failed" as const) : completed ? ("completed" as const) : ("running" as const),
reason: "auto" as const,
...(failed
? {
error: {
type: "compaction.failed",
message: 'Error: {"error":{"type":"ProviderError","message":"The provider rejected the summary."}}',
},
}
: { summary: state.summary, recent: "" }),
time: { created: STORY_TIME + 10 },
}
const cancelled = {
id: "msg_notice_compaction_cancelled",
type: "compaction" as const,
status: "failed" as const,
reason: "manual" as const,
error: { type: "aborted", message: "Cancellation detail should stay hidden." },
time: { created: STORY_TIME + 20 },
}
return {
sessionID: CURRENT_SESSION_ID,
messages: [noticeUser, noticeAssistant, message, ...(state.second ? [cancelled] : [])],
status: { type: completed || failed ? "idle" : "busy" },
diffs: [],
} satisfies SessionDocument
})
return (
<section class="mx-auto flex w-full max-w-[760px] flex-col gap-4 p-6">
<div class="flex flex-wrap gap-3">
<button type="button" onClick={() => setState("summary", "## Checkpoint\n\nStreamed implementation details.")}>
Stream summary
</button>
<button
type="button"
onClick={() => setState({ phase: "completed", summary: "## Checkpoint\n\nFinal implementation details." })}
>
Complete summary
</button>
<button type="button" onClick={() => setState("phase", "failed")}>
Fail compaction
</button>
<button type="button" onClick={() => setState("second", true)}>
Cancel next compaction
</button>
</div>
<CurrentSessionProviders document={document()}>
<SessionTimeline document={document()} />
</CurrentSessionProviders>
</section>
)
}
const CompactSession = { render: () => <CompactSessionStory /> }
export const CompactionInProgress = {
render: () => (
<CurrentSessionTimelineStory
@@ -454,155 +156,6 @@ export const MixedDirectionRtl = {
),
}
const MovedLocation = {
render: () => (
<CurrentSessionTimelineStory
title="Moved Session location"
description="A changed working directory stays compact, truncates, and exposes its tooltip."
document={{
...thinkingDocument,
status: { type: "idle" },
messages: [
...thinkingDocument.messages,
{
id: "msg_story_location",
type: "location-switched",
location: { directory: `/Users/usrnk1/Developer/opencode/${"nested-directory/".repeat(24)}session` },
time: { created: 1_735_689_633_000 },
},
],
}}
width="480px"
/>
),
}
const InterruptedTurn = {
render: () => (
<CurrentSessionTimelineStory
title="Interrupted assistant turn"
description="The interruption divider stays between the original response and its continuation."
document={{
...thinkingDocument,
status: { type: "idle" },
messages: [
...thinkingDocument.messages,
{
id: "msg_story_interrupted_before",
type: "assistant",
agent: "build",
model: { id: "claude-sonnet-4", providerID: "anthropic" },
content: [{ type: "text", text: "Before" }],
error: { type: "MessageAbortedError", message: "Stopped" },
time: { created: 1_735_689_633_000, completed: 1_735_689_634_000 },
},
{
id: "msg_story_interrupted_after",
type: "assistant",
agent: "build",
model: { id: "claude-sonnet-4", providerID: "anthropic" },
content: [{ type: "text", text: "After" }],
time: { created: 1_735_689_635_000, completed: 1_735_689_636_000 },
},
],
}}
width="560px"
/>
),
}
const AliasedModelNotices = {
render: () => (
<CurrentSessionTimelineStory
title="Aliased model notices"
description="Provider display names, model variants, and long-name truncation use the production notice component."
document={{
...thinkingDocument,
status: { type: "idle" },
messages: [
{
id: "msg_story_fast_nano",
type: "model-switched",
model: { providerID: "company-gateway", id: "fast-nano", variant: "xhigh" },
time: { created: 1_735_689_590_000 },
},
{
id: "msg_story_long_context",
type: "model-switched",
model: { providerID: "company-gateway", id: "long-context" },
time: { created: 1_735_689_591_000 },
},
...thinkingDocument.messages,
],
}}
width="420px"
/>
),
}
const RichUserAttachments = {
render: () => (
<CurrentSessionTimelineStory
title="Prompt with rich attachments"
description="An image, JSON attachment, source-file reference, and agent mention remain individually visible."
document={{
...thinkingDocument,
status: { type: "idle" },
messages: [
{
id: "msg_story_rich_user",
type: "user",
text: "Use @explore with @src/a.ts and inspect the attachments",
agents: [{ name: "explore", mention: { text: "@explore", start: 4, end: 12 } }],
files: [
{
data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
mime: "image/png",
name: "pixel.png",
source: { type: "inline" },
},
{ data: "e30=", mime: "application/json", name: "tsconfig.json", source: { type: "inline" } },
{
data: "",
mime: "text/plain",
name: "a.ts",
source: { type: "uri", uri: "src/a.ts" },
mention: { text: "@src/a.ts", start: 18, end: 27 },
},
],
time: { created: 1_735_689_633_000 },
},
],
}}
width="620px"
/>
),
}
const conversationScenarios = {
reasoning: AgentReasoning,
hidden: WorkingWithoutReasoningDetails,
retry: RetryAndRecover,
notices: AgentActivityNotices,
compaction: CompactSession,
location: MovedLocation,
interruption: InterruptedTurn,
models: AliasedModelNotices,
attachments: RichUserAttachments,
}
export const Conversation = {
args: { scenario: "notices", summaries: true, reasoning: "heading", tool: false, text: "" },
argTypes: {
scenario: { control: "select", options: Object.keys(conversationScenarios) },
reasoning: { control: "select", options: ["none", "blank", "heading"] },
},
render: (args: { scenario: string; summaries: boolean; reasoning: string; tool: boolean; text: string }) => {
if (args.scenario === "reasoning") return <AgentReasoningStory {...args} />
return conversationScenarios[args.scenario as Exclude<keyof typeof conversationScenarios, "reasoning">].render()
},
}
export const InstructionsUpdatedSingle = {
render: () => (
<CurrentSessionTimelineStory
+1 -6
View File
@@ -69,7 +69,7 @@ const frame = createJSXDecorator((Story, context) => {
<MetaProvider>
<Font />
<ThemeProvider>
<LanguageProvider locale={typeof context.globals?.locale === "string" ? context.globals.locale : "en"}>
<LanguageProvider locale="en">
<UiI18nBridge>
<Scheme value={scheme} />
<Direction value={context.globals?.direction} />
@@ -109,11 +109,6 @@ export default definePreview({
description: "Interface direction",
defaultValue: "ltr",
},
locale: {
name: "Locale",
description: "Interface language",
defaultValue: "en",
},
},
parameters: {
actions: {
-1
View File
@@ -12,7 +12,6 @@
"@tailwindcss/vite": "catalog:",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@playwright/test": "catalog:",
"@solidjs/meta": "catalog:",
"@storybook/addon-a11y": "10.4.4",
"@storybook/addon-docs": "10.4.4",
-61
View File
@@ -1,61 +0,0 @@
# Component browser tests
Production Solid components are tested through their existing Storybook stories without booting the app, configuring a server, seeding browser storage, or navigating unrelated routes.
Keep each spec in the package that owns its production component:
- `packages/session-ui/component-tests/` owns timeline, tool, notice, reasoning, lifecycle, and review coverage.
- `packages/app/component-tests/` owns Composer and other app-only component coverage.
- `packages/storybook/playwright/` owns the shared Storybook startup configuration and `story` mount fixture.
Run a package's isolated browser suite from that package:
```sh
# Session UI components.
cd packages/session-ui
bun run test:components
bun run test:components -- component-tests/session-timeline.spec.ts
bun run test:components:ui
# App-owned components.
cd packages/app
bun run test:components
bun run test:components -- component-tests/composer.spec.ts
```
Both suites are separately filterable Turbo tasks:
```sh
bun turbo test:components --filter=@opencode-ai/session-ui
bun turbo test:components --filter=@opencode-ai/app
```
Component browser coverage deliberately remains separate from each package's default `test` script and from `packages/app`'s `test:e2e`, so expensive Storybook checks can be scheduled independently from required unit and full-app journey CI. Set `PLAYWRIGHT_STORYBOOK_URL` to reuse an existing Storybook instance or `PLAYWRIGHT_STORYBOOK_PORT` to choose its port.
## Adding a test
Keep inspectable scenarios next to the production component in a `*.stories.tsx` file. A story owns its fixtures, providers, state, and callbacks; its package-local spec owns user-visible interactions and assertions.
```ts
import { expect, story } from "../../storybook/playwright/story"
// Moved from packages/app/e2e/regression/session-timeline-context-state.spec.ts
story("preserves collapsed state while a tool completes", async ({ mount }) => {
const component = await mount("current-session-research-agents--agent-research", {
args: { scenario: "exploration" },
})
const trigger = component.locator('[data-slot="collapsible-trigger"]')
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await component.getByRole("button", { name: "Complete read" }).click()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
})
```
The story ID is the Storybook component ID followed by `--` and the kebab-cased story export. Open the same story in Storybook to inspect exactly the scenario covered by the browser test. Preserve an original-source-path comment for every migrated E2E case.
Keep cross-route navigation, remote-server ownership, persistent session state, full-app virtualization, and workflows spanning independent surfaces in `packages/app/e2e/`.
Component rendering and integration coverage can be complementary. A local story control that installs a completed message does not test event delivery, production reducer cleanup, or a live stream. Keep those original checks in E2E, including stream/chunk identity, compaction and retry events, independent lifecycle transitions, and the real app scroll owner. A provenance comment records the source of a component assertion; it is not evidence that its integration counterpart can be deleted.
When moving an assertion, preserve its discriminating fixture: file status kinds, empty/single-variant inputs, singleton groups, live message state, and the order of intermediate updates. Verify the actual scroll container overflows before asserting that keyboard activation does not scroll it.
-31
View File
@@ -1,31 +0,0 @@
import { defineConfig, devices } from "@playwright/test"
const port = Number(process.env.PLAYWRIGHT_STORYBOOK_PORT ?? 6006)
const baseURL = process.env.PLAYWRIGHT_STORYBOOK_URL ?? `http://127.0.0.1:${port}`
export function componentConfig(directory: string) {
return defineConfig({
testDir: `${directory}/component-tests`,
outputDir: `${directory}/component-tests/test-results`,
timeout: 60_000,
expect: { timeout: 10_000 },
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 2 : undefined,
reporter: [["html", { outputFolder: `${directory}/component-tests/playwright-report`, open: "never" }], ["line"]],
webServer: {
command: `bun --bun run --cwd ${directory}/../storybook storybook -- --port ${port} --ci --no-open`,
url: baseURL,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
use: {
baseURL,
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
projects: [{ name: "components", use: { ...devices["Desktop Chrome"] } }],
})
}
-37
View File
@@ -1,37 +0,0 @@
import { expect, test } from "@playwright/test"
import type { Locator } from "@playwright/test"
export { expect }
export const story = test.extend<{
mount: (
id: string,
options?: { args?: Record<string, string | boolean>; globals?: Record<string, string> },
) => Promise<Locator>
}>({
mount: async ({ page }, use) => {
await use(async (id, options) => {
const query = new URLSearchParams({ id, viewMode: "story" })
if (options?.args) {
query.set(
"args",
Object.entries(options.args)
.map(([key, value]) => `${key}:${value}`)
.join(";"),
)
}
if (options?.globals) {
query.set(
"globals",
Object.entries(options.globals)
.map(([key, value]) => `${key}:${value}`)
.join(";"),
)
}
await page.goto(`/iframe.html?${query}`)
const root = page.locator("#storybook-root")
await expect(root).toBeVisible({ timeout: 30_000 })
return root
})
},
})
+1 -1
View File
@@ -12,5 +12,5 @@
"strict": true,
"types": ["vite/client", "node"]
},
"include": [".storybook/**/*.ts", ".storybook/**/*.tsx", "playwright/**/*.ts"]
"include": [".storybook/**/*.ts", ".storybook/**/*.tsx"]
}
+4 -2
View File
@@ -38,6 +38,7 @@ import {
TuiStartupProvider,
TuiTerminalEnvironmentProvider,
useTuiApp,
useTuiPaths,
useTuiStartup,
useTuiTerminalEnvironment,
type TuiApp,
@@ -374,7 +375,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
>
<ClientProvider api={api} url={input.server.endpoint.url} service={service}>
<PermissionProvider>
<DataProvider directory={directory}>
<DataProvider>
<LocationProvider>
<SessionTabsProvider>
<SessionTerminalsProvider>
@@ -459,6 +460,7 @@ function App(props: { pair?: DialogPairCredentials }) {
const log = useLog({ component: "app" })
const app = useTuiApp()
const startup = useTuiStartup()
const paths = useTuiPaths()
const config = useConfig()
const devtools = createMemo(() => config.data.debug?.devtools ?? app.channel === "local")
const route = useRoute()
@@ -725,7 +727,7 @@ function App(props: { pair?: DialogPairCredentials }) {
type: "home",
location: newSessionLocation(
config.data.session.new_location,
data.location.default().directory,
paths.cwd,
current,
location.error?.location,
),
+2 -2
View File
@@ -8,13 +8,13 @@ export type { FormWithLocation } from "@opencode-ai/client/solid"
export const { use: useData, provider: DataProvider } = createSimpleContext({
name: "Data",
init: (props: { directory: string }) => {
init: () => {
const client = useClient()
const data = createData({
api: () => client.api,
event: client.event,
connection: client.connection,
directory: props.directory,
directory: process.cwd(),
})
data satisfies Plugin.Context["data"]
return data
+1 -1
View File
@@ -417,7 +417,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
type: "home",
location: newSessionLocation(
config.session.new_location,
data.location.default().directory,
paths.cwd,
currentLocation,
location.error?.location,
),
+1 -2
View File
@@ -360,8 +360,7 @@ export function Session(props: {
createEffect(() => {
if (restored || !synced() || !rowsSynced() || !scroll || scroll.isDestroyed) return
restored = true
// Initial synchronization can finish after the reader has already navigated.
if (!isAwayFromBottom()) restoreScrollPosition()
restoreScrollPosition()
})
let awayTimer: ReturnType<typeof setTimeout> | undefined
onCleanup(() => {
-100
View File
@@ -6,7 +6,6 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/util/global"
import path from "node:path"
import { createEventStream, createFetch, directory, json } from "./fixture/tui-client"
import { tmpdir } from "./fixture/fixture"
test("SIGHUP clears title and disposes scoped resources once", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
@@ -298,104 +297,6 @@ test("session startup prompt is submitted exactly once", async () => {
}
})
test.each([false, true])("uses the resolved launch directory for new prompts (fallback: %s)", async (fallback) => {
await using state = await tmpdir()
const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true })
setup.renderer.start()
const target = fallback ? directory : process.cwd()
const location = { directory: target, project: { id: "project", directory: target, canonical: target } }
const requests: URL[] = []
const created = Promise.withResolvers<unknown>()
const submitted = Promise.withResolvers<unknown>()
const ready = Promise.withResolvers<void>()
const events = createEventStream()
let session: unknown
const calls = createFetch(async (url, request) => {
requests.push(url)
if (url.searchParams.has("location[directory]") && url.searchParams.get("location[directory]") !== target)
return json({ message: "Directory does not exist on the server" }, { status: 500 })
if (url.pathname === "/api/fs/list") return json({ location, data: [] })
if (url.pathname === "/api/location") return json(location)
if (url.pathname === "/api/agent")
return json({ location, data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }] })
if (url.pathname === "/api/model")
return json({ location, data: [{ id: "model", providerID: "provider", name: "Remote Model", variants: [] }] })
if (url.pathname === "/api/provider") return json({ location, data: [{ id: "provider", name: "Provider" }] })
if (url.pathname === "/api/session" && request.method === "POST") {
const input: unknown = await request.json()
if (typeof input !== "object" || input === null) throw new Error("Expected a session input")
created.resolve(input)
session = {
...input,
projectID: "project",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}
return json({ data: session })
}
if (/^\/api\/session\/[^/]+\/prompt$/.test(url.pathname)) {
submitted.resolve(await request.json())
return json({ data: {} })
}
if (/^\/api\/session\/[^/]+\/(message|inbox|permission)$/.test(url.pathname)) return json({ data: [], cursor: {} })
if (session && /^\/api\/session\/[^/]+$/.test(url.pathname)) return json({ data: session })
return undefined
}, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
try {
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: {
get: async () => ({ animations: false, tabs: { enabled: false }, keybinds: { "session.new": "f6" } }),
update: async () => ({}),
},
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: {},
log: () => {},
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
)
await ready.promise
await setup.waitForFrame((frame) => frame.includes("Build · Remote Model Provider"))
setup.mockInput.pressKey("F6")
await setup.renderOnce()
await setup.mockInput.typeText("REMOTE_READY")
await setup.waitForFrame((frame) => frame.includes("REMOTE_READY"))
setup.mockInput.pressEnter()
expect(
await Promise.race([
submitted.promise,
Bun.sleep(2_000).then(() => {
throw new Error("prompt was not submitted in the resolved server directory")
}),
]),
).toMatchObject({ text: "REMOTE_READY" })
expect(await created.promise).toMatchObject({ location: { directory: target } })
expect(requests[0]?.pathname).toBe("/api/fs/list")
expect(requests[0]?.searchParams.get("location[directory]")).toBe(process.cwd())
expect(
requests.filter((url) => url.pathname === "/api/location" && !url.searchParams.has("location[directory]")),
).toHaveLength(fallback ? 1 : 0)
expect(
requests
.slice(1)
.filter((url) => url.searchParams.has("location[directory]"))
.every((url) => url.searchParams.get("location[directory]") === target),
).toBe(true)
setup.renderer.destroy()
await task
} finally {
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await server.stop()
}
})
test("error investigations repeatedly seed editable home drafts without creating sessions", async () => {
const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true })
setup.renderer.start()
@@ -569,7 +470,6 @@ test("new session inherits the active session model", async () => {
time: { created: 0, updated: 0 },
}
const calls = createFetch((url) => {
if (url.pathname === "/api/fs/list") return json({ location, data: [] })
if (url.pathname === "/api/location") return json(location)
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
if (url.pathname === "/api/session/dummy") return json({ data: session })
@@ -96,7 +96,7 @@ async function renderComposer(
<ConfigProvider config={createTuiResolvedConfig({ keybinds })}>
<Keymap.Provider>
<ClientProvider api={createApi(calls.fetch)}>
<DataProvider directory={process.cwd()}>
<DataProvider>
<LocationProvider>
<RouteProvider initialRoute={{ type: "session", sessionID: "parent" }}>
<ThemeProvider mode="dark" source={{ discover: async () => ({}) }}>
+1 -1
View File
@@ -45,7 +45,7 @@ const config = createTuiResolvedConfig()
function DataProvider(props: ParentProps) {
return (
<ConfigProvider config={config}>
<DataProviderBase directory={process.cwd()}>
<DataProviderBase>
<LocationProvider>
<SyncLocation />
{props.children}
@@ -304,7 +304,7 @@ async function renderIntegration() {
<Keymap.Provider>
<ToastProvider>
<ClientProvider api={createApi(calls.fetch)}>
<DataProvider directory={process.cwd()}>
<DataProvider>
<LocationProvider>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<DialogProvider>
@@ -142,7 +142,7 @@ async function renderMcp(options?: { failed?: boolean; location?: { directory: s
<ToastProvider>
<RouteProvider initialRoute={{ type: "session", sessionID: "ses_existing" }}>
<ClientProvider api={createApi(calls.fetch)}>
<DataProvider directory={process.cwd()}>
<DataProvider>
<LocationProvider>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<DialogProvider>
@@ -318,7 +318,7 @@ async function renderOpen(
<ToastProvider>
<RouteProvider>
<ClientProvider api={createApi(calls.fetch)}>
<DataProvider directory={process.cwd()}>
<DataProvider>
<LocationProvider>
<SessionTabsProvider>
<ThemeProvider mode="dark" source={emptyThemeSource}>
@@ -90,7 +90,7 @@ test("scopes sessions to the active session location", async () => {
<RouteProvider>
<ClientProvider api={createApi(calls.fetch)}>
<PermissionProvider>
<DataProvider directory={process.cwd()}>
<DataProvider>
<LocationProvider>
<SessionTabsProvider>
<ThemeProvider mode="dark" source={emptyThemeSource}>
+1 -1
View File
@@ -106,7 +106,7 @@ async function mountForm(
<ConfigProvider config={config}>
<Keymap.Provider>
<ClientProvider api={createApi(transport.fetch)}>
<DataProvider directory={process.cwd()}>
<DataProvider>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<ToastProvider>{response ? <CurrentForm /> : <FormPrompt form={form} />}</ToastProvider>
</ThemeProvider>
@@ -39,7 +39,6 @@ async function renderSessionTabs(
sessionTimes?: Record<string, { idle?: number; viewed?: number }>
sessionOutcomes?: Record<string, "succeeded" | "failed" | "interrupted">
newLocation?: "launch" | "inherit"
launchDirectory?: string
tabsEnabled?: boolean
viewFailures?: number
preview?: boolean
@@ -169,7 +168,7 @@ async function renderSessionTabs(
initialRoute={options?.home ? { type: "home" } : { type: "session", sessionID: initialSessionID }}
>
<ClientProvider api={createApi(calls.fetch)}>
<DataProvider directory={options?.launchDirectory ?? directory}>
<DataProvider>
<LocationProvider>
<SessionTabsProvider>
<Probe />
@@ -893,17 +892,13 @@ test("tracks a temporary new session tab across close and creation", async () =>
}
})
test("add opens the new session tab in the resolved server launch directory", async () => {
const launchDirectory = `${directory}/server`
const setup = await renderSessionTabs("first", {
launchDirectory,
sessionDirectories: { first: `${directory}/worktree` },
})
test("add opens the new session tab in the launch directory by default", async () => {
const setup = await renderSessionTabs("first", { sessionDirectories: { first: `${directory}/worktree` } })
try {
await wait(() => setup.tabs.current() === "first" && setup.data.session.get("first") !== undefined)
setup.tabs.add()
expect(setup.route.data).toEqual({ type: "home", location: { directory: launchDirectory } })
expect(setup.route.data).toEqual({ type: "home", location: { directory } })
await wait(() => setup.tabs.newTab())
expect(setup.tabs.tabs().map((tab) => tab.sessionID)).toEqual(["first"])
} finally {
-137
View File
@@ -3950,143 +3950,6 @@
},
"description": "Retrieve one projected message owned by the Session.",
"summary": "Get session message"
},
"patch": {
"tags": ["session"],
"operationId": "v2.session.messageUpdate",
"parameters": [
{
"name": "sessionID",
"in": "path",
"schema": {
"type": "string",
"pattern": "^ses"
},
"required": true
},
{
"name": "messageID",
"in": "path",
"schema": {
"type": "string",
"pattern": "^msg_"
},
"required": true
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/Session.Message.Assistant"
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"404": {
"description": "SessionNotFoundError | MessageNotFoundError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
},
{
"$ref": "#/components/schemas/MessageNotFoundErrorEncoded"
}
]
}
}
}
},
"409": {
"description": "SessionBusyError | ConflictError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/SessionBusyErrorEncoded"
},
{
"$ref": "#/components/schemas/ConflictErrorEncoded"
}
]
}
}
}
}
},
"description": "Replace the content of a completed assistant message in an idle session.",
"summary": "Update assistant message content",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"content": {
"type": "array",
"items": {
"anyOf": [
{
"$ref": "#/components/schemas/Session.Message.Assistant.Text"
},
{
"$ref": "#/components/schemas/Session.Message.Assistant.Reasoning"
},
{
"$ref": "#/components/schemas/Session.Message.Assistant.Tool"
}
]
}
}
},
"required": ["content"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/session/{sessionID}/environment": {
-137
View File
@@ -3950,143 +3950,6 @@
},
"description": "Retrieve one projected message owned by the Session.",
"summary": "Get session message"
},
"patch": {
"tags": ["session"],
"operationId": "v2.session.messageUpdate",
"parameters": [
{
"name": "sessionID",
"in": "path",
"schema": {
"type": "string",
"pattern": "^ses"
},
"required": true
},
{
"name": "messageID",
"in": "path",
"schema": {
"type": "string",
"pattern": "^msg_"
},
"required": true
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/Session.Message.Assistant"
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"404": {
"description": "SessionNotFoundError | MessageNotFoundError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
},
{
"$ref": "#/components/schemas/MessageNotFoundErrorEncoded"
}
]
}
}
}
},
"409": {
"description": "SessionBusyError | ConflictError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/SessionBusyErrorEncoded"
},
{
"$ref": "#/components/schemas/ConflictErrorEncoded"
}
]
}
}
}
}
},
"description": "Replace the content of a completed assistant message in an idle session.",
"summary": "Update assistant message content",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"content": {
"type": "array",
"items": {
"anyOf": [
{
"$ref": "#/components/schemas/Session.Message.Assistant.Text"
},
{
"$ref": "#/components/schemas/Session.Message.Assistant.Reasoning"
},
{
"$ref": "#/components/schemas/Session.Message.Assistant.Tool"
}
]
}
}
},
"required": ["content"],
"additionalProperties": false
}
}
},
"required": true
}
}
},
"/api/session/{sessionID}/environment": {

Some files were not shown because too many files have changed in this diff Show More