Compare commits

...
9 Commits
Author SHA1 Message Date
Aiden Cline 33909f48d7 fix(core): label migrated credentials by auth type (#45369) 2026-08-26 14:41:31 -05:00
James Long 80653a0a1a fix(tui): preserve resolved server directory (#45354) 2026-08-26 15:40:00 -04:00
James Long f7913a04d2 fix(cli): stop PTY daemon on explicit service restart (#45373) 2026-08-26 15:39:29 -04:00
Kit Langton cf347cd5e4 refactor(core): advance sessions before running steps (#45358)
Centralize control dispatch and first-Step preparation in advanceToStep. Keep input delivery outside logical-Step retries and preserve queue ordering, Location handoff, context refresh, and durable settlement.
2026-08-26 15:25:13 -04:00
opencode-agent[bot] 6600d59635 chore: update nix node_modules hashes 2026-08-26 19:07:20 +00:00
Aiden Cline 8b6a2450d5 fix(core): isolate invalid tool registrations (#45325) 2026-08-26 13:49:56 -05:00
opencode-agent[bot]andBrendonovich 1aa4046f02 test(app): isolate component coverage with storybook (#45142)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-27 02:42:27 +08:00
opencode-agent[bot] 9bd69fe847 chore: update nix node_modules hashes 2026-08-26 18:32:55 +00:00
Kit Langton ded8a492d1 fix(core): recover background jobs after restart
Persist background Job ownership and terminal results across server restarts. Resume existing subagent Sessions, admit shell cancellation notices without waking idle parents, and preserve explicit cancellation.
2026-08-26 14:17:43 -04:00
86 changed files with 4526 additions and 1082 deletions
+2
View File
@@ -731,6 +731,7 @@
},
"devDependencies": {
"@happy-dom/global-registrator": "20.0.11",
"@playwright/test": "catalog:",
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@types/luxon": "catalog:",
@@ -836,6 +837,7 @@
"@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-QWLIdvu985FH5I9cZJOAuoeFeXU+4Jx9RzBB9RPoeeQ=",
"aarch64-linux": "sha256-SSzGD5hMj2vFvyw+dUPR9g/ZH6qhs0ZyZ/DnltZt3N8=",
"aarch64-darwin": "sha256-CeFUxiV+e8pKho+YcSclC3soQBogoxNMxwyIMztAExU=",
"x86_64-darwin": "sha256-FYwcACzU72y0+KtOpFfU7ndak8vMasqMgd5NLS6+XtY="
"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="
}
}
+2
View File
@@ -1,3 +1,5 @@
src/assets/theme.css
e2e/test-results
e2e/playwright-report
component-tests/test-results
component-tests/playwright-report
@@ -0,0 +1,26 @@
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,54 +12,6 @@ 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,12 +201,15 @@ 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,16 +1,43 @@
import { expect, test } from "@playwright/test"
import { assistantMessage, setupTimeline, shell, userMessage } from "../performance/timeline-stability/fixture"
import {
assistantMessage,
setupTimeline,
shell,
textPart,
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))])],
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),
),
]),
],
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,21 +8,6 @@ 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, {
@@ -1,53 +0,0 @@
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,6 +122,7 @@ 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)
@@ -129,6 +130,7 @@ 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,6 +122,7 @@ 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,
@@ -140,6 +141,9 @@ 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,
@@ -152,88 +156,7 @@ 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.")
})
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()
await expect(cancelled).not.toContainText("Summary before cancellation.")
})
test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
@@ -271,11 +194,6 @@ 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,86 +7,9 @@ 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"
@@ -158,43 +81,6 @@ 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(
[
@@ -236,25 +122,6 @@ 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"
@@ -291,77 +158,8 @@ 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,33 +7,25 @@ import {
renderedPartID,
setupTimeline,
shell,
toolPart,
status,
textPart,
toolPart,
userMessage,
} from "../performance/timeline-stability/fixture"
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)] })
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-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(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(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 }) => {
@@ -132,18 +124,3 @@ 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,31 +7,6 @@ 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"
@@ -138,62 +113,6 @@ 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, {
@@ -221,36 +140,6 @@ 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" }),
@@ -273,14 +162,3 @@ 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,6 +87,7 @@ 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,7 +127,6 @@ 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)
@@ -139,7 +138,6 @@ 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)
@@ -225,7 +223,6 @@ type MotionProbe = {
terminalAnchorGaps: number[]
resetAnchorOnMotion: boolean
panelGaps: number[]
terminalTops: number[]
terminalBottoms: number[]
heights: string[]
animations: string[]
@@ -243,7 +240,6 @@ async function installMotionProbe(page: Page) {
terminalAnchorGaps: [],
resetAnchorOnMotion: false,
panelGaps: [],
terminalTops: [],
terminalBottoms: [],
heights: [],
animations: [],
@@ -270,7 +266,6 @@ 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,
@@ -446,13 +441,6 @@ 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
@@ -516,17 +504,6 @@ 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", "../src/types.ts"]
"include": ["./**/*.ts", "./**/*.tsx", "../component-tests/**/*.ts", "../src/types.ts"]
}
+2
View File
@@ -26,6 +26,8 @@
"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",
@@ -0,0 +1,4 @@
import { fileURLToPath } from "node:url"
import { componentConfig } from "../storybook/playwright/config"
export default componentConfig(fileURLToPath(new URL(".", import.meta.url)))
+7 -1
View File
@@ -21,6 +21,12 @@
"@/*": ["./src/*"]
}
},
"include": ["src", "package.json"],
"include": [
"src",
"component-tests",
"playwright.components.config.ts",
"../storybook/playwright/*.ts",
"package.json"
],
"exclude": ["dist", "ts-dist"]
}
@@ -4,11 +4,14 @@ 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,6 +514,115 @@ 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,10 +80,11 @@ 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}, 'default', ${JSON.stringify(credential)}, ${now}, ${now})
VALUES (${Credential.ID.create()}, ${integrationID}, ${label}, ${JSON.stringify(credential)}, ${now}, ${now})
`)
}
+171 -98
View File
@@ -1,11 +1,41 @@
export * as Job from "./job.js"
import { Cause, Clock, Context, Deferred, Effect, Exit, Layer, Scope, SynchronizedRef } from "effect"
import { Array, Cause, Clock, Context, Deferred, Effect, Exit, Layer, Schema, 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"
export type Status = "running" | "completed" | "error" | "cancelled"
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 Info = {
id: string
@@ -17,6 +47,7 @@ export type Info = {
output?: string
error?: string
metadata?: Record<string, unknown>
notificationID?: SessionMessage.ID
}
type Active = {
@@ -27,6 +58,7 @@ type Active = {
token: object
blockingSessions: Map<SessionSchema.ID, number>
isBackgrounded: boolean
recovery?: Recovery
}
type State = {
@@ -63,6 +95,8 @@ export type StartInput = {
type: string
title?: string
metadata?: Record<string, unknown>
recovery?: Recovery
notificationID?: SessionMessage.ID
run: Effect.Effect<string, unknown>
}
@@ -96,6 +130,8 @@ 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") {}
@@ -126,43 +162,57 @@ function decrementSession(input: Map<SessionSchema.ID, number>, sessionID: Sessi
}
/**
* 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.
* Makes one scoped, process-local registry. Explicitly recoverable background
* work also owns a durable notification marker until its notification is admitted.
*/
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.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)]
})
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)]
}),
)
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 }))
@@ -170,22 +220,6 @@ 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
@@ -201,10 +235,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.fnUntraced(function* (jobs): Effect.fn.Return<readonly [StartResult, Map<string, Active>]> {
const existing = jobs.get(id)
if (existing?.info.status === "running") {
return [{ info: snapshot(existing) }, jobs] as readonly [StartResult, Map<string, Active>]
return [{ info: snapshot(existing) }, jobs]
}
const scope = yield* Scope.fork(state.scope, "parallel")
const token = {}
@@ -216,6 +250,7 @@ export const make = Effect.gen(function* () {
status: "running" as const,
started_at,
metadata: input.metadata,
...(input.notificationID ? { notificationID: input.notificationID } : {}),
},
done,
backgrounded,
@@ -223,14 +258,18 @@ 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)] as readonly [
StartResult,
Map<string, Active>,
]
return [{ info: snapshot(job), scope, token }, new Map(jobs).set(id, job)]
}),
)
if ("scope" in result) yield* fork(result.scope, id, result.token, restore(input.run))
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 }),
)
return result.info
}),
)
@@ -281,20 +320,31 @@ export const make = Effect.gen(function* () {
).pipe(Effect.ensuring(removeBlock(input)))
})
const background: Interface["background"] = Effect.fn("Job.background")(function* (id) {
const result = yield* SynchronizedRef.modify(
state.jobs,
(jobs): readonly [BackgroundResult, Map<string, Active>] => {
const job = jobs.get(id)
if (!job || job.info.status !== "running") return [{}, jobs]
if (job.isBackgrounded) return [{ info: snapshot(job) }, jobs]
const next = {
...job,
isBackgrounded: true,
blockingSessions: new Map<SessionSchema.ID, number>(),
}
return [{ info: snapshot(next), backgrounded: job.backgrounded }, new Map(jobs).set(id, next)]
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(
state.jobs,
Effect.fnUntraced(function* (jobs): Effect.fn.Return<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.isBackgrounded) return [{ info: snapshot(job) }, jobs]
const next = yield* markBackground(job)
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)
@@ -302,60 +352,83 @@ export const make = Effect.gen(function* () {
})
const backgroundAll: Interface["backgroundAll"] = Effect.fn("Job.backgroundAll")(function* (input) {
const result = yield* SynchronizedRef.modify(
const result = yield* SynchronizedRef.modifyEffect(
state.jobs,
(jobs): readonly [BackgroundResult[], Map<string, Active>] => {
const results: BackgroundResult[] = []
Effect.fnUntraced(function* (jobs): Effect.fn.Return<
readonly [Required<BackgroundResult>[], Map<string, Active>]
> {
const results: Required<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 = {
...job,
isBackgrounded: true,
blockingSessions: new Map<SessionSchema.ID, number>(),
}
const updated = yield* markBackground(job)
results.push({ info: snapshot(updated), backgrounded: job.backgrounded })
next.set(id, updated)
}
return [results, next]
},
}),
)
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] : []))
yield* Effect.forEach(result, (item) => Deferred.succeed(item.backgrounded, item.info), { discard: true })
return result.map((item) => item.info)
})
const cancel: Interface["cancel"] = Effect.fn("Job.cancel")(function* (id) {
const completed_at = yield* Clock.currentTimeMillis
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)]
})
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)]
}),
)
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
})
return Service.of({ get, start, wait, block, background, backgroundAll, cancel })
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,
})
})
const layer = Layer.effect(Service, make)
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
export const node = makeGlobalNode({ service: Service, layer, deps: [KV.node] })
+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.orDie, Effect.as({ dispose: Effect.void })),
.pipe(Effect.as({ dispose: Effect.void })),
hook: (name, callback) => hooks.register("tool", name, callback),
},
vcs: {
+3 -1
View File
@@ -29,7 +29,7 @@ export interface Interface {
| "wait"
| "context"
>
readonly job: Pick<Job.Interface, "start" | "wait" | "block" | "background" | "cancel">
readonly job: Pick<Job.Interface, "start" | "wait" | "block" | "background" | "cancel" | "completeBackground">
readonly location: {
readonly agent: {
readonly list: (
@@ -92,6 +92,8 @@ 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: {
+4 -1
View File
@@ -3,6 +3,7 @@ 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 { SessionEvent } from "./event.js"
@@ -52,6 +53,7 @@ export const layer = Layer.effect(
const store = yield* SessionStore.Service
const locations = yield* LocationServiceMap.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(
@@ -118,6 +120,7 @@ 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 },
@@ -167,7 +170,7 @@ export const layer = Layer.effect(
export const node = makeGlobalNode({
service: Service,
layer,
deps: [SessionStore.node, LocationServiceMap.node, Bus.node, Database.node],
deps: [SessionStore.node, LocationServiceMap.node, Bus.node, Database.node, Job.node],
})
/** Low-level compatibility layer for callers that only need durable Session recording. */
+159 -17
View File
@@ -3,6 +3,8 @@ 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"
@@ -45,6 +47,9 @@ 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
@@ -62,14 +67,16 @@ 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 resumeOne = Effect.fnUntraced(function* (sessionID: SessionSchema.ID) {
const prepareResume = 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 // the Session was deleted since listing
if (attempts === undefined) return false
if (attempts > maxAttempts) {
// Terminalize instead: the release hook clears the claim and resets the
// counter atomically with the terminal event.
@@ -78,31 +85,166 @@ export const layer = (options?: Options) =>
{ sessionID, error: RESUME_EXHAUSTED },
{ commit: () => store.release(sessionID) },
)
return
return false
}
yield* bus.publish(SessionEvent.Synthetic, {
sessionID,
text: CONTINUE_AFTER_SERVER_RESTART,
description: "Continuing after restart",
})
// 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 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),
)
})
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
// 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 })
// 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()
}),
})
}),
@@ -111,5 +253,5 @@ export const layer = (options?: Options) =>
export const node = makeGlobalNode({
service: Service,
layer: layer(),
deps: [SessionStore.node, SessionExecution.node, Bus.node],
deps: [SessionStore.node, SessionExecution.node, Bus.node, Job.node, Session.node],
})
+131 -141
View File
@@ -78,83 +78,143 @@ const layer = Layer.effect(
readonly continuation?: Continuation
readonly promotable?: SessionInbox.Promotable
}) {
const sessionID = input.sessionID
let force = input.force
let continuation = input.continuation
let continuing = input.continuation !== undefined
let step = input.continuation?.step ?? 1
let entering = true
const promotable = input.promotable ?? "input"
if (!force && !continuation && !(yield* eligible(input.sessionID, promotable))) return DrainResult.Complete()
yield* plugins.flush
yield* settleStaleToolCalls(input.sessionID)
while (true) {
// 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)))
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()
const result = yield* runSteps(input.sessionID, continuation, promotable)
if (result._tag === "Moved") return result
force = false
continuation = undefined
}
})
yield* plugins.flush
yield* settleStaleToolCalls(sessionID)
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"
})
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) }
}),
)
}
}),
),
)
/** 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
const next = yield* advanceToStep()
if (next._tag !== "Ready") return next
continuing = yield* runStep(next.context, step)
step++
force = false
entering = false
}
})
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
})
/** Owns logical Step policy; each attempt owns its streaming, tools, and durable settlement. */
const runStep = Effect.fn("SessionRunner.runStep")(function* (
sessionID: SessionSchema.ID,
promotable: SessionInbox.Promotable,
step: number,
) {
const runStep = Effect.fn("SessionRunner.runStep")(function* (first: SessionContext.Loaded, step: number) {
const sessionID = first.session.id
let assistantMessageID = SessionMessage.ID.create()
const retry = yield* Schedule.toStepWithSleep(SessionRunnerRetry.schedule(bus, sessionID))
let currentPromotable: SessionInbox.Promotable | undefined = promotable
let currentStep = step
let initial: SessionContext.Loaded | undefined = first
let recoverOverflow = true
let recoverContinuation = true
while (true) {
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)
// 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 compactionInput = { session: loaded.session, messages: loaded.messages, resolved: loaded.model }
if (compaction.required(compactionInput)) {
const compacted = yield* compaction.compact(compactionInput)
@@ -162,7 +222,7 @@ const layer = Layer.effect(
assistantMessageID = SessionMessage.ID.create()
continue
}
const stepLimitReached = loaded.agent.info.steps !== undefined && currentStep >= loaded.agent.info.steps
const stepLimitReached = loaded.agent.info.steps !== undefined && step >= loaded.agent.info.steps
const transcript = SessionModelRequest.baseTranscript({
agent: loaded.agent.info,
model: loaded.model,
@@ -197,7 +257,7 @@ const layer = Layer.effect(
: Effect.succeed(false),
),
})
if (outcome._tag === "Completed") return { needsContinuation: outcome.needsContinuation, step: currentStep }
if (outcome._tag === "Completed") return outcome.needsContinuation
if (outcome._tag === "Retry" || outcome._tag === "Continue") {
yield* retry({ cause: outcome.cause, error: outcome.error, assistantMessageID }).pipe(
Pull.catchDone(() =>
@@ -223,77 +283,6 @@ 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,
) {
@@ -301,23 +290,24 @@ 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}` },
error: {
type: "aborted",
message: `Tool execution interrupted: ${tool.name}${childID ? ` (sessionID: ${childID})` : ""}`,
},
...(metadata && Object.keys(metadata).length > 0 ? { metadata } : {}),
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 })
}),
)
@@ -327,7 +327,10 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
sessionID: input.sessionID,
assistantMessageID,
id,
error,
error:
tool.name === "subagent" && error.type === "aborted" && typeof tool.progress?.sessionID === "string"
? { ...error, message: `${error.message} (sessionID: ${tool.progress.sessionID})` }
: error,
...failureSnapshot(tool, metadata),
executed: tool.providerExecuted,
})
+20 -14
View File
@@ -1,6 +1,6 @@
export * as SessionStore from "./store.js"
import { and, eq, isNotNull, isNull, sql } from "drizzle-orm"
import { and, eq, isNotNull, isNull, notInArray, 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,9 +18,8 @@ export interface Interface {
messageID: SessionMessage.ID,
) => Effect.Effect<{ readonly sessionID: Session.ID; readonly message: SessionMessage.Info } | undefined>
/**
* 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.
* Top-level Sessions holding an execution claim. Recoverable background
* children are resumed separately through their durable Job records.
*/
readonly listSuspended: () => Effect.Effect<ReadonlyArray<Session.ID>>
/**
@@ -33,11 +32,10 @@ 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 (subagent) claims. Children are never resumed
* independently, so a dead child's claim is noise no terminal will ever
* release.
* Clears orphaned child claims except children owned by recoverable
* background subagent jobs.
*/
readonly releaseChildClaims: Effect.Effect<void>
readonly releaseChildClaims: (recoverable: ReadonlyArray<Session.ID>) => Effect.Effect<void>
/**
* Durably counts one more resume of an orphaned claim, returning the new
* total — or undefined when the Session no longer exists.
@@ -103,12 +101,20 @@ const layer = Layer.effect(
.run()
.pipe(Effect.orDie)
}),
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")),
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),
),
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)
// 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) }))
// Teardown interrupts pending commands; it is not a terminal command failure.
yield* Deferred.interrupt(session.done)
}
sessions.clear()
exitOrder.length = 0
+30 -33
View File
@@ -25,7 +25,7 @@ export class RegistrationError extends Schema.TaggedError<RegistrationError>()("
export interface Interface {
readonly transform: (
callback: (draft: { readonly add: (tool: Tool.Info) => void }) => void,
) => Effect.Effect<void, RegistrationError, Scope.Scope>
) => Effect.Effect<void, never, Scope.Scope>
readonly snapshot: (permissions?: Permission.Ruleset) => Effect.Effect<Snapshot>
}
@@ -140,45 +140,35 @@ 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) }))
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({
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({
try: () => ToolDefinition.make(definition(entry.tool)),
catch: (error) =>
new RegistrationError({
name: entry.key,
message: `Invalid tool definition ${entry.key}: ${schemaMakeError(error)}`,
}),
}),
{ discard: true },
})
return true
}).pipe(Effect.catchTag("Tool.RegistrationError", (error) => skipRegistration(entry.tool, error))),
)
// 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* () {
@@ -270,6 +260,13 @@ 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 -1
View File
@@ -115,7 +115,7 @@ export const layer = Layer.effect(
})
}
})
.pipe(Scope.provide(next), Effect.orDie)
.pipe(Scope.provide(next))
if (current) yield* Scope.close(current, Exit.void)
current = next
}),
+46 -51
View File
@@ -115,56 +115,45 @@ 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>,
) {
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 }),
)
})
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 }),
)
yield* ctx.tool
.transform((draft) =>
@@ -286,7 +275,7 @@ export const Plugin = {
const settled = yield* Deferred.make<Output>()
const run = settleShell().pipe(
Effect.tap((output) => Deferred.succeed(settled, output)),
Effect.map((output) => output.output),
Effect.map((output) => resultMessages(output).join("\n\n")),
Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)),
)
const job = yield* runtime.job.start({
@@ -294,6 +283,12 @@ 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,
})
+27 -39
View File
@@ -78,22 +78,6 @@ 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,
@@ -104,23 +88,24 @@ export const Plugin = {
const key = `${childID}:${startedAt}`
if (notifications.has(key)) return
notifications.add(key)
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
}),
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(
Effect.ensuring(Effect.sync(() => notifications.delete(key))),
Effect.forkIn(scope, { startImmediately: true }),
)
@@ -239,6 +224,7 @@ 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(
@@ -246,17 +232,19 @@ 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: {},
run,
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))),
})
if (background) {
+27 -2
View File
@@ -385,6 +385,9 @@ 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" },
})
@@ -402,6 +405,7 @@ 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(
[
@@ -410,14 +414,35 @@ 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: "default",
label: "API key",
value: JSON.stringify({ type: "key", key: "wellknown-key" }),
},
{
integration_id: "openai",
label: "default",
label: "OAuth",
value: JSON.stringify({
type: "oauth",
methodID: "chatgpt-browser",
+174 -1
View File
@@ -1,11 +1,13 @@
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(Job.node))
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Job.node, KV.node])))
describe("Job", () => {
it.live("tracks process-local work through explicit observation", () =>
@@ -145,6 +147,177 @@ 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()
+81 -1
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, Schedule, Schema, Sink, Stream } from "effect"
import { DateTime, Deferred, Effect, Exit, Fiber, Layer, PubSub, Ref, 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,6 +1193,86 @@ 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
+2 -1
View File
@@ -223,6 +223,7 @@ 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)
@@ -262,7 +263,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,6 +282,47 @@ 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
+619 -5
View File
@@ -4,6 +4,8 @@ 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"
@@ -23,7 +25,9 @@ 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])))
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionStore.node, Job.node, KV.node, Session.node])),
)
describe("SessionExecution lifecycle", () => {
test("classifies success and typed failure terminals", () => {
@@ -60,14 +64,13 @@ describe("SessionExecution lifecycle", () => {
const idle = Session.ID.make("ses_recover_idle")
yield* seedSessions(database, [parent], { time_suspended: Date.now() })
yield* seedSessions(database, [idle])
// An orphaned child is never resumed: the resumed parent re-runs its
// tool call and spawns a fresh child instead.
// Children recover through background Job records, never through the root claim sweep.
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 })
}),
)
@@ -147,6 +150,66 @@ 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
@@ -304,6 +367,518 @@ 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* () {
@@ -446,6 +1021,27 @@ 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"],
@@ -531,11 +1127,27 @@ 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({
@@ -553,10 +1165,12 @@ function buildExecution(
)
return yield* Layer.buildWithScope(
SessionRestart.layer(options).pipe(
Layer.provideMerge(SessionExecution.layer),
Layer.provideMerge(sessionLayer),
Layer.provideMerge(Layer.fresh(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(locations),
),
scope,
@@ -128,6 +128,35 @@ 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, Schema, SchemaGetter, SchemaIssue, Scope } from "effect"
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Logger, Schema, SchemaGetter, SchemaIssue, Scope } from "effect"
import { z } from "zod"
import { testEffect } from "./lib/effect"
@@ -71,30 +71,49 @@ const transform = (service: Tool.Interface, tools: Readonly<Record<string, Info>
)
describe("Tool", () => {
it.effect("rejects invalid dotted namespaces", () =>
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", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
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,
yield* transform(
service,
{
before: make(),
"": make(),
["x".repeat(65)]: make(),
"echo.tool": make(),
echo_tool: make(),
execute: make(),
after: make(),
},
{ codemode: false },
)
expect(collision.message).toBe("Duplicate normalized tool name: echo_tool")
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
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([])
}),
)
@@ -159,40 +178,72 @@ describe("Tool", () => {
}),
)
it.effect("validates a registration batch before installing any tools", () =>
it.effect("keeps healthy tools when another namespace is invalid", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
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)
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" } })
})
expect(error).toBeInstanceOf(Tool.RegistrationError)
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
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"])
}),
)
it.effect("rejects invalid tool definitions before installing any tools", () =>
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", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
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"])
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"])
}),
)
+302 -1
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 } from "drizzle-orm"
import { asc, desc, eq, sql } from "drizzle-orm"
import { testEffect } from "./lib/effect"
import { permissionLayer } from "./lib/permission"
import { agentHost, catalogHost, host } from "./plugin/host"
@@ -1444,6 +1444,49 @@ 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
@@ -1554,6 +1597,56 @@ 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
@@ -2562,6 +2655,74 @@ 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
@@ -3320,6 +3481,71 @@ 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
@@ -3640,6 +3866,81 @@ 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,6 +790,48 @@ 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",
() =>
+2
View File
@@ -0,0 +1,2 @@
component-tests/test-results
component-tests/playwright-report
@@ -0,0 +1,127 @@
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)
})
}
@@ -0,0 +1,61 @@
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()
})
@@ -0,0 +1,72 @@
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)")
})
@@ -0,0 +1,50 @@
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")
})
@@ -0,0 +1,42 @@
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()
})
@@ -0,0 +1,74 @@
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()
})
@@ -0,0 +1,65 @@
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")
})
@@ -0,0 +1,142 @@
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")
})
+4 -1
View File
@@ -47,10 +47,13 @@
"scripts": {
"generate:progress-indicator": "bun script/generate-session-progress-indicator.ts",
"typecheck": "tsgo -b",
"test": "bun test src --only-failures"
"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"
},
"devDependencies": {
"@happy-dom/global-registrator": "20.0.11",
"@playwright/test": "catalog:",
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@types/luxon": "catalog:",
@@ -0,0 +1,4 @@
import { fileURLToPath } from "node:url"
import { componentConfig } from "../storybook/playwright/config"
export default componentConfig(fileURLToPath(new URL(".", import.meta.url)))
@@ -1,6 +1,7 @@
import { createStore } from "solid-js/store"
import { CurrentSessionProviders } from "../storybook/current-session-story"
import { editThenTestDocument, reviewDiffs } from "../storybook/current-session-fixtures"
import { SessionReview } from "./session-review"
import { SessionReview, type SessionReviewComment } from "./session-review"
function ReviewStory(props: { split?: boolean }) {
return (
@@ -44,3 +45,35 @@ 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 /> }
@@ -0,0 +1,75 @@
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,8 +21,19 @@ export function CurrentSessionProviders(props: { document: SessionDocument; chil
{ name: "test", color: "green" },
],
provider: {
all: new Map([["anthropic", { models: { "claude-sonnet-4": { name: "Claude Sonnet 4" } } }]]),
connected: ["anthropic"],
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"],
default: { anthropic: "claude-sonnet-4" },
},
session: [
@@ -1,4 +1,7 @@
import { CurrentSessionTimelineStory } from "../storybook/current-session-story"
import { createTwoFilesPatch } from "diff"
import { createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { CurrentSessionProviders, CurrentSessionTimelineStory } from "../storybook/current-session-story"
import {
editThenTestDocument,
fileChangeLoadingDocument,
@@ -6,6 +9,7 @@ import {
multiFilePatchDocument,
writeFileDocument,
} from "../storybook/current-session-fixtures"
import { storyDocument, storyPatchFile, storyTool } from "../storybook/current-session-scenarios"
import { SessionTimeline } from "./session-timeline"
export default {
@@ -70,6 +74,137 @@ 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,10 +1,19 @@
import { CurrentSessionTimelineStory } from "../storybook/current-session-story"
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 {
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 {
@@ -65,3 +74,318 @@ 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,4 +1,7 @@
import { CurrentSessionTimelineStory } from "../storybook/current-session-story"
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 {
executeCodeDocument,
expandedShellDocument,
@@ -9,6 +12,7 @@ import {
terminalPassedDocument,
terminalRunningDocument,
} from "../storybook/current-session-fixtures"
import { storyDocument, storyTool } from "../storybook/current-session-scenarios"
import { SessionTimeline } from "./session-timeline"
export default {
@@ -62,6 +66,17 @@ 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
@@ -110,6 +125,75 @@ 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,6 +1,13 @@
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 { CurrentSessionTimelineStory } from "../storybook/current-session-story"
import { CurrentSessionProviders, CurrentSessionTimelineStory } from "../storybook/current-session-story"
import {
CURRENT_SESSION_ID,
STORY_MODEL,
STORY_TIME,
attachmentsAndCommentsDocument,
attachmentsAndCommentsPresentation,
compactionCancelledDocument,
@@ -53,6 +60,181 @@ 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
@@ -64,6 +246,122 @@ 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
@@ -156,6 +454,155 @@ 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
+6 -1
View File
@@ -69,7 +69,7 @@ const frame = createJSXDecorator((Story, context) => {
<MetaProvider>
<Font />
<ThemeProvider>
<LanguageProvider locale="en">
<LanguageProvider locale={typeof context.globals?.locale === "string" ? context.globals.locale : "en"}>
<UiI18nBridge>
<Scheme value={scheme} />
<Direction value={context.globals?.direction} />
@@ -109,6 +109,11 @@ export default definePreview({
description: "Interface direction",
defaultValue: "ltr",
},
locale: {
name: "Locale",
description: "Interface language",
defaultValue: "en",
},
},
parameters: {
actions: {
+1
View File
@@ -12,6 +12,7 @@
"@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
@@ -0,0 +1,61 @@
# 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
@@ -0,0 +1,31 @@
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
@@ -0,0 +1,37 @@
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"]
"include": [".storybook/**/*.ts", ".storybook/**/*.tsx", "playwright/**/*.ts"]
}
+2 -4
View File
@@ -38,7 +38,6 @@ import {
TuiStartupProvider,
TuiTerminalEnvironmentProvider,
useTuiApp,
useTuiPaths,
useTuiStartup,
useTuiTerminalEnvironment,
type TuiApp,
@@ -375,7 +374,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
>
<ClientProvider api={api} url={input.server.endpoint.url} service={service}>
<PermissionProvider>
<DataProvider>
<DataProvider directory={directory}>
<LocationProvider>
<SessionTabsProvider>
<SessionTerminalsProvider>
@@ -460,7 +459,6 @@ 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()
@@ -727,7 +725,7 @@ function App(props: { pair?: DialogPairCredentials }) {
type: "home",
location: newSessionLocation(
config.data.session.new_location,
paths.cwd,
data.location.default().directory,
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: () => {
init: (props: { directory: string }) => {
const client = useClient()
const data = createData({
api: () => client.api,
event: client.event,
connection: client.connection,
directory: process.cwd(),
directory: props.directory,
})
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,
paths.cwd,
data.location.default().directory,
currentLocation,
location.error?.location,
),
+2 -1
View File
@@ -360,7 +360,8 @@ export function Session(props: {
createEffect(() => {
if (restored || !synced() || !rowsSynced() || !scroll || scroll.isDestroyed) return
restored = true
restoreScrollPosition()
// Initial synchronization can finish after the reader has already navigated.
if (!isAwayFromBottom()) restoreScrollPosition()
})
let awayTimer: ReturnType<typeof setTimeout> | undefined
onCleanup(() => {
+100
View File
@@ -6,6 +6,7 @@ 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 })
@@ -297,6 +298,104 @@ 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()
@@ -470,6 +569,7 @@ 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>
<DataProvider directory={process.cwd()}>
<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>
<DataProviderBase directory={process.cwd()}>
<LocationProvider>
<SyncLocation />
{props.children}
@@ -304,7 +304,7 @@ async function renderIntegration() {
<Keymap.Provider>
<ToastProvider>
<ClientProvider api={createApi(calls.fetch)}>
<DataProvider>
<DataProvider directory={process.cwd()}>
<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>
<DataProvider directory={process.cwd()}>
<LocationProvider>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<DialogProvider>
@@ -318,7 +318,7 @@ async function renderOpen(
<ToastProvider>
<RouteProvider>
<ClientProvider api={createApi(calls.fetch)}>
<DataProvider>
<DataProvider directory={process.cwd()}>
<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>
<DataProvider directory={process.cwd()}>
<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>
<DataProvider directory={process.cwd()}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<ToastProvider>{response ? <CurrentForm /> : <FormPrompt form={form} />}</ToastProvider>
</ThemeProvider>
@@ -39,6 +39,7 @@ 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
@@ -168,7 +169,7 @@ async function renderSessionTabs(
initialRoute={options?.home ? { type: "home" } : { type: "session", sessionID: initialSessionID }}
>
<ClientProvider api={createApi(calls.fetch)}>
<DataProvider>
<DataProvider directory={options?.launchDirectory ?? directory}>
<LocationProvider>
<SessionTabsProvider>
<Probe />
@@ -892,13 +893,17 @@ test("tracks a temporary new session tab across close and creation", async () =>
}
})
test("add opens the new session tab in the launch directory by default", async () => {
const setup = await renderSessionTabs("first", { sessionDirectories: { first: `${directory}/worktree` } })
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` },
})
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 } })
expect(setup.route.data).toEqual({ type: "home", location: { directory: launchDirectory } })
await wait(() => setup.tabs.newTab())
expect(setup.tabs.tabs().map((tab) => tab.sessionID)).toEqual(["first"])
} finally {
+137
View File
@@ -3950,6 +3950,143 @@
},
"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,6 +3950,143 @@
},
"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": {
+4
View File
@@ -11,6 +11,10 @@
"globalPassThroughEnv": ["CI", "OPENCODE_DISABLE_SHARE"],
"tasks": {
"typecheck": {},
"test:components": {
"outputs": [],
"cache": false
},
"@opencode-ai/enterprise#typecheck": {
"dependsOn": ["@opencode-ai/core#typecheck"]
},