Compare commits

..
Author SHA1 Message Date
Aiden Cline 3023c2d995 fix(core): preserve conversation agent during compaction 2026-08-26 23:08:20 -05:00
Aiden Cline fcc6568fcb feat(core): adjust bg shell completion msg (include output file) (#45461) 2026-08-26 23:04:11 -05:00
opencode-agent[bot]andrekram1-node 1c66cd7832 fix(tui): clarify tool grouping setting (#45470)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-26 23:02:27 -05:00
Luke Parker 5cc81a497c fix(app): keep active tools in existing groups (#45462) 2026-08-27 03:54:38 +00:00
Kit Langton 10786cb60c refactor(core): consolidate runner capability reads (#45448) 2026-08-26 23:45:01 -04:00
Kit Langton 1e7c60adce fix(server): wait for plugins before text generation (#45447)
Wait for bounded plugin readiness in the generation location before resolving explicit or default models. Add deterministic cold-first-request regressions through the embedded SDK.
2026-08-26 23:30:52 -04:00
Aiden Cline 1c4f8c40a8 feat(plugin): add tool draft reads (#45443) 2026-08-26 22:16:27 -05:00
Luke Parker 2ca55b479d fix(app): reduce tab switch rendering work (#45428) 2026-08-27 13:10:27 +10:00
opencode-agent[bot]andthdxr 8d7caa178b fix(core): route session events to location subscribers (#45411)
Co-authored-by: thdxr <826656+thdxr@users.noreply.github.com>
2026-08-26 23:04:27 -04:00
Aiden Cline 2bcb67a71e feat(plugin): add tool updates and removal (#45436) 2026-08-26 22:00:01 -05:00
Luke Parker 48d4e52143 fix(app): keep pending steers after assistant work (#45435) 2026-08-27 02:44:18 +00:00
Aiden Cline 40cbea3c19 refactor(core): use shared state for tool registry (#45414) 2026-08-26 21:33:00 -05:00
Luke Parker 51065122d8 fix(app): keep project extensions inside settings (#45432) 2026-08-27 02:30:36 +00:00
Luke Parker 7507f19a00 fix(app): prevent settings loading flicker (#45427) 2026-08-27 02:01:36 +00:00
opencode-agent[bot] 71706577c4 chore: update nix node_modules hashes 2026-08-27 01:53:01 +00:00
85 changed files with 3395 additions and 1470 deletions
-6
View File
@@ -1,6 +0,0 @@
---
"@opencode-ai/core": patch
"@opencode-ai/server": patch
---
Centralize application construction in Core, isolate plugin runtime bindings per application, and preserve interruption options through the plugin bridge.
-6
View File
@@ -1,6 +0,0 @@
---
"@opencode-ai/util": patch
"@opencode-ai/core": patch
---
Validate graph replacement outputs and shared dependency conflicts before building services. Resolve dependencies against the final override map and automatically bind Location maps introduced by replacements.
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-NV1PD2fCgWEKsr9kR0pV9jgkC400dzoF7/DnI/fY5yI=",
"aarch64-linux": "sha256-TDTdwE0mcHLrrKPDwPPBk3qIDl/PXJrLX6Zbwp7EH3I=",
"aarch64-darwin": "sha256-6MEoiV1UKAWgC7C6PR4USCP/LLZXROfBfPg6sb2VVWg=",
"x86_64-darwin": "sha256-8JV6YVZFq1BC++zpARxBWhQ+wuNJrWgTZJ6jfQhDybs="
"x86_64-linux": "sha256-iYdVrLtyKmjlyypisF9SqzgyriWT90kSCh3crxw9AKU=",
"aarch64-linux": "sha256-BV2t4w5ujArbtSC/Qfm3gLzevQW9A6hMgOyPVp94g/o=",
"aarch64-darwin": "sha256-EwMq7zaxzzcsmH0Pjqu4ftGdcM8Lna8mvHgKzRcVI8g=",
"x86_64-darwin": "sha256-PokzxlkQy6JvHADF2ZMIIDI1u9ZjSNNedpmR9gvHS5c="
}
}
+9
View File
@@ -79,6 +79,15 @@ Benchmarks do not assert machine-dependent performance budgets. Streaming proces
Committed smoke and regression tests continue to own correctness coverage for pagination, tab paint, context resize, collapse state, and composer spacing.
Tab-switch timing starts at `mousedown`, when mouse-selected tabs actually navigate, with a `click` fallback for keyboard activation. The probe excludes hidden/transparent content and intersects answers with their virtual-row clip and viewport. The tab workload requires the destination's final answer to be visible with Markdown ready. These results are not directly comparable to older click-start, geometry-only measurements. `stableObservedMs` includes confirmation across three correct samples; `firstCorrectObservedMs` is the first sample meeting all content and geometry checks. Neither is a compositor presentation timestamp.
Each tab scenario reports one sample, including its raw observations. Use Playwright's `--repeat-each=5` for repeated measurements. Cached scenarios warm the destination at the same panel width before leaving it; a separate resized scenario validates reuse after opening the review pane changes that width.
```sh
bunx playwright test --config e2e/performance/playwright.config.ts \
timeline/session-tab-switch-benchmark.spec.ts --repeat-each=5
```
## Retained renderer memory
Run the catalog workload against the production app bundle:
@@ -126,13 +126,19 @@ test("keeps moving upward while drag-selecting above the timeline", async ({ pag
)
})
})
const textBox = await text.boundingBox()
const textBox = await text.evaluate((element) => {
const range = document.createRange()
range.selectNodeContents(element)
const rect = range.getClientRects()[0]
return rect ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } : null
})
const scrollBox = await scroller.boundingBox()
expect(textBox).not.toBeNull()
expect(scrollBox).not.toBeNull()
if (!textBox || !scrollBox) return
await page.mouse.move(textBox.x + textBox.width - 10, textBox.y + textBox.height / 2)
// Start on a text line, not the empty right edge or gap between wrapped lines.
await page.mouse.move(textBox.x + Math.min(20, textBox.width / 2), textBox.y + textBox.height / 2)
await page.mouse.down()
await page.mouse.move(textBox.x + 20, scrollBox.y - 120, { steps: 30 })
@@ -195,6 +201,45 @@ test("does not pull a keyboard-scrolled user during shell remeasurement", async
await reportVisualStability(testInfo, "keyboard-during-resize", trace, anchorPlan(regions))
})
test("keeps an older answer selected while scrolling within the interaction buffer", async ({ page }) => {
await setupTimeline(page, {
messages: history(80),
viewport: { width: 1400, height: 700 },
reducedMotion: true,
})
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
const answer = page.getByText("History 78.", { exact: false })
await expect(answer).toBeVisible()
await expect
.poll(() =>
answer.evaluate((element) => element.closest('[data-component="markdown"]')?.hasAttribute("data-markdown-ready")),
)
.toBe(true)
const textBox = await answer.evaluate((element) => {
const range = document.createRange()
range.selectNodeContents(element)
const rect = range.getClientRects()[0]
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
})
const scrollBox = await scroller.boundingBox()
expect(scrollBox).not.toBeNull()
if (!scrollBox) return
await page.mouse.move(textBox.x + Math.min(180, textBox.width - 2), textBox.y + textBox.height / 2)
await page.mouse.down()
await page.mouse.move(textBox.x + 2, textBox.y + textBox.height / 2, { steps: 30 })
await page.mouse.up()
await expect.poll(() => page.evaluate(() => window.getSelection()?.toString())).toContain("History 78.")
await page.mouse.move(scrollBox.x + scrollBox.width / 2, scrollBox.y + scrollBox.height / 2)
await page.mouse.wheel(0, -450)
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeGreaterThan(400)
await expect(answer).toHaveCount(1)
await expect.poll(() => page.evaluate(() => window.getSelection()?.toString())).toContain("History 78.")
await page.getByRole("heading", { name: "Timeline visual stability" }).click()
await expect.poll(() => page.evaluate(() => window.getSelection()?.isCollapsed)).toBe(true)
})
test("tracks keyboard scrolling from a focused timeline descendant", async ({ page }, testInfo) => {
const shellID = "prt_descendant_keyboard_01_shell"
const timeline = await setupTimeline(page, {
@@ -259,12 +304,16 @@ test("does not claim keyboard scrolling owned by a nested scrollable", async ({
const before = await scroller.evaluate((element) => element.scrollTop)
const nestedBefore = await nested.evaluate((element) => element.scrollTop)
await nested.press("PageUp")
await page.waitForTimeout(300)
await expect.poll(() => nested.evaluate((element) => element.scrollTop)).toBeLessThan(nestedBefore)
expect(await scroller.evaluate((element) => element.scrollTop)).toBe(before)
expect(await nested.evaluate((element) => element.scrollTop)).toBeLessThan(nestedBefore)
await nested.evaluate((element) => (element.scrollTop = 0))
await scroller.evaluate((element) => (element.scrollTop = Math.min(300, element.scrollHeight - element.clientHeight)))
await nested.evaluate((element) => element.scrollTo({ top: 0, behavior: "instant" }))
await expect.poll(() => nested.evaluate((element) => element.scrollTop)).toBe(0)
await scroller.evaluate((element) => {
element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -1 }))
element.scrollTo({ top: Math.min(300, element.scrollHeight - element.clientHeight), behavior: "instant" })
})
await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeLessThan(500)
const boundaryBefore = await scroller.evaluate((element) => element.scrollTop)
expect(boundaryBefore).toBeGreaterThan(0)
await nested.press("PageUp")
@@ -11,115 +11,73 @@ import {
} from "./timeline-test-helpers"
import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
type Result = Awaited<ReturnType<typeof measureSessionSwitch>>
const scenarios = [
{ cached: false, review: false, resized: false },
{ cached: false, review: true, resized: false },
{ cached: true, review: false, resized: false },
{ cached: true, review: true, resized: false },
{ cached: true, review: true, resized: true },
]
benchmark(
"benchmarks session tab switching with and without the review pane",
async ({ browser, report }, testInfo) => {
benchmark.setTimeout(360_000)
const runs = Number(process.env.SESSION_TAB_SWITCH_RUNS ?? 5)
const results = {
closed: { cold: [] as Result[], hot: [] as Result[] },
open: { cold: [] as Result[], hot: [] as Result[] },
}
for (const reviewPane of ["closed", "open"] as const) {
for (const mode of ["cold", "hot"] as const) {
for (let run = 0; run < runs; run++) {
results[reviewPane][mode].push(
await withBenchmarkPage(
browser,
`session-tab-switch-${reviewPane}-${mode}-${run}`,
(page) => trial(page, mode, reviewPane),
testInfo,
),
)
scenarios.forEach((scenario) => {
const name = `tab switch: ${scenario.cached ? "cached" : "unmounted"}, review ${scenario.review ? "open" : "closed"}${scenario.resized ? ", resized" : ""}`
benchmark(name, async ({ browser, report }, testInfo) => {
const result = await withBenchmarkPage(
browser,
name,
async (page) => {
await mockStressTimeline(page, { vcsDiff: createReviewDiffs() })
await installTimelineSettings(page)
await installStressSessionTabs(page)
await page.goto(stressSessionHref(fixture.sourceID))
await expectSessionTitle(page, fixture.expected.sourceTitle)
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
if (scenario.review && !scenario.resized) await openReviewPane(page)
if (scenario.cached) {
await switchSession(page, fixture.targetID, fixture.expected.targetTitle)
const answer = page.locator(`[data-timeline-part-id="${fixture.expected.targetPartIDs.at(-1)}"]`)
await expect(answer.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "")
await expect
.poll(() =>
answer.evaluate((element) => element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })),
)
.toBe(true)
await waitForStableTimeline(page, fixture.expected.targetMessageIDs.at(-1)!)
await switchSession(page, fixture.sourceID, fixture.expected.sourceTitle)
}
}
}
report({ results, summary: summarizeReviewPane(results) }, { runs, reviewDiffs: createReviewDiffs().length })
},
)
if (scenario.resized) await openReviewPane(page)
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
async function trial(page: Page, mode: "cold" | "hot", reviewPane: "closed" | "open") {
const reviewDiffs = createReviewDiffs()
await mockStressTimeline(page, { vcsDiff: reviewDiffs })
await installTimelineSettings(page)
await installStressSessionTabs(page)
if (mode === "hot") {
await page.goto(stressSessionHref(fixture.targetID))
await expectSessionTitle(page, fixture.expected.targetTitle)
await waitForStableTimeline(page, fixture.expected.targetMessageIDs.at(-1)!)
await switchSession(page, fixture.sourceID, fixture.expected.sourceTitle)
} else {
await page.goto(stressSessionHref(fixture.sourceID))
await expectSessionTitle(page, fixture.expected.sourceTitle)
}
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
if (reviewPane === "open") {
await openReviewPane(page)
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
}
const destinationIDs = fixture.messages[fixture.targetID].map((message) => message.id)
const sourceIDs = fixture.messages[fixture.sourceID].map((message) => message.id)
const lastID = fixture.expected.targetMessageIDs.at(-1)!
const href = stressSessionHref(fixture.targetID)
const result = await measureSessionSwitch(page, {
destinationIDs,
sourceIDs,
lastID,
href,
switch: () => switchSession(page, fixture.targetID, fixture.expected.targetTitle),
})
return result
}
function summarize(results: Record<"cold" | "hot", Result[]>) {
const stats = (values: (number | null)[]) => {
const sorted = values.filter((value): value is number => value !== null).sort((a, b) => a - b)
return {
min: sorted[0] ?? null,
median: sorted[Math.floor(sorted.length / 2)] ?? null,
max: sorted.at(-1) ?? null,
missing: values.length - sorted.length,
}
}
return Object.fromEntries(
Object.entries(results).map(([mode, values]) => [
mode,
{
firstDestinationObservedMs: stats(values.map((value) => value.firstDestinationObservedMs)),
firstCorrectObservedMs: stats(values.map((value) => value.firstCorrectObservedMs)),
stableObservedMs: stats(values.map((value) => value.stableObservedMs)),
return measureSessionSwitch(page, {
destinationIDs: fixture.messages[fixture.targetID].map((message) => message.id),
sourceIDs: fixture.messages[fixture.sourceID].map((message) => message.id),
lastID: fixture.expected.targetMessageIDs.at(-1)!,
requiredPartID: fixture.expected.targetPartIDs.at(-1),
href: stressSessionHref(fixture.targetID),
switch: () => switchSession(page, fixture.targetID, fixture.expected.targetTitle),
})
},
]),
)
}
function summarizeReviewPane(results: Record<"closed" | "open", Record<"cold" | "hot", Result[]>>) {
return Object.fromEntries(
Object.entries(results).map(([reviewPane, values]) => [
reviewPane,
summarize(values as Record<"cold" | "hot", Result[]>),
]),
)
}
testInfo,
)
expect(result.unknownSamples).toBe(0)
expect(result.wrongDestinationSamples).toBe(0)
if (scenario.cached) expect(result.blankSamples).toBe(0)
report(result, { ...scenario, inputEvent: "mousedown", requireReadyAnswer: true })
})
})
async function switchSession(page: Page, sessionID: string, title: string) {
const href = stressSessionHref(sessionID)
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
await expect(tab).toBeVisible()
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(sessionID)}"]`)
await expect(tab).toHaveCount(1)
await tab.click()
await expectSessionTitle(page, title)
}
async function openReviewPane(page: Page) {
await page.getByRole("button", { name: "Toggle review" }).click()
const panel = page.locator("#review-panel")
await expect(panel).toBeVisible()
await expect(page.locator("#review-panel")).toBeVisible()
await page.waitForFunction(() => {
const panel = document.querySelector<HTMLElement>("#review-panel")
const text = panel?.textContent ?? ""
const text = document.querySelector("#review-panel")?.textContent ?? ""
return text.includes("generated-000.ts") && text.includes("+3")
})
}
@@ -20,9 +20,10 @@ export function classifySessionSwitch(samples: SessionSwitchSample[]) {
const firstCorrect = samples.findIndex(isCorrectDestination)
const stable = samples.findIndex((_, index) => isStableSessionSwitch(samples.slice(index, index + 3)))
return {
samples,
firstDestinationObservedMs: samples[firstDestination]?.observedAtMs ?? null,
firstCorrectObservedMs: samples[firstCorrect]?.observedAtMs ?? null,
stableObservedMs: samples[stable + 2]?.observedAtMs ?? null,
stableObservedMs: stable < 0 ? null : samples[stable + 2].observedAtMs,
wrongDestinationSamples: samples
.slice(firstDestination)
.filter((sample) => sample.destination.length > 0 && !sample.last).length,
@@ -0,0 +1,67 @@
import { benchmark, expect } from "../benchmark"
import { measureSessionSwitch } from "./session-tab-switch-probe"
import type { SessionSwitchSample } from "./session-tab-switch-metrics"
benchmark("starts at mousedown and excludes hidden or unfinished destination content", async ({ page, report }) => {
await page.setContent(`
<a href="/session/destination">Destination</a>
<div class="scroll-view__viewport" style="height:200px;overflow:auto">
<div data-timeline-row="message" data-timeline-key="row" data-message-id="source">
<div data-timeline-part-id="answer"><div data-component="markdown">Destination answer</div></div>
</div>
</div>
`)
await page.evaluate(() => {
document.querySelector("a")!.addEventListener("mousedown", () => {
const row = document.querySelector<HTMLElement>("[data-message-id]")!
row.dataset.messageId = "destination"
row.style.visibility = "hidden"
})
})
const result = await measureSessionSwitch(page, {
destinationIDs: ["destination"],
sourceIDs: ["source"],
lastID: "destination",
requiredPartID: "answer",
requireBottomAnchor: false,
href: "/session/destination",
switch: async () => {
// No click is dispatched: the probe must observe the event that activates tabs.
await page.getByRole("link", { name: "Destination" }).dispatchEvent("mousedown", { button: 0 })
await page.waitForFunction(() => {
const host = window as Window & { __sessionSwitchProbe?: { samples: SessionSwitchSample[] } }
return host.__sessionSwitchProbe?.samples.some((sample) => !sample.hasVisibleRows)
})
await page.locator("[data-message-id]").evaluate((row) => row.style.removeProperty("visibility"))
await page.waitForFunction(() => {
const host = window as Window & { __sessionSwitchProbe?: { samples: SessionSwitchSample[] } }
return host.__sessionSwitchProbe?.samples.some(
(sample) => sample.destination.length > 0 && sample.requiredPartVisible === false,
)
})
const beforeClip = await page.evaluate(() => {
const row = document.querySelector<HTMLElement>("[data-timeline-key]")!
row.style.cssText = "height:10px;position:relative;overflow:clip"
const answer = row.querySelector<HTMLElement>("[data-timeline-part-id]")!
answer.style.cssText = "position:absolute;top:30px;width:150px"
answer.querySelector('[data-component="markdown"]')!.setAttribute("data-markdown-ready", "")
return (
(window as Window & { __sessionSwitchProbe?: { samples: SessionSwitchSample[] } }).__sessionSwitchProbe
?.samples.length ?? 0
)
})
await page.waitForFunction((count) => {
const host = window as Window & { __sessionSwitchProbe?: { samples: SessionSwitchSample[] } }
return host.__sessionSwitchProbe?.samples.slice(count).some((sample) => sample.requiredPartVisible === false)
}, beforeClip)
await page.locator("[data-timeline-key]").evaluate((row) => {
row.style.height = "100px"
})
},
})
expect(result.blankSamples).toBeGreaterThan(0)
expect(result.firstCorrectObservedMs).not.toBeNull()
expect(result.stableObservedMs).not.toBeNull()
expect(result.firstCorrectObservedMs).toBeGreaterThan(result.firstDestinationObservedMs!)
report(result)
})
@@ -25,7 +25,7 @@ async function installSessionSwitchProbe(
let running = true
const reviewLevels: Record<string, string> = {
panel: "#review-panel",
tabs: '#review-panel [data-component="tabs"]',
tabs: '#review-panel [data-component="tabs"]',
body: '#review-panel [data-slot="session-review-v2-body"]',
review: '#review-panel [data-component="session-review-v2"]',
preview: '#review-panel [data-slot="session-review-v2-preview"]',
@@ -37,7 +37,6 @@ async function installSessionSwitchProbe(
if (!running || started === undefined) return
setTimeout(() => {
if (!running || started === undefined) return
const observedAtMs = performance.now() - started
const reviewPanel = document.querySelector<HTMLElement>("#review-panel")
const reviewFile = reviewPanel?.querySelector('[data-component="file"][data-mode="diff"]')
const initialReviewFile = initialReviewNodes.file
@@ -63,26 +62,30 @@ async function installSessionSwitchProbe(
)
if (root) {
const view = root.getBoundingClientRect()
const visible = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
.filter((element) => {
const rect = element.getBoundingClientRect()
return rect.bottom > view.top && rect.top < view.bottom
})
.map((element) => element.dataset.messageId!)
const hasVisibleRows = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")].some((element) => {
const inViewport = (element: HTMLElement) => {
if (!element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) return false
const rect = element.getBoundingClientRect()
return rect.bottom > view.top && rect.top < view.bottom
})
const clip = element.closest<HTMLElement>("[data-timeline-key]")?.getBoundingClientRect() ?? view
return (
Math.min(rect.bottom, clip.bottom, view.bottom) > Math.max(rect.top, clip.top, view.top) &&
Math.min(rect.right, clip.right, view.right) > Math.max(rect.left, clip.left, view.left)
)
}
const visible = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
.filter(inViewport)
.map((element) => element.dataset.messageId!)
const hasVisibleRows = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")].some(inViewport)
const requiredPartVisible = requiredPartID
? [...root.querySelectorAll<HTMLElement>("[data-timeline-part-id]")].some((element) => {
if (element.dataset.timelinePartId !== requiredPartID) return false
const rect = element.getBoundingClientRect()
return rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom
if (!element.textContent?.trim()) return false
if (element.querySelector('[data-component="markdown"]:not([data-markdown-ready])')) return false
return inViewport(element)
})
: undefined
const spacer = root.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
samples.push({
observedAtMs,
observedAtMs: performance.now() - started,
destination: visible.filter((id) => destination.has(id)),
source: visible.filter((id) => source.has(id)),
hasVisibleRows,
@@ -94,7 +97,7 @@ async function installSessionSwitchProbe(
})
} else {
samples.push({
observedAtMs,
observedAtMs: performance.now() - started,
destination: [],
source: [],
hasVisibleRows: false,
@@ -107,23 +110,25 @@ async function installSessionSwitchProbe(
requestAnimationFrame(sample)
}, 0)
}
document.addEventListener(
"click",
(event) => {
const link = event.target instanceof Element ? event.target.closest("a") : undefined
if (link?.getAttribute("href") !== href) return
started = performance.now()
for (const [name, selector] of Object.entries(reviewLevels)) {
initialReviewNodes[name] = document.querySelector(selector)
}
requestAnimationFrame(sample)
},
{ capture: true, once: true },
)
const start = (event: MouseEvent) => {
if (started !== undefined || event.button !== 0) return
const link = event.target instanceof Element ? event.target.closest("a") : undefined
if (link?.getAttribute("href") !== href) return
started = performance.now()
for (const [name, selector] of Object.entries(reviewLevels)) {
initialReviewNodes[name] = document.querySelector(selector)
}
requestAnimationFrame(sample)
}
// Tabs activate on mousedown; click alone misses the synchronous navigation work.
document.addEventListener("mousedown", start, true)
document.addEventListener("click", start, true)
;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe = {
samples,
stop: () => {
running = false
document.removeEventListener("mousedown", start, true)
document.removeEventListener("click", start, true)
},
}
}, input)
@@ -53,6 +53,15 @@ test("reports missing correctness without throwing", () => {
expect(result.stableObservedMs).toBeNull()
})
test("does not report stability for only two correct samples", () => {
const result = classifySessionSwitch([
{ observedAtMs: 16, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
{ observedAtMs: 32, destination: ["destination"], source: [], hasVisibleRows: true, last: true, bottomErrorPx: 0 },
])
expect(result.firstCorrectObservedMs).toBe(16)
expect(result.stableObservedMs).toBeNull()
})
test("requires an explicitly tracked part to be visible", () => {
const result = classifySessionSwitch([
{
@@ -0,0 +1,103 @@
import { expect, test } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
const directory = "C:/Projects/extensions-demo"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const session = {
id: "ses_project_extensions",
title: "Existing session",
directory,
projectID: "proj_extensions_demo",
time: { created: 1700000000000, updated: 1700000000000 },
}
test.use({ viewport: { width: 1440, height: 1000 }, colorScheme: "dark" })
test("project Extensions stays inside settings while plugins load", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: session.projectID,
canonical: directory,
name: "Extensions demo",
vcs: "git",
time: session.time,
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [session],
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ server, sessionID, directory }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] } }),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ server, sessionID: session.id, directory },
)
const href = `/server/${base64Encode(server)}/session/${session.id}`
await page.goto(href)
await expect(page.getByRole("heading", { name: session.title, exact: true })).toBeVisible()
await page.keyboard.press("Control+,")
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
await settings.getByText("Extensions demo", { exact: true }).click()
const dialog = page.getByRole("dialog")
await expect(dialog.getByRole("textbox", { name: "Name", exact: true })).toBeFocused()
const globalPlugins = Promise.withResolvers<void>()
const projectPlugins = Promise.withResolvers<void>()
await page.route(
(url) => url.pathname === "/api/plugin",
async (route) => {
const project = new URL(route.request().url()).searchParams.get("location[directory]")
await (project ? projectPlugins : globalPlugins).promise
await route.fulfill({
json: {
location: project ? { directory: project } : {},
data: (project ? ["shared-plugin", "project-plugin"] : ["shared-plugin"]).map((id) => ({
id,
source: { type: "package", package: id },
status: "active",
tui: false,
})),
},
})
},
)
const requested = page.waitForRequest((request) => {
const url = new URL(request.url())
return url.pathname === "/api/plugin" && url.searchParams.get("location[directory]") === directory
})
await dialog.getByRole("tab", { name: "Extensions", exact: true }).click()
await requested
await expect(page).toHaveURL(href)
await expect(dialog.getByRole("heading", { name: "Extensions", exact: true })).toBeVisible()
await expect(settings).toBeVisible()
await expect(page.getByRole("heading", { name: session.title, exact: true, includeHidden: true })).toBeHidden()
await dialog.getByRole("tab", { name: "Plugins", exact: true }).click()
await expect(dialog.getByRole("tab", { name: "Plugins", exact: true })).toHaveAttribute("aria-selected", "true")
globalPlugins.resolve()
await dialog.getByRole("tab", { name: "Scripts", exact: true }).click()
await expect(dialog.getByRole("heading", { name: "Scripts", exact: true })).toBeVisible()
await dialog.getByRole("tab", { name: "Extensions", exact: true }).click()
projectPlugins.resolve()
await dialog.getByRole("tab", { name: "Plugins", exact: true }).click()
await expect(dialog.getByText("project-plugin", { exact: true })).toBeVisible()
await dialog.getByRole("button", { name: "Shared with all projects 1", exact: true }).click()
await expect(dialog.getByText("shared-plugin", { exact: true })).toBeVisible()
await expect(page).toHaveURL(href)
await page.keyboard.press("Escape")
await expect(dialog).toBeHidden()
await expect(settings.getByRole("tab", { name: "Projects", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(page.getByRole("heading", { name: session.title, exact: true, includeHidden: true })).toBeHidden()
})
@@ -1,5 +1,5 @@
import { expect, test, type Page } from "@playwright/test"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client/promise"
import { base64Encode } from "@opencode-ai/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
@@ -18,7 +18,7 @@ type InboxRow = {
delivery: "steer" | "queue"
}
function createQueueMock(seed: string[]) {
function createQueueMock(seed: string[], messages: SessionMessageInfo[] = []) {
const rows: InboxRow[] = seed.map((text, index) => ({
id: `inb_seed_${index + 1}`,
sessionID,
@@ -32,13 +32,16 @@ function createQueueMock(seed: string[]) {
const changes: { inboxID: string; action: "cancel" | "steer" }[] = []
const log: string[] = []
let sequence = 0
const emit = (type: OpenCodeEvent["type"], data: OpenCodeEvent["data"]) => {
const emit = <Type extends OpenCodeEvent["type"]>(
type: Type,
data: Extract<OpenCodeEvent, { type: Type }>["data"],
) => {
sequence += 1
events.push({
id: `evt_queue_${sequence}`,
type,
created: Date.now(),
durable: { aggregateID: sessionID, seq: sequence, version: 1 },
durable: { aggregateID: sessionID, seq: sequence, version: type === "session.tool.success" ? 2 : 1 },
data,
} as OpenCodeEvent)
}
@@ -47,6 +50,8 @@ function createQueueMock(seed: string[]) {
prompts,
changes,
log,
messages,
emit,
events: () => events.splice(0),
onPrompt: (input: { sessionID: string; body: Record<string, unknown> }) => {
prompts.push(input.body)
@@ -126,10 +131,11 @@ async function openSession(page: Page, mock: ReturnType<typeof createQueueMock>,
directory,
title: "Session queue regression",
version: "dev",
model: { id: "queue-model", providerID: "opencode" },
time: { created: 1700000000000, updated: 1700000000000 },
},
],
pageMessages: () => ({ items: [] }),
pageMessages: () => ({ items: mock.messages }),
sessionStatus: () => ({ [sessionID]: { type: "running" } }),
inbox: () => mock.rows.map((row) => ({ ...row, payload: { ...row.payload } })),
onPrompt: mock.onPrompt,
@@ -227,3 +233,130 @@ test("editing restores the existing draft and replaces only the original queue p
expect(mock.changes.map((change) => change.action)).toEqual(["cancel", "cancel", "cancel"])
expect(mock.log[0]).toBe("prompt:queue")
})
for (const delivery of ["steer", "queue"] as const) {
test(`keeps finished tools above a pending ${delivery === "queue" ? "queue-to-steer" : "steer"} follow-up`, async ({
page,
}, testInfo) => {
const model = { id: "queue-model", providerID: "opencode" }
const userID = "msg_queue_initial_user"
const assistantID = "msg_queue_continued_assistant"
const followUp = "U2: Also check the retry path."
const mock = createQueueMock(
[],
[
{ id: userID, type: "user", text: "U1: Inspect the queue ordering.", time: { created: 1700000000000 } },
{
id: "msg_queue_initial_assistant",
type: "assistant",
agent: "build",
model,
content: [{ type: "text", text: "A1: I will inspect the current implementation." }],
finish: "tool-calls",
time: { created: 1700000000001, completed: 1700000000002 },
},
],
)
const view = await openSession(page, mock, delivery)
const transcript = page.locator("[data-timeline-virtual-content]")
const thinking = transcript.locator('[data-timeline-row="Thinking"]')
await expect(transcript.getByText("A1: I will inspect the current implementation.", { exact: true })).toBeVisible()
await expect(thinking).toBeVisible()
await expect(view.input).toBeEditable()
await view.input.fill(followUp)
await view.input.press("Enter")
await expect.poll(() => mock.rows.map((row) => row.delivery)).toEqual([delivery])
await expect(view.input).toHaveText("")
const inboxID = mock.rows[0].id
const pending = transcript.locator(`[data-timeline-row="UserMessage"][data-message-id="${inboxID}"]`)
if (delivery === "queue") {
const queued = view.rows.filter({ hasText: followUp })
await expect(queued).toBeVisible()
await expect(pending).toHaveCount(0)
await queued.hover()
await queued.getByRole("button", { name: "Steer", exact: true }).click()
await expect.poll(() => mock.changes).toEqual([{ inboxID, action: "steer" }])
}
await expect(view.rows).toHaveCount(0)
await expect(pending).toContainText(followUp)
// The next assistant step still belongs to U1: U2 has been admitted, not delivered.
mock.emit("session.step.started", { sessionID, assistantMessageID: assistantID, agent: "build", model })
for (const tool of [
{ id: "tool_queue_read", name: "read", input: { path: "src/queue.ts" } },
{ id: "tool_queue_grep", name: "grep", input: { pattern: "retry", path: "src" } },
]) {
const ref = { sessionID, assistantMessageID: assistantID, id: tool.id }
mock.emit("session.tool.input.started", { ...ref, name: tool.name })
mock.emit("session.tool.input.ended", { ...ref, text: JSON.stringify(tool.input) })
mock.emit("session.tool.called", { ...ref, input: tool.input, executed: true })
mock.emit("session.tool.success", {
...ref,
content: [{ type: "text", text: "Inspection complete." }],
executed: true,
})
}
mock.emit("session.step.ended", {
sessionID,
assistantMessageID: assistantID,
finish: "tool-calls",
cost: 0,
tokens: { input: 100, output: 20, reasoning: 0, cache: { read: 0, write: 0 } },
})
const tools = page.locator('[data-timeline-part-ids="tool_queue_read,tool_queue_grep"]')
await expect(tools).toBeVisible()
await expect(tools).toContainText(/Used\s*Read, Grep/)
await expect(tools.locator('[data-component="tag"]')).toHaveText("2")
await expect(thinking).toBeVisible()
await expect(pending).toBeVisible()
expect(mock.rows.map((row) => ({ id: row.id, delivery: row.delivery }))).toEqual([
{ id: inboxID, delivery: "steer" },
])
await transcript.screenshot({ path: testInfo.outputPath("pending-steer.png") })
// Soft assertions let delivery run too, even when the pending ordering regresses.
await expect
.soft(tools.or(thinking).or(pending))
.toHaveText([/Used\s*Read, Grep/, /Thinking/, /U2: Also check the retry path\./])
await expect
.soft(transcript.locator('[data-timeline-row="AssistantPart"]').filter({ has: tools }))
.toHaveAttribute("data-message-id", userID)
await expect
.configure({ soft: true })
.poll(async () => {
const boxes = await Promise.all([tools.boundingBox(), thinking.boundingBox(), pending.boundingBox()])
return (
boxes.every((box) => box !== null) &&
boxes[0]!.y + boxes[0]!.height <= boxes[1]!.y &&
boxes[1]!.y + boxes[1]!.height <= boxes[2]!.y
)
})
.toBe(true)
mock.rows.splice(0, 1)
mock.emit("session.inbox.delivered", { sessionID, inboxID })
await expect(thinking).toHaveAttribute("data-message-id", inboxID)
await expect(pending).toHaveCount(1)
await expect(transcript.locator('[data-timeline-row="UserMessage"]')).toHaveCount(2)
await expect(transcript.locator('[data-timeline-row="AssistantPart"]').filter({ has: tools })).toHaveAttribute(
"data-message-id",
userID,
)
const later = { sessionID, assistantMessageID: "msg_queue_follow_up_assistant" }
mock.emit("session.step.started", { ...later, agent: "build", model })
mock.emit("session.text.started", { ...later, ordinal: 0 })
mock.emit("session.text.ended", { ...later, ordinal: 0, text: "A3: Now checking the retry path for U2." })
const response = transcript
.locator('[data-timeline-row="AssistantPart"]')
.filter({ hasText: "A3: Now checking the retry path for U2." })
await expect(response).toHaveAttribute("data-message-id", inboxID)
await expect(tools.or(pending).or(response).or(thinking)).toHaveText([
/Used\s*Read, Grep/,
/U2: Also check the retry path\./,
/A3: Now checking the retry path for U2\./,
/Thinking/,
])
})
}
@@ -208,6 +208,56 @@ test("navigates from a running subagent card and hides background controls in th
await expect(page.getByText(/move running work to the background/i)).toHaveCount(0)
})
for (const name of ["shell", "subagent"] as const) {
test(`keeps the background shortcut available for a grouped running ${name}`, async ({ page }) => {
const message = assistant(false, true)
await setupTimeline(page, {
sessionMessages: [
user,
{
...message,
content: [
{
type: "tool",
id: "call_read",
name: "read",
state: {
status: "completed",
input: { path: "src/example.ts" },
content: [{ type: "text", text: "export const example = true" }],
metadata: {},
},
time: { created: 1, completed: 2 },
},
{
type: "tool",
id: "call_running",
name,
state: {
status: "running",
input:
name === "shell" ? { command: "echo checking" } : { agent: "general", description: "Inspect code" },
metadata: {},
},
time: { created: 3 },
},
],
},
],
})
const group = page.locator('[data-timeline-part-ids="call_read,call_running"]')
await expect(group).toBeVisible()
await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
await expect(page.locator('[data-component="session-background-hint"]')).toBeVisible()
const request = page.waitForRequest(
(request) =>
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/background`,
)
await page.keyboard.press("Control+b")
await request
})
}
test("shows a badge for active background work", async ({ page }) => {
const childID = "ses_background_child"
await setupTimeline(page, {
@@ -44,7 +44,7 @@ test("expands a mixed collapsed tool stack without expanding its individual call
const group = page.locator(
'[data-timeline-part-ids="prt_stack_shell_1,prt_stack_explore,prt_stack_patch,prt_stack_shell_2"]',
)
const summary = group.getByRole("button", { name: "Used Shell, Explore, Patch" })
const summary = group.getByRole("button", { name: "Used Shell, Agent, Patch" })
await expect(summary).toHaveAttribute("aria-expanded", "false")
await expect(summary).toHaveCSS("height", "28px")
await expect(group.locator('[data-component="tag"]')).toHaveText("4")
@@ -0,0 +1,121 @@
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
const directory = "C:/Projects/settings-demo"
const sandboxes = Array.from({ length: 12 }, (_, index) => `${directory}/workspace-${index + 1}`)
test.use({ viewport: { width: 1440, height: 1000 }, colorScheme: "dark" })
test.beforeEach(async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: "proj_settings_demo",
canonical: directory,
name: "Settings demo",
vcs: "git",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes,
},
provider: { all: [], connected: [], default: {} },
sessions: sandboxes.map((directory, index) => ({
id: `ses_settings_${index + 1}`,
title: `Workspace ${index + 1} session`,
directory,
projectID: "proj_settings_demo",
time: { created: 1700000000000, updated: 1700000000000 },
})),
pageMessages: () => ({ items: [] }),
})
await page.goto("/")
await page.getByRole("button", { name: "Settings", exact: true }).click()
await expect(page.getByTestId("settings-screen").getByRole("tab", { name: "Preferences" })).toBeVisible()
})
test("workspaces opens without waiting for inventory or sessions", async ({ page }) => {
const inventory = Promise.withResolvers<void>()
const sessions = Promise.withResolvers<void>()
await page.route("**/api/worktree/*", async (route) => {
await inventory.promise
await route.fallback()
})
await page.route("**/api/session?*", async (route) => {
if (new URL(route.request().url()).searchParams.has("directory")) await sessions.promise
await route.fallback()
})
const settings = page.getByTestId("settings-screen")
const requested = page.waitForRequest((request) => new URL(request.url()).pathname.startsWith("/api/worktree/"))
await settings.getByRole("tab", { name: "Workspaces", exact: true }).click()
await requested
await expect(settings.getByRole("heading", { name: "Workspaces", exact: true })).toBeVisible()
await expect(settings.getByRole("button", { name: "Back to app" })).toBeVisible()
await expect(settings.getByText("No workspaces", { exact: true })).toHaveCount(0)
inventory.resolve()
await expect(settings.getByText(sandboxes[0], { exact: true })).toBeVisible()
await expect(settings.getByText("12 workspaces", { exact: true })).toBeVisible()
sessions.resolve()
await expect(settings.getByText("Workspace 1 session", { exact: true })).toBeVisible()
const refresh = Promise.withResolvers<void>()
await page.route("**/api/worktree/*", async (route) => {
await refresh.promise
await route.fallback()
})
await settings.getByRole("tab", { name: "Preferences", exact: true }).click()
await settings.getByRole("tab", { name: "Workspaces", exact: true }).click()
await expect(settings.getByText("Workspace 1 session", { exact: true })).toBeVisible()
refresh.resolve()
})
test("extensions opens without waiting for MCPs or plugins", async ({ page }) => {
const mcps = Promise.withResolvers<void>()
const plugins = Promise.withResolvers<void>()
await page.route("**/api/mcp", async (route) => {
await mcps.promise
await route.fulfill({
json: { location: { directory }, data: [{ name: "demo-mcp", status: { status: "connected" } }] },
})
})
await page.route("**/api/plugin", async (route) => {
await plugins.promise
await route.fulfill({
json: {
location: { directory },
data: [
{ id: "demo-plugin", source: { type: "package", package: "demo-plugin" }, status: "active", tui: false },
],
},
})
})
const settings = page.getByTestId("settings-screen")
const requested = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/mcp")
await settings.getByRole("tab", { name: "Extensions", exact: true }).click()
await requested
await expect(settings.getByRole("heading", { name: "Extensions", exact: true })).toBeVisible()
await expect(settings.getByRole("button", { name: "Back to app" })).toBeVisible()
await settings.getByRole("tab", { name: "Plugins", exact: true }).click()
await expect(settings.getByRole("tab", { name: "Plugins", exact: true })).toHaveAttribute("aria-selected", "true")
plugins.resolve()
await expect(settings.getByText("demo-plugin", { exact: true })).toBeVisible()
mcps.resolve()
await settings.getByRole("tab", { name: "MCPs", exact: true }).click()
await expect(settings.getByRole("switch", { name: "demo-mcp" })).toBeChecked()
})
test("workspace inventory uses the settings panel scroll area", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Workspaces", exact: true }).click()
await expect(settings.getByText("Workspace 1 session", { exact: true })).toBeVisible()
const list = settings.locator('[data-component="settings-list"]')
await expect(list).toHaveCSS("max-height", "none")
await expect(list).toHaveCSS("overflow-y", "visible")
await settings.getByText("Workspace 12 session", { exact: true }).scrollIntoViewIfNeeded()
await expect(settings.getByText("Workspace 12 session", { exact: true })).toBeInViewport()
await expect(settings.getByRole("button", { name: "Back to app" })).toBeInViewport()
await page.setViewportSize({ width: 390, height: 844 })
await expect(list).toHaveCSS("max-height", "none")
await expect(list).toHaveCSS("overflow-y", "visible")
await settings.getByText("Workspace 12 session", { exact: true }).scrollIntoViewIfNeeded()
await expect(settings.getByText("Workspace 12 session", { exact: true })).toBeInViewport()
})
@@ -52,7 +52,7 @@ test("shows parent lineage while the child timeline loads", async ({ page }) =>
await page.goto(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
await page.getByRole("button", { name: "Used Explore" }).click()
await page.getByRole("button", { name: "Used Agent" }).click()
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
await Promise.all([requested.promise, expect(page).toHaveURL(sessionHref(childID))])
await Promise.all([
@@ -77,7 +77,7 @@ test("keeps the parent visible while the child session resolves", async ({ page
await page.goto(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
await page.getByRole("button", { name: "Used Explore" }).click()
await page.getByRole("button", { name: "Used Agent" }).click()
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
await requested.promise
await Promise.all([expect(page).toHaveURL(sessionHref(parentID)), expectSessionTitle(page, parentTitle)]).finally(
@@ -195,7 +195,7 @@ async function setup(page: Page, events?: () => OpenCodeEvent[]) {
async function openChildFromParent(page: Page) {
await page.goto(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
await page.getByRole("button", { name: "Used Explore" }).click()
await page.getByRole("button", { name: "Used Agent" }).click()
const card = page.locator(`a[href="${sessionHref(childID)}"]`)
await expect(card).toBeVisible()
+1 -2
View File
@@ -8,7 +8,7 @@ import type { SessionModel } from "./model"
import { sessionPanelLayout } from "./session-panel-layout"
import { clampSessionPanelWidth, sessionPanelWidthMax } from "./session-panel-width"
export function createSessionScreenLayout(session: SessionModel, serverScope: string) {
export function createSessionScreenLayout(session: SessionModel) {
const layout = useLayout()
const settings = useSettings()
const size = createSizing()
@@ -92,7 +92,6 @@ export function createSessionScreenLayout(session: SessionModel, serverScope: st
centered: createMemo(() => session.isDesktop()),
files: { open: fileTreeOpen },
panel: {
key: createMemo(() => (session.identity.params.id ? `${serverScope}\0${session.identity.params.id}` : undefined)),
max: panelMax,
ref: (element: HTMLDivElement) => {
row = element
+5 -9
View File
@@ -4,7 +4,6 @@ import createPresence from "solid-presence"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { SessionHeader } from "@/session/header/session-header"
import { useLayout } from "@/shell/state/layout"
import { useServerSDK } from "@/runtime/server/client"
import { useSettings } from "@/settings/model"
import { MessageTimeline } from "@/session/timeline/message-timeline"
import type { SessionModel } from "@/session/model"
@@ -23,10 +22,9 @@ import { SessionIdentityHeader } from "./session-identity-header"
export function SessionScreen(props: { session: SessionModel }) {
const session = props.session
const layout = useLayout()
const serverSDK = useServerSDK()
const settings = useSettings()
const isDesktop = session.isDesktop
const screen = createSessionScreenLayout(session, serverSDK.scope)
const screen = createSessionScreenLayout(session)
const timeline = createSessionTimelineInteraction(session)
const messagesReady = timeline.ready
const [store, setStore] = createStore({
@@ -177,12 +175,10 @@ export function SessionScreen(props: { session: SessionModel }) {
width: screen.panel.width(),
}}
>
<Show when={screen.panel.key()} keyed>
{(_) => (
<SessionPanelFrame raised={!!session.identity.params.id}>
<ErrorBoundary fallback={sessionErrorFallback}>{sessionPanelContent()}</ErrorBoundary>
</SessionPanelFrame>
)}
<Show when={!!session.identity.params.id}>
<SessionPanelFrame raised>
<ErrorBoundary fallback={sessionErrorFallback}>{sessionPanelContent()}</ErrorBoundary>
</SessionPanelFrame>
</Show>
<Show when={screen.panel.resizable()}>
@@ -1,6 +1,8 @@
import { describe, expect, test } from "bun:test"
import type { SessionInboxInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
import { createRoot } from "solid-js"
import { applyTimelineMessageHandoff, visibleTimelineMessages } from "./controller-projection"
import { createTimelineProjection } from "./projection"
const messages = [
{ id: "msg_1", type: "user", text: "first", time: { created: 1 } },
@@ -17,6 +19,105 @@ const messages = [
] satisfies SessionMessageInfo[]
describe("visibleTimelineMessages", () => {
const steer = {
id: "msg_3",
sessionID: "ses_1",
timeCreated: 3,
type: "user",
delivery: "steer",
payload: { text: "queued" },
} satisfies SessionInboxInfo
const work = {
id: "msg_5",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [
{
type: "tool",
id: "tool_read",
name: "read",
state: {
status: "completed",
input: { filePath: "src/example.ts" },
content: [{ type: "text", text: "export const example = true" }],
metadata: {},
},
time: { created: 5, completed: 6 },
},
],
time: { created: 5, completed: 6 },
} satisfies SessionMessageInfo
test("keeps work and thinking above an undelivered steer", () => {
const source = [...messages.slice(0, 3), work]
const visible = visibleTimelineMessages(source, [steer])
expect(visible.map((message) => message.id)).toEqual(["msg_1", "msg_2", "msg_5", "msg_3"])
expect(source.map((message) => message.id)).toEqual(["msg_1", "msg_2", "msg_3", "msg_5"])
expect(visible[2]).toBe(work)
createRoot((dispose) => {
const projection = createTimelineProjection({
sessionMessages: () => visible,
status: () => ({ type: "busy" }),
showReasoningSummaries: () => false,
shellToolDefaultOpen: () => false,
editToolDefaultOpen: () => false,
pendingUserMessageIDs: () => new Set([steer.id]),
})
expect(projection.activeMessageID()).toBe("msg_1")
expect(projection.rows().map((row) => [row._tag, row.userMessageID])).toEqual([
["UserMessage", "msg_1"],
["AssistantPart", "msg_1"],
["Thinking", "msg_1"],
["TurnGap", "msg_3"],
["UserMessage", "msg_3"],
])
expect(
projection
.assistantMessagesByParent()
.get("msg_1")
?.map((message) => message.id),
).toEqual(["msg_2", "msg_5"])
expect(projection.assistantMessagesByParent().has(steer.id)).toBe(false)
dispose()
})
})
test("moves a queued input after existing work when changed to steer", () => {
const source = [...messages.slice(0, 3), work]
expect(visibleTimelineMessages(source, [{ ...steer, delivery: "queue" }]).map((message) => message.id)).toEqual([
"msg_1",
"msg_2",
"msg_5",
])
expect(visibleTimelineMessages(source, [steer]).map((message) => message.id)).toEqual([
"msg_1",
"msg_2",
"msg_5",
"msg_3",
])
const delivered = [messages[0], messages[1], work, messages[2]]
expect(visibleTimelineMessages(delivered, [])).toBe(delivered)
})
test("preserves steer order and excludes reverted steers", () => {
const source = [...messages, work]
const pending = [steer, { ...steer, id: "msg_4" }]
expect(visibleTimelineMessages(source, pending).map((message) => message.id)).toEqual([
"msg_1",
"msg_2",
"msg_5",
"msg_3",
"msg_4",
])
expect(visibleTimelineMessages(source, pending, "msg_4").map((message) => message.id)).toEqual([
"msg_1",
"msg_2",
"msg_3",
])
})
test("hides queued inputs until delivery", () => {
const pending = [
{
@@ -17,8 +17,19 @@ export function visibleTimelineMessages(
const queued = new Set(
pending.flatMap((item) => (item.type === "user" && item.delivery === "queue" ? [item.id] : [])),
)
if (queued.size === 0 && !revertMessageID) return messages
return messages.filter((message) => !queued.has(message.id) && (!revertMessageID || message.id < revertMessageID))
const steers = new Set(
pending.flatMap((item) => (item.type === "user" && item.delivery === "steer" ? [item.id] : [])),
)
if (queued.size === 0 && steers.size === 0 && !revertMessageID) return messages
const visible = messages.filter(
(message) => !queued.has(message.id) && (!revertMessageID || message.id < revertMessageID),
)
if (steers.size === 0) return visible
// Pending steers do not own assistant work until they are delivered.
return [
...visible.filter((message) => !steers.has(message.id)),
...visible.filter((message) => steers.has(message.id)),
]
}
export function timelineChildTitle(input: {
@@ -349,8 +349,7 @@ function MessageTimelineView(
: projects.find((item) => containsDirectory(item.worktree, sessionDirectory()))
})
const workspaceSession = createMemo(() => isWorkspaceDirectory(project(), sessionDirectory()))
const showProjectIcon = () =>
import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" && settings.general.showProjectIcon()
const showProjectIcon = () => import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" && settings.general.showProjectIcon()
const avatarProject = createMemo(() => {
if (!showProjectIcon()) return
const session = props.session.data.info()
@@ -469,13 +468,13 @@ function MessageTimelineView(
})
const backgroundHintPartID = createMemo(() => {
const blocking = new Set(props.background.blocking().map((task) => task.partID))
const row = projection
if (blocking.size === 0) return
return projection
.rows()
.findLast(
(row) => row._tag === "AssistantPart" && row.group.type === "part" && blocking.has(row.group.ref.partID),
.flatMap((row) =>
row._tag === "AssistantPart" ? (row.group.type === "part" ? [row.group.ref] : row.group.refs) : [],
)
if (row?._tag !== "AssistantPart" || row.group.type !== "part") return
return row.group.ref.partID
.findLast((ref) => blocking.has(ref.partID))?.partID
})
const [backgroundHintRef, setBackgroundHintRef] = createSignal<HTMLDivElement>()
const backgroundHintPresence = createAnimatedPresence(backgroundHintPartID, () => backgroundHintRef() ?? null)
@@ -1,8 +1,15 @@
import { createVirtualizer, defaultRangeExtractor, elementScroll, type VirtualItem } from "@tanstack/solid-virtual"
import {
createVirtualizer,
defaultRangeExtractor,
elementScroll,
type Range,
type VirtualItem,
} from "@tanstack/solid-virtual"
import { isScrollKeyTarget, scrollKey, scrollKeyOwner, ScrollView } from "@opencode-ai/ui/scroll-view"
import { TimelineRow } from "@opencode-ai/session-ui/timeline/projection"
import { useLanguage } from "@/runtime/i18n/language"
import {
batch,
createEffect,
createMemo,
createSignal,
@@ -67,11 +74,12 @@ export function createTimelineVirtualizer(input: Input) {
const coldBottomMount = !initialMeasurements?.length && input.pinned()
const [listRoot, setListRoot] = createSignal<HTMLDivElement>()
const [toolOpen, setToolOpen] = createStore<Record<string, boolean | undefined>>(cached?.toolOpen ?? {})
const [renderOverscan, setRenderOverscan] = createSignal(initialMeasurements?.length || coldBottomMount ? 6 : 20)
const [overscan, setOverscan] = createSignal(2)
const rows = input.projection.rows
const rowByKey = input.projection.rowByKey
const knownKeys = new Set(rows().map(TimelineRow.key))
const addedKeys = new Set<string>()
const measuredElements = new WeakSet<Element>()
let touchStart: number | undefined
let pointerHeld = false
let maxScroll = 0
@@ -91,14 +99,19 @@ export function createTimelineVirtualizer(input: Input) {
initialOffset: () => (input.pinned() ? Number.MAX_SAFE_INTEGER : 0),
initialMeasurementsCache: initialMeasurements,
estimateSize: () => fallbackItemSize,
// Do not replace this with TanStack's default measurer: without a ResizeObserver entry,
// it returns the cached height instead of reading the element (TanStack/virtual#1183).
// Restored sessions, deferred tools, and rewrapped content can then keep stale heights;
// our fixed-height, overflow-clipped rows will hide their content. Keep observer entries
// on the cheap precomputed path, but make explicit measurements read the real height.
measureElement: (element, entry) => {
// A newly observed element gets a real ResizeObserver box before paint. Reuse
// its snapshot on attachment, but later explicit measurements must read layout
// so deferred/rewrapped content cannot keep stale, clipped heights (TanStack/virtual#1183).
measureElement: (element, entry, instance) => {
const initial = !measuredElements.has(element)
measuredElements.add(element)
const box = entry?.borderBoxSize[0]
return box ? Math.round(box.blockSize) : element.offsetHeight
if (box) return Math.round(box.blockSize)
if (initial) {
const size = instance.itemSizeCache.get(instance.options.getItemKey(instance.indexFromElement(element)))
if (size !== undefined) return size
}
return element.offsetHeight
},
scrollToFn: (offset, options, instance) => {
if (virtualContent) virtualContent.style.height = `${instance.getTotalSize()}px`
@@ -130,41 +143,59 @@ export function createTimelineVirtualizer(input: Input) {
return input.showHeader() ? 64 : 0
},
paddingEnd: 64,
rangeExtractor: (range) => {
get rangeExtractor() {
const id = input.projection.activeMessageID()
const active = id ? (input.projection.messageLastRowIndex().get(id) ?? -1) : -1
const indexes = defaultRangeExtractor({ ...range, overscan: renderOverscan() })
return filterVirtualIndexes(
[...new Set([...indexes, ...(active < 0 ? [] : [active])])].sort((a, b) => a - b),
range.count,
)
const buffer = overscan()
return (range: Range) => {
const indexes = defaultRangeExtractor({ ...range, overscan: buffer })
return filterVirtualIndexes(
[...new Set([...indexes, ...(active < 0 ? [] : [active])])].sort((a, b) => a - b),
range.count,
)
}
},
})
const resizeItem = virtualizer.resizeItem
let resizeAnchorScheduled = false
// Rows measure asynchronously, so the last row can still hold its estimate when TanStack
// reconciles the end. Coalesce one correction per measurement batch, before paint.
const anchorResizedBottom = () => {
if (resizeAnchorScheduled) return
resizeAnchorScheduled = true
const pendingSizes = new Map<number, { key: string; size: number }>()
let resizeScheduled = false
// Read the whole measurement delivery before committing reactive row sizes.
// Otherwise each row can render and force layout before the next is measured.
virtualizer.resizeItem = (index, size) => {
const row = rows()[index]
if (!row) return
const key = TimelineRow.key(row)
if (virtualizer.itemSizeCache.get(key) === size) {
pendingSizes.delete(index)
return
}
pendingSizes.set(index, { key, size })
if (resizeScheduled) return
resizeScheduled = true
queueMicrotask(() => {
resizeAnchorScheduled = false
resizeScheduled = false
if (!pendingSizes.size) return
const sizes = [...pendingSizes]
pendingSizes.clear()
batch(() => {
sizes.forEach(([index, value]) => {
const row = rows()[index]
if (row && TimelineRow.key(row) === value.key) resizeItem(index, value.size)
})
})
if (!input.pinned()) return
virtualizer.scrollToEnd()
const root = listRoot()
// Reopening a settled scroll-to-end operation can fight subsequent keyboard scrolling.
if (root && Math.abs(root.scrollHeight - root.clientHeight - root.scrollTop) > endEpsilon)
virtualizer.scrollToEnd()
})
}
virtualizer.resizeItem = (index, size) => {
resizeItem(index, size)
if (listRoot() && input.pinned()) anchorResizedBottom()
}
onCleanup(() => pendingSizes.clear())
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => {
// Prepended rows can resize more than once as deferred content mounts. Keep
// compensating while they remain entirely above the visible content fold.
if (addedKeys.has(String(item.key)))
return (
item.end <=
(instance.scrollOffset ?? 0) + instance.scrollAdjustments + instance.options.scrollMargin
)
return item.end <= (instance.scrollOffset ?? 0) + instance.scrollAdjustments + instance.options.scrollMargin
const first = instance.range?.startIndex
return first !== undefined && item.index < first
}
@@ -185,50 +216,41 @@ export function createTimelineVirtualizer(input: Input) {
})
})
let settleFrame: number | undefined
let overscanFrame: number | undefined
let overscanTimer: number | undefined
const expandOverscan = () => {
overscanFrame = requestAnimationFrame(() => {
overscanFrame = undefined
// Let the visible rows paint before building the normal interaction buffer.
overscanTimer = window.setTimeout(() => {
overscanTimer = undefined
setOverscan(20)
}, 0)
})
}
const pendingMeasurements = () =>
virtualizer.getVirtualItems().some((item) => !virtualizer.itemSizeCache.has(item.key))
const settleColdBottom = () => {
if (input.pinned()) virtualizer.scrollToEnd()
if (virtualContent?.querySelector(pendingMarkdown) || pendingMeasurements()) {
overscanFrame = requestAnimationFrame(settleColdBottom)
settleFrame = requestAnimationFrame(settleColdBottom)
return
}
overscanFrame = requestAnimationFrame(() => {
settleFrame = requestAnimationFrame(() => {
if (input.pinned()) virtualizer.scrollToEnd()
if (virtualContent?.querySelector(pendingMarkdown) || pendingMeasurements()) {
settleColdBottom()
return
}
overscanFrame = undefined
const content = virtualContent
if (!content) return
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
content.style.removeProperty("visibility")
return
}
const animation = ["animate-in", "fade-in", "duration-150"]
const clearAnimation = (event: AnimationEvent) => {
if (event.target !== content) return
content.removeEventListener("animationend", clearAnimation)
content.removeEventListener("animationcancel", clearAnimation)
content.classList.remove(...animation)
}
content.addEventListener("animationend", clearAnimation)
content.addEventListener("animationcancel", clearAnimation)
content.classList.add(...animation)
content.style.removeProperty("visibility")
settleFrame = undefined
virtualContent?.style.removeProperty("visibility")
expandOverscan()
})
}
onMount(() => {
overscanFrame = requestAnimationFrame(() => {
if (renderOverscan() < 20) setRenderOverscan(20)
if (!coldBottomMount) {
overscanFrame = undefined
return
}
settleColdBottom()
})
if (coldBottomMount) settleFrame = requestAnimationFrame(settleColdBottom)
if (!coldBottomMount) expandOverscan()
})
let measuredSessionKey = input.sessionKey()
@@ -255,11 +277,13 @@ export function createTimelineVirtualizer(input: Input) {
const handleListWheel = (event: WheelEvent & { currentTarget: HTMLDivElement }) => {
input.onUserScroll(event.target)
if (event.deltaY < 0) input.onUnpin()
setOverscan(20)
}
const handleListTouchStart = (event: TouchEvent) => {
input.onUserScroll(event.target)
touchStart = event.touches[0]?.clientY
setOverscan(20)
}
const handleListTouchMove = (event: TouchEvent & { currentTarget: HTMLDivElement }) => {
@@ -276,14 +300,19 @@ export function createTimelineVirtualizer(input: Input) {
const handleListPointerDown = (event: PointerEvent & { currentTarget: HTMLDivElement }) => {
input.onUserScroll(event.target)
pointerHeld = true
const release = () => {
pointerHeld = false
window.removeEventListener("pointerup", release)
window.removeEventListener("pointercancel", release)
}
window.addEventListener("pointerup", release)
window.addEventListener("pointercancel", release)
setOverscan(20)
}
const releasePointer = () => {
pointerHeld = false
}
onMount(() => {
window.addEventListener("pointerup", releasePointer)
window.addEventListener("pointercancel", releasePointer)
})
onCleanup(() => {
window.removeEventListener("pointerup", releasePointer)
window.removeEventListener("pointercancel", releasePointer)
})
const handleListKeyDown = (event: KeyboardEvent & { currentTarget: HTMLDivElement }) => {
const key = scrollKey(event)
@@ -292,6 +321,7 @@ export function createTimelineVirtualizer(input: Input) {
if (scrollKeyOwner(event.currentTarget, event.target, key) !== event.currentTarget) return
input.onUserScroll(event.currentTarget)
if (upwardKeys.has(key)) input.onUnpin()
setOverscan(20)
}
// Following resumes by arriving at the end, either by scrolling there or by content shrinking
@@ -414,7 +444,6 @@ export function createTimelineVirtualizer(input: Input) {
<Show when={input.showHeader()}>{props.header}</Show>
<div
data-timeline-virtual-content
class="motion-reduce:animate-none"
ref={(element) => {
virtualContent = element
input.setContentRef(element)
@@ -446,7 +475,9 @@ export function createTimelineVirtualizer(input: Input) {
cache.delete(ownerSessionKey)
cache.set(ownerSessionKey, { measurements: virtualizer.takeSnapshot(), toolOpen: { ...toolOpen } })
while (cache.size > 16) cache.delete(cache.keys().next().value!)
if (settleFrame !== undefined) cancelAnimationFrame(settleFrame)
if (overscanFrame !== undefined) cancelAnimationFrame(overscanFrame)
if (overscanTimer !== undefined) window.clearTimeout(overscanTimer)
input.setScrollRef(undefined)
input.setRevealMessage?.(() => {})
input.setScrollToEnd?.(() => {})
@@ -27,6 +27,7 @@ export const SettingsExtensions: Component = () => {
const [mcpList, { refetch: refetchMcp }] = createResource(
() => serverSdk.connection.status() === "connected",
() => serverSdk.api.mcp.list().then((result) => result.data),
{ initialValue: [] },
)
const toggleMcp = useMcpToggle(() => undefined, refetchMcp)
const mcps = createMemo<McpRowItem[]>(() => {
@@ -44,6 +45,7 @@ export const SettingsExtensions: Component = () => {
const [pluginList] = createResource(
() => serverSdk.connection.status() === "connected",
() => serverSdk.api.plugin.list().then((result) => result.data),
{ initialValue: [] },
)
const plugins = createMemo<PluginRowItem[]>(() => pluginLabels(pluginList.latest ?? []).map((name) => ({ name })))
-6
View File
@@ -885,8 +885,6 @@
display: flex;
flex-direction: column;
gap: 0;
max-height: 480px;
overflow-y: auto;
padding: 20px;
border-radius: 6px;
background-color: var(--v2-background-bg-base);
@@ -1048,8 +1046,6 @@
}
.settings-workspaces-inventory [data-component="settings-list"] {
max-height: none;
overflow-y: visible;
padding: 14px;
}
@@ -1082,8 +1078,6 @@
}
.settings-workspaces-inventory [data-component="settings-list"] {
max-height: none;
overflow-y: visible;
padding: 14px;
}
@@ -97,10 +97,12 @@ export const ProjectSettingsExtensions: Component = () => {
const [globalPluginList] = createResource(
() => serverSDK.connection.status() === "connected",
() => serverSDK.api.plugin.list().then((result) => result.data),
{ initialValue: [] },
)
const [projectPluginList] = createResource(
() => (serverSDK.connection.status() === "connected" ? directorySDK().directory : undefined),
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
{ initialValue: [] },
)
const globalPlugins = createMemo(() => pluginLabels(globalPluginList.latest ?? []))
const projectPlugins = createMemo(() => {
@@ -60,6 +60,7 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
const projectQuery = useQuery(() => ({
queryKey: [serverSDK.scope, "settings-workspace-projects"] as const,
enabled: serverSDK.connection.status() === "connected",
queryFn: async () =>
Promise.all(
(await serverSDK.api.project.list()).map(async (project) => {
@@ -71,10 +72,9 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
),
refetchOnMount: "always",
}))
const workspaces = createMemo(() => workspaceInventory(projectQuery.data ?? []))
const projects = createMemo(() =>
(projectQuery.data ?? []).filter((project) => managedWorkspaceDirectories(project).length > 0),
)
const inventory = createMemo(() => (projectQuery.isPending ? [] : (projectQuery.data ?? [])))
const workspaces = createMemo(() => workspaceInventory(inventory()))
const projects = createMemo(() => inventory().filter((project) => managedWorkspaceDirectories(project).length > 0))
const projectName = (project: Project) => project.name || getFilename(project.worktree)
const projectOptions = createMemo(() => [
{ id: "all", label: language.t("settings.workspaces.filter.all") },
@@ -110,18 +110,18 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
workspaceDirectories().map((directory) => String(pathKey(directory))),
] as const,
queryFn: () => loadSessions(workspaceDirectories()),
enabled: workspaceDirectories().length > 0,
enabled: serverSDK.connection.status() === "connected" && workspaceDirectories().length > 0,
refetchOnMount: "always",
}))
const sessionsByWorkspace = createMemo(
() =>
new Map(
workspaces().map((workspace) => [
pathKey(workspace.directory),
sessionQuery.data ? sessionsForWorkspace(sessionQuery.data, workspace.directory) : [],
]),
),
)
const sessionsByWorkspace = createMemo(() => {
const sessions = sessionQuery.isPending ? [] : (sessionQuery.data ?? [])
return new Map(
workspaces().map((workspace) => [
pathKey(workspace.directory),
sessionsForWorkspace(sessions, workspace.directory),
]),
)
})
const workspaceSessions = (workspace: Workspace) => sessionsByWorkspace().get(pathKey(workspace.directory)) ?? []
const sessionCount = (workspace: Workspace) => {
if (sessionQuery.isPending) return language.t("session.messages.loading")
@@ -279,7 +279,9 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
<div class="settings-tab-body settings-workspaces">
<div class="settings-workspaces-toolbar">
<span class="settings-workspaces-count">
{language.plural("settings.workspaces.count", filtered().length)}
<Show when={!projectQuery.isPending && !projectQuery.isError}>
{language.plural("settings.workspaces.count", filtered().length)}
</Show>
</span>
<div class="settings-workspaces-toolbar-actions">
<Show when={projects().length > 1}>
@@ -332,7 +334,17 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
<div class="settings-workspaces-inventory">
<Show
when={filtered().length > 0}
fallback={<div class="settings-workspaces-empty">{language.t("settings.workspaces.empty")}</div>}
fallback={
<div class="settings-workspaces-empty">
{language.t(
projectQuery.isPending
? "common.loading"
: projectQuery.isError
? "common.requestFailed"
: "settings.workspaces.empty",
)}
</div>
}
>
<SettingsList>
<For each={filtered()}>
-108
View File
@@ -1,108 +0,0 @@
export * as Application from "./application.js"
export { Options } from "./application/options.js"
import { Effect, Layer } from "effect"
import type { Options } from "./application/options.js"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import type { Node } from "@opencode-ai/util/effect/app-node"
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
import { Global } from "@opencode-ai/util/global"
import { App } from "./app.js"
import { Bus } from "./bus.js"
import { Config } from "./config.js"
import { Credential } from "./credential.js"
import { Database } from "./database/database.js"
import { AppNodeBuilder } from "./effect/app-node-builder.js"
import { EventLogger } from "./event-logger.js"
import { FileSystemSearch } from "./filesystem/search.js"
import { Watcher } from "./filesystem/watcher.js"
import { InstructionDiscovery } from "./instruction-discovery.js"
import { Job } from "./job.js"
import { LocationActivity } from "./location-activity.js"
import { LocationServiceMap } from "./location-service-map.js"
import { MCP } from "./mcp/index.js"
import { ModelsDev } from "./models-dev.js"
import { PermissionSaved } from "./permission/saved.js"
import { PersistentPty } from "./persistent-pty.js"
import { PluginRuntime } from "./plugin/runtime.js"
import { SdkPlugins } from "./plugin/sdk.js"
import { Project } from "./project.js"
import { PtyTicket } from "./pty/ticket.js"
import { Session } from "./session.js"
import { SessionRestart } from "./session/execution/restart.js"
import { SessionTransfer } from "./session/transfer.js"
import { ShellSelect } from "./shell/select.js"
import { WellKnown } from "./wellknown.js"
import { Workspace } from "./workspace.js"
import { Worktree } from "./worktree.js"
const services = LayerNode.group([
Global.node,
Database.node,
Bus.node,
EventLogger.node,
httpClient,
Job.node,
Project.node,
Worktree.node,
Session.node,
SessionTransfer.node,
SdkPlugins.node,
PermissionSaved.node,
PtyTicket.node,
PersistentPty.node,
Credential.node,
WellKnown.node,
LocationServiceMap.node,
LocationActivity.node,
SessionRestart.node,
Workspace.node,
])
/** Build the standard application without choosing an HTTP or process host. */
export function layer<A = never, E = never>(
options: Options = {},
overrides: LayerNode.Replacements = [],
extra?: Node.GlobalNode<A, E>,
) {
return build(LayerNode.group([services, ...(extra ? [extra] : [])]), [
[Database.node, Database.configured(options.database)],
[Bus.node, Bus.configured({ persist: options.events?.persist })],
[App.node, App.configured(options.app)],
[ModelsDev.node, ModelsDev.configured(options.models)],
[Watcher.node, Watcher.configured({ enabled: options.fs?.filewatcher })],
[FileSystemSearch.node, FileSystemSearch.configured({ fff: options.fs?.fff })],
[Global.node, Global.layerWith(options.config?.directory ? { config: options.config.directory } : {})],
[Config.node, Config.configured(options.config)],
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: options.config?.project })],
[ShellSelect.node, ShellSelect.configured({ gitbash: options.windows?.gitbash })],
[
MCP.node,
MCP.configured({
clientInfo: { name: options.app?.name ?? "opencode", version: options.app?.version ?? "unknown" },
}),
],
...overrides,
])
}
/** Own the global-to-Location runtime connection, including for focused application fixtures. */
export function build<A, E>(root: Node.GlobalNode<A, E>, overrides: LayerNode.Replacements = []) {
return Layer.effectContext(
Effect.gen(function* () {
const scope = yield* Effect.scope
const memoMap = yield* Layer.makeMemoMap
const cell = PluginRuntime.makeCell()
// Location factories must capture this same map, not an enclosing host's map.
return yield* Layer.buildWithMemoMap(
AppNodeBuilder.build(LayerNode.group([root, PluginRuntime.providerNode]), [
[PluginRuntime.node, PluginRuntime.layerWithCell(cell)],
[PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(cell)],
...overrides,
]),
memoMap,
scope,
)
}),
)
}
-34
View File
@@ -1,34 +0,0 @@
export * as ApplicationOptions from "./options.js"
import { Schema } from "effect"
import { Database } from "../database/database.js"
import { ModelsDev } from "../models-dev.js"
export const Options = Schema.Struct({
app: Schema.optional(
Schema.Struct({
name: Schema.optional(Schema.String),
version: Schema.optional(Schema.String),
channel: Schema.optional(Schema.String),
}),
),
database: Schema.optional(Database.Options),
events: Schema.optional(Schema.Struct({ persist: Schema.optional(Schema.Boolean) })),
models: Schema.optional(ModelsDev.Options),
config: Schema.optional(
Schema.Struct({
directory: Schema.optional(Schema.String),
project: Schema.optional(Schema.Boolean),
file: Schema.optional(Schema.String),
content: Schema.optional(Schema.String),
}),
),
windows: Schema.optional(Schema.Struct({ gitbash: Schema.optional(Schema.String) })),
fs: Schema.optional(
Schema.Struct({
filewatcher: Schema.optional(Schema.Boolean),
fff: Schema.optional(Schema.Boolean),
}),
),
})
export type Options = typeof Options.Type
+97 -31
View File
@@ -11,6 +11,9 @@ import { KeyedMutex } from "./effect/keyed-mutex.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { isDeepStrictEqual } from "node:util"
import { Durable } from "@opencode-ai/schema/durable-event-manifest"
import { SessionEvent } from "@opencode-ai/schema/session-event"
import type { SessionID } from "@opencode-ai/schema/session-id"
import { AbsolutePath } from "@opencode-ai/schema/schema"
export type Subscriber<D extends Event.Definition = Event.Definition> = (event: Event.Payload<D>) => Effect.Effect<void>
export type Unsubscribe = Effect.Effect<void>
@@ -119,6 +122,9 @@ export interface Subscribe {
/**
* Volatile live channel: every event published from now on, nothing before or
* across a disconnect. Consumers that need reliability combine it with `log`.
* With an ambient Location, delivery is restricted to that Location and global
* events. Unlocated Session events use the Session's owner at publication time.
* Session moves reach both the old and new Location, without changing the event.
*/
(): Stream.Stream<Event.Payload>
<D extends Event.Definition>(definition: D): Stream.Stream<Event.Payload<D>>
@@ -183,6 +189,7 @@ export function configured(options?: Options) {
// Deferred import: a static one would close the module cycle
// bus → location → project → bus and hit the node bindings in TDZ.
const { Location } = yield* Effect.promise(() => import("./location.js"))
const { SessionTable } = yield* Effect.promise(() => import("./session/sql.js"))
const pubsub = {
live: yield* PubSub.unbounded<Event.Payload>(),
durable: new Map<string, Set<PubSub.PubSub<void>>>(),
@@ -194,6 +201,64 @@ export function configured(options?: Options) {
const { db } = yield* Database.Service
const logReadPageSize = options?.logReadPageSize ?? 512
const persist = options?.persist ?? false
const sessions = new Map<SessionID, Location.Ref>()
// Keep routing separate from the public event, and retain its snapshot
// while a slow subscriber drains events queued before a move or deletion.
const routes = new WeakMap<Event.Payload, readonly Location.Ref[]>()
const isSessionEvent = (event: Event.Payload): event is SessionEvent.Event =>
Object.hasOwn(SessionEvent.All.cases, event.type)
const prepareRoutes = Effect.fnUntraced(function* (events: readonly Event.Payload[]) {
const updates = new Map<SessionID, Location.Ref | undefined>()
const resolved = new Map<Event.Payload, readonly Location.Ref[]>()
for (const event of events) {
if (!isSessionEvent(event)) continue
const id = event.data.sessionID
if (event.type === "session.created") {
updates.set(id, event.data.location)
resolved.set(event, [event.location ?? event.data.location])
continue
}
if (event.location && event.type !== "session.forked" && event.type !== "session.moved") {
if (event.type === "session.deleted") updates.set(id, undefined)
continue
}
const owner = event.type === "session.forked" ? event.data.parentID : id
let ref = updates.has(owner) ? updates.get(owner) : sessions.get(owner)
if (!ref && !updates.has(owner)) {
const row = yield* db
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, owner))
.get()
.pipe(Effect.orDie)
ref = row
? { directory: AbsolutePath.make(row.directory), workspaceID: row.workspaceID ?? undefined }
: undefined
updates.set(owner, ref)
}
if (event.type === "session.moved") {
// Both owners need the transition, even if the producer supplied
// an envelope location. Later events use only the destination.
updates.set(id, event.data.location)
resolved.set(event, ref ? [ref, event.data.location] : [event.data.location])
continue
}
if (event.type === "session.forked") updates.set(id, ref)
resolved.set(event, event.location ? [event.location] : ref ? [ref] : [])
if (event.type === "session.deleted") updates.set(id, undefined)
}
// Apply only after the projection transaction commits. A failed move
// must not redirect events away from the Session's actual location.
return () => {
for (const [id, ref] of updates) {
if (ref) sessions.set(id, ref)
else sessions.delete(id)
}
for (const [event, ref] of resolved) routes.set(event, ref)
}
})
const getOrCreate = (definition: Event.Definition) =>
Effect.gen(function* () {
@@ -335,6 +400,7 @@ export function configured(options?: Options) {
...event,
durable: { aggregateID, seq, version: durable.version },
} as Event.Payload
const route = yield* prepareRoutes([committed])
for (const projector of list) {
yield* projector(committed)
}
@@ -366,12 +432,13 @@ export function configured(options?: Options) {
])
.run()
.pipe(Effect.orDie)
return { aggregateID, seq }
return { aggregateID, seq, event: committed, route }
}),
{ behavior: "immediate" },
)
.pipe(Effect.orDie)
if (committed) {
committed.route()
yield* Effect.forEach(
pubsub.durable.get(committed.aggregateID) ?? [],
(wake) => PubSub.publish(wake, undefined),
@@ -409,15 +476,14 @@ export function configured(options?: Options) {
Effect.gen(function* () {
const committed = yield* commitDurableEvent(definition, event as Event.Payload, undefined, commit)
if (!committed) return event
event = {
...event,
durable: envelope(committed.aggregateID, committed.seq, definition.durable.version),
}
event = committed.event as Event.Payload<D>
yield* notify(event as Event.Payload, true)
return event
}),
)
}
const route = yield* prepareRoutes([event as Event.Payload])
route()
yield* notify(event as Event.Payload, false)
return event
})
@@ -527,7 +593,11 @@ export function configured(options?: Options) {
.pipe(Effect.orDie)
const firstSeq = (row?.seq ?? -1) + 1
const finalSeq = firstSeq + payloads.length - 1
const result = new Array<Event.Payload>()
const queued = payloads.map((item, index) => ({
...item.event,
durable: envelope(aggregateID, firstSeq + index, item.definition.durable.version),
}))
const route = yield* prepareRoutes(queued)
const rows = new Array<typeof EventTable.$inferInsert>()
const ids = new Set<Event.ID>()
for (const [index, item] of payloads.entries()) {
@@ -559,10 +629,7 @@ export function configured(options?: Options) {
}),
)
}
const event = {
...item.event,
durable: envelope(aggregateID, seq, item.definition.durable.version),
} as Event.Payload
const event = queued[index]
for (const projector of projectors.get(
versionedType(item.definition.type, item.definition.durable.version),
) ?? []) {
@@ -578,7 +645,6 @@ export function configured(options?: Options) {
type: versionedType(item.definition.type, item.definition.durable.version),
data: encoded,
})
result.push(event)
}
yield* db
.insert(EventSequenceTable)
@@ -587,11 +653,12 @@ export function configured(options?: Options) {
.run()
.pipe(Effect.orDie)
if (persist) yield* db.insert(EventTable).values(rows).run().pipe(Effect.orDie)
return result
return { events: queued, route }
}),
{ behavior: "immediate" },
)
.pipe(Effect.orDie)
committed.route()
yield* Effect.forEach(
pubsub.durable.get(aggregateID) ?? [],
(wake) => PubSub.publish(wake, undefined),
@@ -599,8 +666,8 @@ export function configured(options?: Options) {
discard: true,
},
)
yield* Effect.forEach(committed, (event) => notify(event, true), { discard: true })
return committed as PublishResult<I>
yield* Effect.forEach(committed.events, (event) => notify(event, true), { discard: true })
return committed.events as PublishResult<I>
}),
),
)
@@ -632,13 +699,7 @@ export function configured(options?: Options) {
strictOwner: options?.strictOwner,
})
if (committed && options?.publish) {
yield* notify(
{
...payload,
durable: envelope(committed.aggregateID, committed.seq, definition.durable.version),
},
true,
)
yield* notify(committed.event, true)
}
}),
)
@@ -653,7 +714,10 @@ export function configured(options?: Options) {
yield* db.delete(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).run()
}),
)
.pipe(Effect.orDie)
.pipe(
Effect.tap(() => Effect.sync(() => sessions.delete(aggregateID as SessionID))),
Effect.orDie,
)
}
function claim(aggregateID: string, ownerID: string) {
@@ -671,15 +735,17 @@ export function configured(options?: Options) {
Effect.map((location) =>
Option.match(location, {
onNone: () => stream,
onSome: (location) =>
stream.pipe(
Stream.filter(
(event) =>
!event.location ||
(event.location.directory === location.directory &&
event.location.workspaceID === location.workspaceID),
),
),
onSome: (location) => {
const matches = (ref: Location.Ref) =>
ref.directory === location.directory && ref.workspaceID === location.workspaceID
return stream.pipe(
Stream.filter((event) => {
const refs = routes.get(event)
if (refs) return refs.some(matches)
return !event.location || matches(event.location)
}),
)
},
}),
),
),
+6 -1
View File
@@ -5,11 +5,16 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
export function build<A, E>(root: LayerNode.Node<A, E, any>, replacements: LayerNode.Replacements = []) {
// Only build the location service map if it's actually needed
if (!LayerNode.hasUnbound(root, LocationServiceMap.node, replacements)) return LayerNode.compile(root, replacements)
if (!LayerNode.hasUnbound(root, LocationServiceMap.node) || hasReplacement(replacements, LocationServiceMap.node))
return LayerNode.compile(root, replacements)
const locationMap = buildLocationServiceMap(replacements)
const locationMapNode = makeGlobalNode({ service: LocationServiceMap.Service, layer: locationMap, deps: [] })
return LayerNode.compile(root, replacements.concat([[LocationServiceMap.node, locationMapNode]]))
}
function hasReplacement(replacements: LayerNode.Replacements, node: LayerNode.Node<unknown, unknown, any>) {
return replacements.some(([source]) => source.name === node.name)
}
export * as AppNodeBuilder from "./app-node-builder.js"
+2 -8
View File
@@ -358,14 +358,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
hook: (name, callback) => hooks.register("shell", name, callback),
},
tool: {
transform: (callback) =>
tools
.transform((draft) =>
callback({
add: (tool) => draft.add(tool),
}),
)
.pipe(Effect.as({ dispose: Effect.void })),
transform: tools.transform,
reload: tools.reload,
hook: (name, callback) => hooks.register("tool", name, callback),
},
vcs: {
+1 -1
View File
@@ -76,7 +76,7 @@ export const layerWithCell = (cell: Cell) =>
resume: (sessionID) => require(cell, (runtime) => runtime.session.resume(sessionID)),
switchAgent: (input) => require(cell, (runtime) => runtime.session.switchAgent(input)),
switchModel: (input) => require(cell, (runtime) => runtime.session.switchModel(input)),
interrupt: (sessionID, options) => require(cell, (runtime) => runtime.session.interrupt(sessionID, options)),
interrupt: (sessionID) => require(cell, (runtime) => runtime.session.interrupt(sessionID)),
synthetic: (input) => require(cell, (runtime) => runtime.session.synthetic(input)),
wait: (sessionID) => require(cell, (runtime) => runtime.session.wait(sessionID)),
context: (sessionID) => require(cell, (runtime) => runtime.session.context(sessionID)),
+94 -92
View File
@@ -1,6 +1,6 @@
export * as SessionCompaction from "./compaction.js"
import { LLMClient, AIError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai"
import { LLMClient, AIError, LLMEvent, LLMRequest, Message } from "@opencode-ai/ai"
import type { StreamOptions } from "@opencode-ai/ai/route"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Context, Effect, Layer, Stream } from "effect"
@@ -8,14 +8,16 @@ import { Bus } from "../bus.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { llmClient } from "../effect/app-node-platform.js"
import { SessionEvent } from "./event.js"
import type { SessionContext } from "./context.js"
import type { Instructions } from "../instructions/index.js"
import type { AgentNotFoundError } from "./error.js"
import type { SessionMessage } from "./message.js"
import { SessionModelRequest } from "./model-request.js"
import { SessionRunnerModel } from "./runner/model.js"
import type { SessionRunnerModel } from "./runner/model.js"
import { SessionSchema } from "./schema.js"
import { toSessionError } from "./to-session-error.js"
import { Token } from "../util/token.js"
import { SessionUsage } from "./usage.js"
import { Agent } from "../agent.js"
import { State } from "../state.js"
const DEFAULT_BUFFER = 20_000
@@ -69,33 +71,35 @@ type Dependencies = {
readonly llm: {
readonly stream: (request: LLMRequest, options?: StreamOptions) => Stream.Stream<LLMEvent, AIError>
}
readonly models: SessionRunnerModel.Interface
readonly modelRequests: SessionModelRequest.Interface
}
export type AutoInput = {
readonly session: SessionSchema.Info
readonly messages: readonly SessionMessage.Info[]
readonly resolved: SessionRunnerModel.Resolved
readonly prepare: SessionModelRequest.Interface["prepare"]
/** The runner resolves the conversation agent only when there is history to compact. */
readonly context: Effect.Effect<
SessionContext.Loaded,
AgentNotFoundError | SessionRunnerModel.Error | Instructions.InitializationBlocked
>
}
type RequiredInput = Pick<AutoInput, "messages" | "resolved">
export type ManualInput = {
readonly session: SessionSchema.Info
readonly messages: readonly SessionMessage.Info[]
export type ManualInput = Pick<AutoInput, "session" | "messages" | "context" | "prepare"> & {
readonly inputID: SessionMessage.ID
readonly started?: boolean
}
type Plan = {
readonly session: SessionSchema.Info
readonly resolved: SessionRunnerModel.Resolved
readonly context: AutoInput["context"]
readonly reason: SessionMessage.Compaction["reason"]
readonly prompt: string
readonly recent: string
readonly messages: readonly SessionMessage.Info[]
readonly inputID?: SessionMessage.ID
readonly started?: boolean
readonly prepare: SessionModelRequest.Interface["prepare"]
}
export type Outcome =
@@ -172,10 +176,7 @@ const serialize = (message: SessionMessage.Info) => {
return ""
}
const select = (
messages: readonly SessionMessage.Info[],
tokens: number,
): { readonly head: string; readonly recent: string } | undefined => {
const select = (messages: readonly SessionMessage.Info[], tokens: number) => {
const conversation = messages
.filter((message) => message.type !== "compaction" && message.type !== "system")
.flatMap((message) => {
@@ -197,10 +198,8 @@ const select = (
if (latestUser > 0) split = latestUser
}
return {
head: conversation
.slice(0, split)
.map((item) => item.text)
.join("\n\n"),
split: messages.indexOf(conversation[split].message),
hasHead: split > 0,
recent: conversation
.slice(split)
.map((item) => item.text)
@@ -208,14 +207,12 @@ const select = (
}
}
export const buildPrompt = (input: { readonly previousSummary?: string; readonly context: readonly string[] }) =>
export const buildPrompt = () =>
[
input.previousSummary
? `Update the anchored summary below using the conversation history above.\nPreserve still-true details, remove stale details, and merge in the new facts.\n<previous-summary>\n${input.previousSummary}\n</previous-summary>`
: "Create a new anchored summary from the conversation history.",
"Summarize the conversation above so work can continue without the earlier messages.",
SUMMARY_TEMPLATE,
"The following is the conversation history:",
...input.context,
"If the history contains a conversation checkpoint, incorporate its summary and recent context. Preserve still-true details, remove stale details, and merge in the new facts.",
"Do not continue the task or call tools. Output only the summary.",
].join("\n\n")
const planContent = (messages: readonly SessionMessage.Info[], tokens: number) => {
@@ -225,13 +222,10 @@ const planContent = (messages: readonly SessionMessage.Info[], tokens: number) =
(message): message is SessionMessage.CompactionCompleted =>
message.type === "compaction" && message.status === "completed",
)
const previousRecent = previousSummary?.recent ?? ""
const summarizeRecent = !previousRecent && !selected.head
const summarizeRecent = !previousSummary?.recent && !selected.hasHead
return {
prompt: buildPrompt({
previousSummary: previousSummary?.summary,
context: summarizeRecent ? [selected.recent] : [previousRecent, selected.head].filter(Boolean),
}),
// Keep the existing checkpoint and chronological updates in their original positions.
messages: summarizeRecent ? messages : messages.slice(0, selected.split),
recent: summarizeRecent ? "" : selected.recent,
}
}
@@ -258,11 +252,39 @@ const make = (dependencies: Dependencies) => {
return { status: "failed" as const, error: input.error }
})
const execute = Effect.fn("SessionCompaction.execute")(function* (plan: Plan) {
if (!plan.started)
yield* dependencies.bus.publish(SessionEvent.Compaction.Started, {
if (
!plan.messages.some((message) => message.type !== "compaction" && message.type !== "system" && serialize(message))
)
return yield* failed({
sessionID: plan.session.id,
reason: plan.reason,
recent: plan.recent,
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
inputID: plan.inputID,
})
const loaded = yield* plan.context.pipe(
Effect.catch((cause) =>
failed({
sessionID: plan.session.id,
reason: plan.reason,
error: toSessionError(cause),
inputID: plan.inputID,
}),
),
)
if ("status" in loaded) return loaded
const content = planContent(loaded.messages, state.get().tokens)
if (!content)
return yield* failed({
sessionID: plan.session.id,
reason: plan.reason,
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
inputID: plan.inputID,
})
if (!plan.started)
yield* dependencies.bus.publish(SessionEvent.Compaction.Started, {
sessionID: loaded.session.id,
reason: plan.reason,
recent: content.recent,
inputID: plan.inputID,
})
@@ -272,33 +294,44 @@ const make = (dependencies: Dependencies) => {
const recordUsage = Effect.suspend(() =>
usage
? dependencies.bus.publish(SessionEvent.UsageRecorded, {
sessionID: plan.session.id,
sessionID: loaded.session.id,
source: "compaction",
...usage,
})
: Effect.void,
)
const prepared = yield* dependencies.modelRequests.prepare({
scope: { session: plan.session, agentID: Agent.ID.make("compaction"), model: plan.resolved },
transcript: { system: [], messages: [Message.user(plan.prompt)] },
contextHooks: false,
const transcript = SessionModelRequest.baseTranscript({
agent: loaded.agent.info,
model: loaded.model,
tools: loaded.tools,
initial: loaded.initial,
messages: content.messages,
})
yield* dependencies.llm.stream(prepared.request, prepared.options).pipe(
const prepared = yield* plan.prepare({
scope: { session: loaded.session, agentID: loaded.agent.id, model: loaded.model, tools: loaded.tools },
transcript,
})
const request = LLMRequest.update(prepared.request, {
messages: [...prepared.request.messages, Message.user(buildPrompt())],
})
yield* dependencies.llm.stream(request, prepared.options).pipe(
Stream.runForEach((event) => {
if (LLMEvent.is.providerError(event))
failure = {
type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error",
message: event.message,
}
if (LLMEvent.is.toolCall(event))
failure = { type: "compaction.failed", message: "Compaction attempted to call a tool" }
if (LLMEvent.is.textDelta(event)) {
chunks.push(event.text)
return dependencies.bus.publish(SessionEvent.Compaction.Delta, {
sessionID: plan.session.id,
sessionID: loaded.session.id,
text: event.text,
})
}
if (LLMEvent.is.stepFinish(event)) {
const step = SessionUsage.record(event.usage, plan.resolved.cost)
const step = SessionUsage.record(event.usage, loaded.model.cost)
usage = usage ? SessionUsage.add(usage, step) : step
}
return Effect.void
@@ -313,7 +346,7 @@ const make = (dependencies: Dependencies) => {
Effect.andThen(
plan.reason === "auto"
? failed({
sessionID: plan.session.id,
sessionID: loaded.session.id,
reason: plan.reason,
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
inputID: plan.inputID,
@@ -328,35 +361,29 @@ const make = (dependencies: Dependencies) => {
if (failure || !summary.trim()) {
const error = failure ?? { type: "compaction.failed" as const, message: "Compaction produced no summary" }
return yield* failed({
sessionID: plan.session.id,
sessionID: loaded.session.id,
reason: plan.reason,
error,
inputID: plan.inputID,
})
}
yield* dependencies.bus.publish(SessionEvent.Compaction.Ended, {
sessionID: plan.session.id,
sessionID: loaded.session.id,
reason: plan.reason,
text: summary,
recent: plan.recent,
recent: content.recent,
})
return { status: "completed" as const }
})
const compact = Effect.fn("SessionCompaction.compact")(function* (input: AutoInput) {
const content = planContent(input.messages, state.get().tokens)
if (content)
return yield* execute({
session: input.session,
resolved: input.resolved,
reason: "auto",
...content,
})
return yield* failed({
sessionID: input.session.id,
const compact = Effect.fn("SessionCompaction.compact")((input: AutoInput) =>
execute({
session: input.session,
messages: input.messages,
context: input.context,
prepare: input.prepare,
reason: "auto",
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
})
})
}),
)
const required = (input: RequiredInput) => {
const config = state.get()
if (!config.auto) return false
@@ -378,35 +405,12 @@ const make = (dependencies: Dependencies) => {
if (used <= 0) return false
return used >= promptCeiling
}
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
const content = planContent(input.messages, state.get().tokens)
if (!content)
return yield* failed({
sessionID: input.session.id,
reason: "manual",
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
inputID: input.inputID,
})
const resolved = yield* dependencies.models.resolve(input.session).pipe(
Effect.catch((cause) =>
failed({
sessionID: input.session.id,
reason: "manual",
error: toSessionError(cause),
inputID: input.inputID,
}),
),
)
if ("status" in resolved) return resolved
return yield* execute({
session: input.session,
resolved,
const compactManual = Effect.fn("SessionCompaction.compactManual")((input: ManualInput) =>
execute({
...input,
reason: "manual",
inputID: input.inputID,
started: input.started,
...content,
})
})
}),
)
return Service.of({
transform: state.transform,
reload: state.reload,
@@ -422,14 +426,12 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const bus = yield* Bus.Service
const llm = yield* LLMClient.Service
const models = yield* SessionRunnerModel.Service
const modelRequests = yield* SessionModelRequest.Service
return make({ bus, llm, models, modelRequests })
return make({ bus, llm })
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Bus.node, llmClient, SessionRunnerModel.node, SessionModelRequest.node],
deps: [Bus.node, llmClient],
})
+59 -8
View File
@@ -2,6 +2,7 @@ export * as SessionContext from "./context.js"
import { Context, Effect, Layer } from "effect"
import { Agent } from "../agent.js"
import { Catalog } from "../catalog.js"
import { CodeModeInstructions } from "../codemode/instructions.js"
import { Database } from "../database/database.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
@@ -11,6 +12,7 @@ import { InstructionBuiltIns } from "../instructions/builtins.js"
import { Location } from "../location.js"
import { McpInstructions } from "../mcp/instructions.js"
import { McpTool } from "../tool/mcp.js"
import { Model } from "../model.js"
import { PluginSupervisor } from "../plugin/supervisor.js"
import { ReferenceInstructions } from "../reference/instructions.js"
import { SkillInstructions } from "../skill/instructions.js"
@@ -19,6 +21,7 @@ import { AgentNotFoundError } from "./error.js"
import { SessionHistory } from "./history.js"
import { InstructionEntry } from "./instruction-entry.js"
import { SessionMessage } from "./message.js"
import { SessionModelRequest } from "./model-request.js"
import { SessionRunnerModel } from "./runner/model.js"
import { SessionSchema } from "./schema.js"
import { SessionStore } from "./store.js"
@@ -42,14 +45,27 @@ export interface Loaded {
/**
* Resolves model-request state in two phases: `select` fixes the Session,
* agent, instruction sources, and tool snapshot; `load` adds the model and
* active history for that selection. This module does not build or execute the
* model request.
* active history for that selection. Auxiliary operations resolve only the
* capabilities they need; request preparation stays separate from selection.
*/
export interface Interface {
/** Selects the Session, agent, instructions, and tools used by subsequent work. */
readonly select: (sessionID: SessionSchema.ID) => Effect.Effect<Selection, AgentNotFoundError>
readonly select: (sessionID: SessionSchema.ID, agentID?: Agent.ID) => Effect.Effect<Selection, AgentNotFoundError>
/** Resolves the model and active history for that selection. */
readonly load: (selection: Selection) => Effect.Effect<Loaded, SessionRunnerModel.Error>
readonly resolveModel: (
session: SessionSchema.Info,
) => Effect.Effect<SessionRunnerModel.Resolved, SessionRunnerModel.Error>
/** Selects auxiliary title capabilities without instruction or tool preflight. */
readonly selectTitle: (session: SessionSchema.Info) => Effect.Effect<
| {
readonly agent: Agent.Info
readonly primary: SessionRunnerModel.Resolved | undefined
readonly selected: SessionRunnerModel.Resolved
}
| undefined
>
readonly prepare: SessionModelRequest.Interface["prepare"]
}
/** Location-scoped model-context loader for durable Session Steps. */
@@ -60,6 +76,7 @@ const layer = Layer.effect(
Effect.gen(function* () {
const agents = yield* Agent.Service
const builtins = yield* InstructionBuiltIns.Service
const catalog = yield* Catalog.Service
const db = (yield* Database.Service).db
const discovery = yield* InstructionDiscovery.Service
const entries = yield* InstructionEntry.Service
@@ -67,13 +84,42 @@ const layer = Layer.effect(
const mcpInstructions = yield* McpInstructions.Service
const mcpTools = yield* McpTool.Service
const models = yield* SessionRunnerModel.Service
const modelRequests = yield* SessionModelRequest.Service
const plugins = yield* PluginSupervisor.Service
const referenceInstructions = yield* ReferenceInstructions.Service
const skillInstructions = yield* SkillInstructions.Service
const store = yield* SessionStore.Service
const registry = yield* Tool.Service
const select = Effect.fn("SessionContext.select")(function* (sessionID: SessionSchema.ID) {
const resolveModel = (session: SessionSchema.Info) => models.resolve(session, catalog.model.available)
const selectTitle = Effect.fn("SessionContext.selectTitle")(function* (session: SessionSchema.Info) {
const agent = yield* agents.get(Agent.ID.make("title"))
if (!agent) return
const primary = yield* resolveModel(session).pipe(Effect.orElseSucceed(() => undefined))
const info = yield* Effect.gen(function* () {
if (agent.model) return yield* catalog.model.get(agent.model.providerID, agent.model.id)
if (!primary) return
return yield* catalog.model.small(primary.ref.providerID)
})
const variant =
agent.model?.variant ?? MINIMAL_REASONING_VARIANTS.find((id) => info?.variants.some((item) => item.id === id))
const preferred =
info &&
(yield* resolveModel({
...session,
model: Model.Ref.make({
providerID: info.providerID,
id: info.id,
...(variant ? { variant } : {}),
}),
}).pipe(Effect.orElseSucceed(() => undefined)))
const selected = preferred ?? primary
if (!selected) return
return { agent, primary, selected }
})
const select = Effect.fn("SessionContext.select")(function* (sessionID: SessionSchema.ID, agentID?: Agent.ID) {
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
@@ -81,8 +127,8 @@ const layer = Layer.effect(
yield* plugins.flush
yield* mcpTools.flush
const agent = yield* agents.select(session.agent)
if (!agent.info) return yield* new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id })
const agent = yield* agents.select(agentID ?? session.agent)
if (!agent.info) return yield* new AgentNotFoundError({ sessionID: session.id, agent: agent.id })
const loaded = yield* Effect.all(
{
tools: registry.snapshot(agent.info.permissions),
@@ -112,7 +158,7 @@ const layer = Layer.effect(
})
const load = Effect.fn("SessionContext.load")(function* (selection: Selection) {
const model = yield* models.resolve(selection.session)
const model = yield* resolveModel(selection.session)
const history = yield* SessionHistory.entriesForRunner(db, selection.session.id, selection.instructions)
return {
session: selection.session,
@@ -124,15 +170,19 @@ const layer = Layer.effect(
}
})
return Service.of({ select, load })
return Service.of({ select, load, resolveModel, selectTitle, prepare: modelRequests.prepare })
}),
)
/** Variant IDs that minimize reasoning output, in preference order. */
const MINIMAL_REASONING_VARIANTS = ["none", "minimal", "low"].map((id) => Model.VariantID.make(id))
export const node = makeLocationNode({
service: Service,
layer,
deps: [
Agent.node,
Catalog.node,
Database.node,
InstructionBuiltIns.node,
InstructionDiscovery.node,
@@ -143,6 +193,7 @@ export const node = makeLocationNode({
PluginSupervisor.node,
ReferenceInstructions.node,
SessionRunnerModel.node,
SessionModelRequest.node,
SessionStore.node,
SkillInstructions.node,
Tool.node,
+3 -6
View File
@@ -9,7 +9,6 @@ import { SessionContext } from "./context.js"
import { SessionGenerate } from "./generate.js"
import { SessionHistory } from "./history.js"
import { SessionModelRequest } from "./model-request.js"
import { SessionRunnerModel } from "./runner/model.js"
export const layer = Layer.effect(
SessionGenerate.Service,
@@ -17,13 +16,11 @@ export const layer = Layer.effect(
const context = yield* SessionContext.Service
const database = yield* Database.Service
const llm = yield* LLMClient.Service
const models = yield* SessionRunnerModel.Service
const modelRequests = yield* SessionModelRequest.Service
return SessionGenerate.Service.of({
generate: Effect.fn("SessionGenerate.generate")(function* (input) {
const selection = yield* context.select(input.sessionID)
const model = yield* models.resolve(selection.session)
const model = yield* context.resolveModel(selection.session)
const history = yield* SessionHistory.preview(database.db, selection.session.id, selection.instructions)
const transcript = SessionModelRequest.baseTranscript({
agent: selection.agent.info,
@@ -32,7 +29,7 @@ export const layer = Layer.effect(
initial: history.initial,
messages: history.messages,
})
const prepared = yield* modelRequests.prepare({
const prepared = yield* context.prepare({
scope: { session: selection.session, agentID: selection.agent.id, model, tools: selection.tools },
transcript: {
system: transcript.system,
@@ -59,5 +56,5 @@ export const layer = Layer.effect(
export const node = makeLocationNode({
service: SessionGenerate.Service,
layer,
deps: [SessionContext.node, Database.node, SessionModelRequest.node, SessionRunnerModel.node, llmClient],
deps: [SessionContext.node, Database.node, llmClient],
})
+18
View File
@@ -63,6 +63,24 @@ export const load = Effect.fn("SessionHistory.load")(function* (db: DatabaseServ
return (yield* messageEntries(db, sessionID)).map((entry) => entry.message)
})
/** Finds the last assistant even when a checkpoint has replaced it in model-visible history. */
export const latestAssistant = Effect.fn("SessionHistory.latestAssistant")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
) {
const row = yield* db
.select()
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "assistant")))
.orderBy(desc(SessionMessageTable.seq))
.limit(1)
.get()
.pipe(Effect.orDie)
if (!row) return
const message = yield* decodeMessageRow(row).pipe(Effect.orDie)
return message.type === "assistant" ? message : undefined
})
export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
+2 -2
View File
@@ -60,7 +60,7 @@ interface PrepareInput {
readonly session: SessionSchema.Info
readonly agentID: Agent.ID
readonly model: SessionRunnerModel.Resolved
/** Omitted for requests that carry no tools (title, compaction). */
/** Omitted for requests that carry no tools, such as titles. */
readonly tools?: Tool.Snapshot
}
readonly transcript: {
@@ -70,7 +70,7 @@ interface PrepareInput {
readonly toolChoice?: LLM.RequestInput["toolChoice"]
/**
* Session context hooks shape the agent conversation. Requests that are not
* part of the conversation (title, compaction) opt out: their transcripts
* part of the conversation (such as titles) opt out: their transcripts
* pass through unchanged.
*/
readonly contextHooks?: false
+27 -5
View File
@@ -8,6 +8,7 @@ import { InstructionState } from "../instruction-state.js"
import { SessionCompaction } from "../compaction.js"
import { SessionContext } from "../context.js"
import { SessionEvent } from "../event.js"
import { SessionHistory } from "../history.js"
import { SessionInbox } from "../inbox.js"
import { SessionModelRequest } from "../model-request.js"
import { SessionModelTransport } from "../model-transport.js"
@@ -36,7 +37,6 @@ const layer = Layer.effect(
const bus = yield* Bus.Service
const store = yield* SessionStore.Service
const context = yield* SessionContext.Service
const modelRequests = yield* SessionModelRequest.Service
const modelTransport = yield* SessionModelTransport.Service
const db = (yield* Database.Service).db
const compaction = yield* SessionCompaction.Service
@@ -140,9 +140,12 @@ const layer = Layer.effect(
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
const compacted = yield* restore(
Effect.gen(function* () {
const messages = yield* store.context(sessionID)
return yield* compaction.compactManual({
session,
messages: yield* store.context(sessionID),
prepare: context.prepare,
messages,
context: loadCompactionContext(sessionID, messages),
inputID: pending.id,
started: true,
})
@@ -203,6 +206,20 @@ const layer = Layer.effect(
return selected
})
const loadCompactionContext = Effect.fn("SessionRunner.loadCompactionContext")(function* (
sessionID: SessionSchema.ID,
messages: readonly SessionMessage.Info[],
loaded?: SessionContext.Loaded,
) {
const last =
messages.findLast((message) => message.type === "assistant") ??
(yield* SessionHistory.latestAssistant(db, sessionID))
if (loaded && (!last || last.agent === loaded.agent.id)) return loaded
const selected = yield* context.select(sessionID, last?.agent)
yield* InstructionState.prepare(db, bus, selected.instructions, sessionID)
return yield* context.load(selected)
})
/** Owns logical Step policy; each attempt owns its streaming, tools, and durable settlement. */
const runStep = Effect.fn("SessionRunner.runStep")(function* (first: SessionContext.Loaded, step: number) {
const sessionID = first.session.id
@@ -215,7 +232,13 @@ const layer = Layer.effect(
// 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 }
const compactionInput = {
session: loaded.session,
messages: loaded.messages,
resolved: loaded.model,
prepare: context.prepare,
context: loadCompactionContext(sessionID, loaded.messages, loaded),
}
if (compaction.required(compactionInput)) {
const compacted = yield* compaction.compact(compactionInput)
if (compacted.status !== "completed") return yield* new StepFailedError({ error: compacted.error })
@@ -230,7 +253,7 @@ const layer = Layer.effect(
initial: loaded.initial,
messages: loaded.messages,
})
const prepared = yield* modelRequests.prepare({
const prepared = yield* context.prepare({
scope: { session: loaded.session, agentID: loaded.agent.id, model: loaded.model, tools: loaded.tools },
transcript: {
system: transcript.system,
@@ -319,7 +342,6 @@ export const node = makeLocationNode({
Bus.node,
llmClient,
SessionContext.node,
SessionModelRequest.node,
SessionModelTransport.node,
SessionStore.node,
SessionCompaction.node,
+8 -6
View File
@@ -3,7 +3,6 @@ export * as SessionRunnerModel from "./model.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { LanguageModel } from "@opencode-ai/ai"
import { Context, Effect, Layer, Schema } from "effect"
import { Catalog } from "../../catalog.js"
import { ModelResolver } from "../../model-resolver.js"
import { Capabilities, ID, Info, Ref, VariantID } from "../../model.js"
import { Provider } from "../../provider.js"
@@ -41,7 +40,11 @@ export type Error = ModelNotSelectedError | ModelUnavailableError | ModelResolve
export type Resolved = ModelResolver.Resolved
export interface Interface {
readonly resolve: (session: SessionSchema.Info) => Effect.Effect<Resolved, Error>
/** Availability is sampled lazily for each explicitly selected model resolution. */
readonly resolve: (
session: SessionSchema.Info,
available: () => Effect.Effect<ReadonlyArray<Info>>,
) => Effect.Effect<Resolved, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionRunnerModel") {}
@@ -70,17 +73,16 @@ export const resolved = (
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const resolver = yield* ModelResolver.Service
return Service.of({
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) {
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session, available) {
// Location plugins populate and filter the catalog asynchronously during layer startup.
if (!session.model) {
const resolved = yield* resolver.resolve()
if (resolved) return resolved
return yield* new ModelNotSelectedError({ sessionID: session.id })
}
const selected = (yield* catalog.model.available()).find(
const selected = (yield* available()).find(
(model) => model.providerID === session.model?.providerID && model.id === session.model.id,
)
if (!selected)
@@ -94,4 +96,4 @@ const layer = Layer.effect(
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [Catalog.node, ModelResolver.node] })
export const node = makeLocationNode({ service: Service, layer, deps: [ModelResolver.node] })
+13 -55
View File
@@ -4,18 +4,16 @@ import { isDeepStrictEqual } from "node:util"
import { LLMClient, AIError, LLMEvent, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
import type { StreamOptions } from "@opencode-ai/ai/route"
import { Context, DateTime, Effect, Layer, Stream } from "effect"
import { Agent } from "../agent.js"
import { Catalog } from "../catalog.js"
import type { Agent } from "../agent.js"
import { Database } from "../database/database.js"
import { Bus } from "../bus.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { isExactRootFallback } from "@opencode-ai/util/session-title-fallback"
import { llmClient } from "../effect/app-node-platform.js"
import { Model } from "../model.js"
import { SessionContext } from "./context.js"
import { SessionEvent } from "./event.js"
import { SessionHistory } from "./history.js"
import { SessionModelRequest } from "./model-request.js"
import { SessionRunnerModel } from "./runner/model.js"
import type { SessionRunnerModel } from "./runner/model.js"
import { SessionSchema } from "./schema.js"
import { SessionUsage } from "./usage.js"
import { SessionStore } from "./store.js"
@@ -30,10 +28,7 @@ type Dependencies = {
readonly llm: {
readonly stream: (request: LLMRequest, options?: StreamOptions) => Stream.Stream<LLMEvent, AIError>
}
readonly agents: Agent.Interface
readonly catalog: Catalog.Interface
readonly models: SessionRunnerModel.Interface
readonly modelRequests: SessionModelRequest.Interface
readonly context: SessionContext.Interface
readonly store: SessionStore.Interface
}
@@ -72,7 +67,7 @@ const attempt = Effect.fn("SessionTitle.attempt")(function* (
})
: Effect.void,
)
const prepared = yield* dependencies.modelRequests.prepare({
const prepared = yield* dependencies.context.prepare({
scope: { session: input.session, agentID: input.agent.id, model: input.model },
transcript: {
system: input.agent.system ? [SystemPart.make(input.agent.system)] : [],
@@ -106,9 +101,6 @@ const attempt = Effect.fn("SessionTitle.attempt")(function* (
.find((line) => line.length > 0)
})
/** Variant IDs that minimize reasoning output, in preference order. */
const MINIMAL_REASONING_VARIANTS = ["none", "minimal", "low"].map((id) => Model.VariantID.make(id))
const make = (dependencies: Dependencies) => {
const generate = Effect.fn("SessionTitle.generate")(function* (
db: Database.Interface["db"],
@@ -140,34 +132,12 @@ const make = (dependencies: Dependencies) => {
Effect.orElseSucceed(() => firstUser.text),
)
: firstUser.text
const agent = yield* dependencies.agents.get(Agent.ID.make("title"))
if (!agent) return
const primary = yield* dependencies.models.resolve(session).pipe(Effect.orElseSucceed(() => undefined))
const info = yield* Effect.gen(function* () {
if (agent.model) return yield* dependencies.catalog.model.get(agent.model.providerID, agent.model.id)
if (!primary) return
return yield* dependencies.catalog.model.small(primary.ref.providerID)
})
const variant =
agent.model?.variant ?? MINIMAL_REASONING_VARIANTS.find((id) => info?.variants.some((item) => item.id === id))
const preferred =
info &&
(yield* dependencies.models
.resolve({
...session,
model: Model.Ref.make({
providerID: info.providerID,
id: info.id,
...(variant ? { variant } : {}),
}),
})
.pipe(Effect.orElseSucceed(() => undefined)))
const selected = preferred ?? primary
if (!selected) return
const selection = yield* dependencies.context.selectTitle(session)
if (!selection) return
const title =
(yield* attempt(dependencies, { session, agent, text, model: selected })) ??
(primary && !isDeepStrictEqual(selected.ref, primary.ref)
? yield* attempt(dependencies, { session, agent, text, model: primary })
(yield* attempt(dependencies, { session, agent: selection.agent, text, model: selection.selected })) ??
(selection.primary && !isDeepStrictEqual(selection.selected.ref, selection.primary.ref)
? yield* attempt(dependencies, { session, agent: selection.agent, text, model: selection.primary })
: undefined)
if (!title) return
const expectedSequence = (yield* Bus.latestSequence(db, sessionID)) + 1
@@ -192,13 +162,10 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const bus = yield* Bus.Service
const llm = yield* LLMClient.Service
const agents = yield* Agent.Service
const catalog = yield* Catalog.Service
const models = yield* SessionRunnerModel.Service
const modelRequests = yield* SessionModelRequest.Service
const context = yield* SessionContext.Service
const store = yield* SessionStore.Service
const database = yield* Database.Service
const title = make({ bus, llm, agents, catalog, models, modelRequests, store })
const title = make({ bus, llm, context, store })
return Service.of({
generate: (sessionID) => title.generate(database.db, sessionID),
})
@@ -208,14 +175,5 @@ export const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [
Bus.node,
llmClient,
Agent.node,
Catalog.node,
SessionRunnerModel.node,
SessionModelRequest.node,
SessionStore.node,
Database.node,
],
deps: [Bus.node, llmClient, SessionContext.node, SessionStore.node, Database.node],
})
+127 -136
View File
@@ -4,7 +4,7 @@ export type { Context, Metadata, Options, Result } from "@opencode-ai/schema/too
import { ToolDefinition, type ToolCall } from "@opencode-ai/ai"
import { Tool } from "@opencode-ai/schema/tool"
import { Context, Effect, Layer, Schema, SchemaIssue, Scope, Semaphore } from "effect"
import { Context, Effect, Layer, Result, Schema, SchemaIssue, Types } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import type { Agent } from "./agent.js"
import { CodeModeCatalog } from "./codemode/catalog.js"
@@ -14,6 +14,7 @@ import { Permission } from "./permission.js"
import { PluginHooks } from "./plugin/hooks.js"
import { SessionMessage } from "./session/message.js"
import { SessionSchema } from "./session/schema.js"
import { State } from "./state.js"
import { definition, execute, normalizeContent } from "./tool/runtime.js"
import { Wildcard } from "./util/wildcard.js"
@@ -22,10 +23,20 @@ export class RegistrationError extends Schema.TaggedError<RegistrationError>()("
message: Schema.String,
}) {}
export interface Interface {
readonly transform: (
callback: (draft: { readonly add: (tool: Tool.Info) => void }) => void,
) => Effect.Effect<void, never, Scope.Scope>
export interface Draft {
readonly list: () => readonly (Tool.Info & { readonly id: string })[]
readonly get: (id: string) => (Tool.Info & { readonly id: string }) | undefined
readonly add: (tool: Tool.Info) => void
readonly update: (id: string, update: (tool: Types.Mutable<Tool.Info>) => void) => void
readonly remove: (id: string) => void
}
type Data = {
tools: Map<string, Tool.Info & { readonly id: string }>
errors: { tool: Tool.Info; error: RegistrationError }[]
}
export interface Interface extends State.Transformable<Draft> {
readonly snapshot: (permissions?: Permission.Ruleset) => Effect.Effect<Snapshot>
}
@@ -79,9 +90,6 @@ const layer = Layer.effect(
]
})
const local = new Map<string, Array<{ readonly token: object; readonly tool: Tool.Info }>>()
const lock = Semaphore.makeUnsafe(1)
const executeTool = Effect.fn("Tool.execute")(function* (
tool: Tool.Info,
name: string,
@@ -137,112 +145,106 @@ const layer = Layer.effect(
}
})
const transform: Interface["transform"] = Effect.fn("Tool.transform")(function* (callback) {
const tools: Array<Tool.Info> = []
yield* Effect.sync(() => callback({ add: (tool) => tools.push(tool) }))
const valid = yield* Effect.filter(normalizedEntries(tools), (entry) =>
Effect.gen(function* () {
if (entry.tool.options?.namespace !== undefined) yield* validateNamespace(entry.tool.options.namespace)
yield* validateName(normalizedName(entry.tool))
if (entry.tool.options?.codemode === false && entry.key === "execute")
return yield* new RegistrationError({
name: entry.key,
message: 'Tool name "execute" is reserved for CodeMode',
})
yield* Effect.try({
try: () => ToolDefinition.make(definition(entry.tool)),
catch: (error) =>
new RegistrationError({
name: entry.key,
message: `Invalid tool definition ${entry.key}: ${schemaMakeError(error)}`,
}),
})
return true
}).pipe(Effect.catchTag("Tool.RegistrationError", (error) => skipRegistration(entry.tool, error))),
)
// 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* () {
const token = {}
for (const entry of entries)
local.set(entry.key, [...(local.get(entry.key) ?? []), { token, tool: entry.tool }])
yield* Effect.addFinalizer(() =>
lock.withPermit(
Effect.sync(() => {
for (const entry of entries) {
const remaining = local.get(entry.key)?.filter((item) => item.token !== token) ?? []
if (remaining.length > 0) local.set(entry.key, remaining)
else local.delete(entry.key)
}
}),
),
)
}),
const state: State.Interface<Data, Draft> = State.create<Data, Draft>({
name: "tool",
initial: () => ({
tools: new Map(),
errors: [],
}),
draft: (draft) => ({
list: () => Array.from(draft.tools.values()),
get: (id) => draft.tools.get(id),
add: (tool) => {
const error = registrationError(tool)
if (error) {
draft.errors.push({ tool, error })
return
}
const id = effectiveName(tool)
draft.tools.set(id, { ...tool, id, options: tool.options && { ...tool.options } })
},
update: (id, update) => {
const current = draft.tools.get(id)
if (!current) return
const tool = { ...current, options: current.options && { ...current.options } }
update(tool)
tool.name = current.name
tool.id = id
if (tool.options?.namespace !== current.options?.namespace)
tool.options = { ...tool.options, namespace: current.options?.namespace }
const error = registrationError(tool)
if (error) {
draft.errors.push({ tool, error })
return
}
draft.tools.set(id, tool)
},
remove: (id) => {
draft.tools.delete(id)
},
}),
finalize: () =>
Effect.forEach(
state.get().errors,
({ tool, error }) =>
Effect.logError("Skipping invalid tool registration", {
name: tool.name,
namespace: tool.options?.namespace,
error: error.message,
}),
{ discard: true },
),
)
})
return Service.of({
transform,
transform: state.transform,
reload: state.reload,
snapshot: Effect.fn("Tool.snapshot")((permissions) =>
lock.withPermit(
Effect.gen(function* () {
const active = new Map<string, Tool.Info>()
const rules = permissions ?? []
for (const [name, entries] of local) {
const tool = entries.at(-1)?.tool
if (!tool) continue
if (whollyDisabled(tool.options?.permission ?? name, rules)) continue
active.set(name, tool)
}
const direct = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode === false))
const codemode = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode !== false))
const executeRule = rules.findLast((rule) => Wildcard.match("execute", rule.action))
const codemodeEnabled = executeRule?.resource !== "*" || executeRule.effect !== "deny"
const codemodeTool = codemodeEnabled
? CodeModeTool.create(codemode, (name, tool, input, context) => executeTool(tool, name, input, context))
: undefined
const codeModeCatalog = codemodeEnabled ? CodeModeTool.catalog(codemode) : undefined
return {
...(codeModeCatalog === undefined ? {} : { codeModeCatalog }),
definitions: [
...Array.from(direct)
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.map(([, tool]) => definition(tool)),
...(codemodeTool ? [definition(codemodeTool)] : []),
],
execute: (input: {
readonly sessionID: SessionSchema.ID
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
readonly call: ToolCall
readonly progress?: (update: Tool.Metadata) => Effect.Effect<void>
}) => {
const context: Tool.Context = {
sessionID: input.sessionID,
agent: input.agent,
messageID: input.messageID,
id: Tool.CallID.make(input.call.id),
progress: input.progress ?? (() => Effect.void),
}
if (input.call.name === "execute" && codemodeTool)
return executeTool(codemodeTool, input.call.name, input.call.input, context)
const tool = direct.get(input.call.name)
if (tool) return executeTool(tool, input.call.name, input.call.input, context)
return new Tool.Error({ message: `Unknown tool: ${input.call.name}` })
},
}
}),
),
Effect.sync(() => {
const active = new Map<string, Tool.Info>()
const rules = permissions ?? []
for (const [name, tool] of state.get().tools) {
if (whollyDisabled(tool.options?.permission ?? name, rules)) continue
active.set(name, tool)
}
const direct = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode === false))
const codemode = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode !== false))
const executeRule = rules.findLast((rule) => Wildcard.match("execute", rule.action))
const codemodeEnabled = executeRule?.resource !== "*" || executeRule.effect !== "deny"
const codemodeTool = codemodeEnabled
? CodeModeTool.create(codemode, (name, tool, input, context) => executeTool(tool, name, input, context))
: undefined
const codeModeCatalog = codemodeEnabled ? CodeModeTool.catalog(codemode) : undefined
return {
...(codeModeCatalog === undefined ? {} : { codeModeCatalog }),
definitions: [
...Array.from(direct)
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.map(([, tool]) => definition(tool)),
...(codemodeTool ? [definition(codemodeTool)] : []),
],
execute: (input: {
readonly sessionID: SessionSchema.ID
readonly agent: Agent.ID
readonly messageID: SessionMessage.ID
readonly call: ToolCall
readonly progress?: (update: Tool.Metadata) => Effect.Effect<void>
}) => {
const context: Tool.Context = {
sessionID: input.sessionID,
agent: input.agent,
messageID: input.messageID,
id: Tool.CallID.make(input.call.id),
progress: input.progress ?? (() => Effect.void),
}
if (input.call.name === "execute" && codemodeTool)
return executeTool(codemodeTool, input.call.name, input.call.input, context)
const tool = direct.get(input.call.name)
if (tool) return executeTool(tool, input.call.name, input.call.input, context)
return new Tool.Error({ message: `Unknown tool: ${input.call.name}` })
},
}
}),
),
})
}),
@@ -260,27 +262,22 @@ 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
: Effect.fail(new RegistrationError({ name, message: `Invalid tool name: ${name}` }))
const validateNamespace = (namespace: string) =>
namespace.split(".").every((segment) => /^[A-Za-z0-9_-]{1,64}$/.test(segment))
? Effect.void
: Effect.fail(
new RegistrationError({
name: namespace,
message: `Invalid tool namespace: ${JSON.stringify(namespace)}`,
}),
)
function registrationError(tool: Tool.Info) {
const namespace = tool.options?.namespace
if (namespace !== undefined && !namespace.split(".").every((segment) => /^[A-Za-z0-9_-]{1,64}$/.test(segment)))
return new RegistrationError({ name: namespace, message: `Invalid tool namespace: ${JSON.stringify(namespace)}` })
const name = normalizedName(tool)
if (!/^[A-Za-z0-9_-]{1,64}$/.test(name)) return new RegistrationError({ name, message: `Invalid tool name: ${name}` })
const id = effectiveName(tool)
if (tool.options?.codemode === false && id === "execute")
return new RegistrationError({ name: id, message: 'Tool name "execute" is reserved for CodeMode' })
const result = Result.try({
try: () => ToolDefinition.make(definition(tool)),
catch: (error) =>
new RegistrationError({ name: id, message: `Invalid tool definition ${id}: ${schemaMakeError(error)}` }),
})
return Result.isFailure(result) ? result.failure : undefined
}
const normalizedName = (tool: Tool.Info) => tool.name.replace(/[^a-zA-Z0-9_-]/g, "_")
@@ -289,12 +286,6 @@ const effectiveName = (tool: Tool.Info) =>
? normalizedName(tool)
: `${tool.options.namespace.replaceAll(".", "_")}_${normalizedName(tool)}`
const normalizedEntries = (tools: ReadonlyArray<Tool.Info>) =>
tools.map((tool) => ({
key: effectiveName(tool),
tool,
}))
export const node = makeLocationNode({
service: Service,
layer,
+10 -7
View File
@@ -30,17 +30,20 @@ Leaves own resolution, permission, and side-effect ordering. Translate only expe
## Registration
Built-ins, plugins, and MCP install tools through `ToolRegistry.Service.transform`, adding complete tool objects to the draft. A tool may provide a namespace, which flattens direct model names to `<namespace>_<tool>`, and defaults into CodeMode (`codemode` defaults true; `codemode: false` keeps the tool on the provider's native tool list).
Built-ins, plugins, and MCP install tools through `Tool.Service.transform`, adding complete tool objects to the draft. A tool may provide a namespace, which flattens direct model names to `<namespace>_<tool>`, and defaults into CodeMode (`codemode` defaults true; `codemode: false` keeps the tool on the provider's native tool list).
Registrations are scoped:
The service uses shared `State` to replay synchronous transforms in registration order against a fresh draft. `Tool.Service.reload()` rebuilds from captured source data without changing registration precedence. Registrations are scoped and return a real, idempotent `dispose` Effect:
- The latest active same-placement registration wins.
- Closing any registration removes only that registration and reveals the next active one.
- Each model request captures the effective tools it advertises; later registration changes affect later requests.
- The latest valid active registration for the same effective name wins.
- `update` and `remove` target effective names and do nothing for missing tools. Updates preserve the name and namespace; invalid updates leave the previous definition intact. Creating a tool requires `add`.
- Disposing a registration or closing its scope removes only its transform and rebuilds from the remaining transforms, revealing any earlier definition it overrode.
- Each model request captures the effective definitions and executors it advertises; later reloads and disposal affect later snapshots. Captured executors may still reference mutable producer-owned state.
MCP owns one stable tool transform that reads its latest discovered tools. Tool-list changes update that source and reload the tool state instead of re-registering at the end of the transform order. MCP refresh therefore preserves the precedence of later plugin overrides.
Type safety ends at registration. The registry validates model input and declared output at runtime and should not carry producer schema generics through storage or execution.
`ToolRegistry.Service` is Location-scoped. Do not make the registry process-global or construct a separate application-tool service for each Location.
`Tool.Service` is Location-scoped. Do not make the registry process-global or construct a separate application-tool service for each Location.
## Permissions
@@ -56,4 +59,4 @@ Producer capture limits remain local to producers. For example, Bash keeps `AppP
## Current Gaps
- MCP and future Session-scoped registrations still need an explicit canonical registration design.
- Future Session-scoped registrations still need an explicit canonical registration design.
+17 -16
View File
@@ -2,7 +2,7 @@ export * as McpTool from "./mcp.js"
import { ToolFailure } from "@opencode-ai/ai"
import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { Context, Effect, Exit, Fiber, type JsonSchema, Layer, Scope, Semaphore, Stream } from "effect"
import { Context, Effect, Fiber, type JsonSchema, Layer, Semaphore, Stream } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "../bus.js"
@@ -30,18 +30,15 @@ export const layer = Layer.effect(
const tools = yield* Tool.Service
const bus = yield* Bus.Service
const permission = yield* Permission.Service
const scope = yield* Scope.Scope
const lock = Semaphore.makeUnsafe(1)
let current: Scope.Closeable | undefined
let discovered: MCP.Tool[] = []
// Register the current tool set under a fresh child scope, then close the previous one so the
// registry never has a gap where MCP tools disappear mid-swap.
const reconcile = lock.withPermit(
Effect.gen(function* () {
const discovered = yield* mcp.tools()
const next = yield* Scope.fork(scope)
yield* tools
.transform((draft) => {
// Register once after initial discovery; only subsequent updates need a debounced reload.
const initial = yield* lock
.withPermit(
Effect.gen(function* () {
discovered = yield* mcp.tools()
yield* tools.transform((draft) => {
for (const tool of discovered) {
const schema = (tool.inputSchema ?? {}) as JsonSchema.JsonSchema
draft.add({
@@ -115,15 +112,19 @@ export const layer = Layer.effect(
})
}
})
.pipe(Scope.provide(next))
if (current) yield* Scope.close(current, Exit.void)
current = next
}),
)
.pipe(Effect.forkScoped)
const reconcile = lock.withPermit(
Effect.gen(function* () {
discovered = yield* mcp.tools()
yield* tools.reload()
}),
)
const initial = yield* reconcile.pipe(Effect.forkScoped)
yield* bus.subscribe(McpEvent.ToolsChanged).pipe(
Stream.runForEach(() => reconcile),
// Each read loads the whole catalog, so queued notifications need only one refresh.
Stream.runForEachArray(() => reconcile),
Effect.forkScoped({ startImmediately: true }),
)
return Service.of({ flush: Effect.asVoid(Fiber.await(initial)) })
+5 -6
View File
@@ -19,9 +19,8 @@ import { ToolOutput } from "../../tool-output.js"
export const name = "shell"
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
const BACKGROUND_STARTED = "The command was moved to the background."
const BACKGROUND_INSTRUCTION =
"You will be notified automatically when the command finishes. DO NOT sleep, poll, or proactively check on its progress."
"You will be notified automatically when the command finishes. Avoid sleep commands or polling for completion; if you need the output before then, read the file directly."
const OS =
process.platform === "darwin"
? "macOS"
@@ -95,8 +94,8 @@ const toolResult = (output: Output) => {
}
}
const backgroundResult = (shellID: string) => ({
output: BACKGROUND_STARTED,
const backgroundResult = (shellID: string, file: string) => ({
output: `Command moved to the background (shell ID: ${shellID}).\nOutput is streaming to: ${file}`,
shellID,
truncated: false,
status: "running" as const,
@@ -295,7 +294,7 @@ export const Plugin = {
if (input.background === true) {
yield* runtime.job.background(job.id)
yield* notifyWhenDone(context.sessionID, context.id, info.id, info.command, settled)
return backgroundResult(info.id)
return backgroundResult(info.id, info.file)
}
const result = yield* runtime.job
@@ -304,7 +303,7 @@ export const Plugin = {
if (result?.type === "backgrounded") {
yield* shell.timeout(info.id, 0)
yield* notifyWhenDone(context.sessionID, context.id, info.id, info.command, settled)
return backgroundResult(info.id)
return backgroundResult(info.id, info.file)
}
if (result?.info.status === "error")
return yield* Effect.fail(new Error(result.info.error ?? "Command failed"))
-146
View File
@@ -1,146 +0,0 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Context, Deferred, Effect, Exit, Layer, Scope } from "effect"
import type { Plugin } from "@opencode-ai/plugin/effect"
import { Global } from "@opencode-ai/util/global"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Application } from "@opencode-ai/core/application"
import { Database } from "@opencode-ai/core/database/database"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { Tool } from "@opencode-ai/core/tool"
import { tempGlobalLayer } from "./fixture/global"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(Layer.empty)
const options = {
database: { path: ":memory:" },
config: { project: false, content: JSON.stringify({ plugins: ["-opencode.*"] }) },
models: { fetch: false },
fs: { filewatcher: false, fff: false },
} satisfies Application.Options
describe("Application", () => {
it.live("shares the application's database across isolated Locations", () =>
Effect.gen(function* () {
const directory = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(directory) => Effect.promise(() => directory[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => fs.mkdir(path.join(directory.path, "second")))
const observed: Database.Service["Service"][] = []
const supervisor = makeLocationNode({
service: PluginSupervisor.Service,
layer: Layer.effect(
PluginSupervisor.Service,
Effect.gen(function* () {
const database = yield* Database.Service
observed.push(database)
return PluginSupervisor.Service.of({ flush: Effect.void })
}),
),
deps: [Database.node],
})
const context = yield* Layer.build(
Application.layer(options, [
[Global.node, tempGlobalLayer],
[PluginSupervisor.node, supervisor],
]),
)
const locations = Context.get(context, LocationServiceMap.Service)
const firstRef = Location.Ref.make({ directory: AbsolutePath.make(directory.path) })
const first = yield* locations.contextEffect(firstRef)
const again = yield* locations.contextEffect(firstRef)
const second = yield* locations.contextEffect(
Location.Ref.make({ directory: AbsolutePath.make(path.join(directory.path, "second")) }),
)
expect(observed).toHaveLength(2)
expect(observed.every((database) => database === Context.get(context, Database.Service))).toBe(true)
expect(Context.get(first, Tool.Service)).toBe(Context.get(again, Tool.Service))
expect(Context.get(first, Tool.Service)).not.toBe(Context.get(second, Tool.Service))
}),
)
it.live("isolates repeated application builds and binds plugins to their owning sessions", () =>
Effect.gen(function* () {
const directory = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(directory) => Effect.promise(() => directory[Symbol.asyncDispose]()),
)
const scope = yield* Effect.scope
const firstScope = yield* Scope.fork(scope)
const secondScope = yield* Scope.fork(scope)
const application = Application.layer(options, [[Global.node, tempGlobalLayer]])
const first = yield* Layer.build(application).pipe(Scope.provide(firstScope))
const second = yield* Layer.build(application).pipe(Scope.provide(secondScope))
const ref = Location.Ref.make({ directory: AbsolutePath.make(directory.path) })
const firstReady = yield* Deferred.make<Plugin.Context>()
const secondReady = yield* Deferred.make<Plugin.Context>()
yield* Context.get(first, SdkPlugins.Service).register({
id: "application-probe",
effect: (context) => Deferred.succeed(firstReady, context),
})
yield* Context.get(second, SdkPlugins.Service).register({
id: "application-probe",
effect: (context) => Deferred.succeed(secondReady, context),
})
yield* Context.get(first, LocationServiceMap.Service).contextEffect(ref).pipe(Scope.provide(firstScope))
yield* Context.get(second, LocationServiceMap.Service).contextEffect(ref).pipe(Scope.provide(secondScope))
const firstPlugin = yield* Deferred.await(firstReady).pipe(Effect.timeout("5 seconds"))
const secondPlugin = yield* Deferred.await(secondReady).pipe(Effect.timeout("5 seconds"))
const firstSession = yield* firstPlugin.session.create({ title: "first application" })
const secondSession = yield* secondPlugin.session.create({ title: "second application" })
expect(Context.get(first, Database.Service)).not.toBe(Context.get(second, Database.Service))
expect((yield* Context.get(first, Session.Service).get(firstSession.id)).title).toBe("first application")
expect(Exit.isFailure(yield* Context.get(second, Session.Service).get(firstSession.id).pipe(Effect.exit))).toBe(
true,
)
yield* Scope.close(firstScope, Exit.void)
expect((yield* secondPlugin.session.get({ sessionID: secondSession.id })).title).toBe("second application")
expect((yield* secondPlugin.session.create({ title: "still running" })).title).toBe("still running")
}),
)
it.live("preserves interruption options through the application-owned plugin bridge", () =>
Effect.gen(function* () {
const seen: { sessionID: Session.ID; options?: { readonly continue?: boolean } }[] = []
const context = yield* Layer.build(
Application.build(PluginRuntime.node, [
[Global.node, tempGlobalLayer],
[Database.node, Database.configured({ path: ":memory:" })],
[
SessionExecution.node,
Layer.succeed(
SessionExecution.Service,
SessionExecution.Service.of({
active: Effect.succeed(new Set()),
resume: () => Effect.void,
wake: () => Effect.void,
awaitIdle: () => Effect.void,
interrupt: (sessionID, options) =>
Effect.sync(() => {
seen.push({ sessionID, options })
return true
}),
}),
),
],
]),
)
const runtime = Context.get(context, PluginRuntime.Service)
const sessionID = Session.ID.create()
expect(yield* runtime.session.interrupt(sessionID, { continue: true })).toBe(true)
expect(seen).toEqual([{ sessionID, options: { continue: true } }])
}),
)
})
@@ -0,0 +1,336 @@
import { describe, expect } from "bun:test"
import { Deferred, Effect, Exit, Fiber, Stream } from "effect"
import { eq } from "drizzle-orm"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
import { Event } from "@opencode-ai/schema/event"
import { Project } from "@opencode-ai/schema/project"
import { AbsolutePath } from "@opencode-ai/schema/schema"
import { SessionID } from "@opencode-ai/schema/session-id"
import { SessionEvent } from "@opencode-ai/schema/session-event"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { WorkspaceID } from "@opencode-ai/schema/workspace-id"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node]), [
[Bus.node, Bus.configured({ persist: true })],
]),
)
const a = Location.Ref.make({ directory: AbsolutePath.make("/a") })
const b = Location.Ref.make({ directory: AbsolutePath.make("/b") })
const otherWorkspace = Location.Ref.make({ directory: a.directory, workspaceID: WorkspaceID.make("wrk_other") })
const id = SessionID.make("ses_routing")
const Done = Bus.ephemeral({ type: "test.routing.done", schema: {} })
const Global = Bus.ephemeral({ type: "test.routing.global", schema: { sessionID: SessionID } })
const seed = Effect.fn(function* (ref: Location.Ref = a) {
const database = yield* Database.Service
yield* database.db.insert(ProjectTable).values({ id: Project.ID.global, worktree: a.directory, sandboxes: [] }).run()
yield* database.db
.insert(SessionTable)
.values({
id,
project_id: Project.ID.global,
directory: ref.directory,
workspace_id: ref.workspaceID,
slug: "routing",
version: "test",
})
.run()
})
const watch = (bus: Bus.Interface, ref?: Location.Ref, gate?: Deferred.Deferred<void>) => {
const collect = bus.subscribe().pipe(
Stream.takeUntil((event) => event.type === Done.type),
Stream.mapEffect((event) => (gate ? Deferred.await(gate).pipe(Effect.as(event)) : Effect.succeed(event))),
Stream.runCollect,
)
return (ref ? collect.pipe(Effect.provideService(Location.Service, location(ref))) : collect).pipe(
Effect.forkScoped({ startImmediately: true }),
)
}
const delta = (bus: Bus.Interface) =>
bus.publish(SessionEvent.Text.Delta, {
sessionID: id,
assistantMessageID: SessionMessage.ID.make("msg_routing"),
ordinal: 0,
delta: "text",
})
describe("Bus Session routing", () => {
it.effect("delivers workspace-only moves to both owners without duplicating same-location moves", () =>
Effect.gen(function* () {
yield* seed()
const bus = yield* Bus.Service
const first = yield* watch(bus, a)
const second = yield* watch(bus, otherWorkspace)
const moved = yield* bus.publish(
SessionEvent.Moved,
{ sessionID: id, location: otherWorkspace, projectID: Project.ID.global },
{ location: a },
)
const after = yield* delta(bus)
const same = yield* bus.publish(SessionEvent.Moved, {
sessionID: id,
location: otherWorkspace,
projectID: Project.ID.global,
})
const done = yield* bus.publish(Done, {})
expect(Array.from(yield* Fiber.join(first))).toEqual([moved, done])
expect(Array.from(yield* Fiber.join(second))).toEqual([moved, after, same, done])
expect(moved.location).toEqual(a)
}),
)
it.effect("routes forks through their parent before the child exists", () =>
Effect.gen(function* () {
yield* seed()
const bus = yield* Bus.Service
const database = yield* Database.Service
yield* bus.publish(SessionEvent.Synthetic, { sessionID: id, text: "Fork boundary" })
const boundary = yield* database.db
.select({ id: SessionMessageTable.id })
.from(SessionMessageTable)
.where(eq(SessionMessageTable.session_id, id))
.get()
if (!boundary) return yield* Effect.die("Missing fork boundary")
yield* Effect.forEach(["publish", "batch", "replay"] as const, (mode) =>
Effect.gen(function* () {
const child = SessionID.create()
const first = yield* watch(bus, a)
const second = yield* watch(bus, b)
const payload = {
sessionID: child,
parentID: id,
boundary: { type: "before" as const, messageID: boundary.id },
}
const eventID = Event.ID.create()
if (mode === "publish") yield* bus.publish(SessionEvent.Forked, payload, { id: eventID })
if (mode === "batch") yield* bus.publishAll([[SessionEvent.Forked, payload, { id: eventID }]])
if (mode === "replay")
yield* bus.replay(
{
id: eventID,
type: Bus.versionedType(SessionEvent.Forked.type, 2),
seq: 0,
aggregateID: child,
data: payload,
},
{ publish: true },
)
const after = yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID: child })
const done = yield* bus.publish(Done, {})
expect(Array.from(yield* Fiber.join(first)).map((event) => event.id)).toEqual([eventID, after.id, done.id])
expect(Array.from(yield* Fiber.join(second))).toEqual([done])
}),
)
}),
)
it.effect("routes existing Sessions without changing public events or global delivery", () =>
Effect.gen(function* () {
yield* seed()
const bus = yield* Bus.Service
const first = yield* watch(bus, a)
const second = yield* watch(bus, b)
const workspace = yield* watch(bus, otherWorkspace)
const global = yield* watch(bus)
const listened: Event.Payload[] = []
yield* bus.listen((event) =>
Effect.sync(() => {
listened.push(event)
}),
)
const renamed = yield* bus.publish(SessionEvent.Renamed, { sessionID: id, title: "first" })
const text = yield* delta(bus)
const broadcast = yield* bus.publish(Global, { sessionID: id })
const explicit = yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID: id }, { location: b })
const done = yield* bus.publish(Done, {})
expect(Array.from(yield* Fiber.join(first))).toEqual([renamed, text, broadcast, done])
expect(Array.from(yield* Fiber.join(second))).toEqual([broadcast, explicit, done])
expect(Array.from(yield* Fiber.join(workspace))).toEqual([broadcast, done])
expect(Array.from(yield* Fiber.join(global))).toEqual([renamed, text, broadcast, explicit, done])
expect(listened).toEqual([renamed, text, broadcast, explicit, done])
expect(renamed).not.toHaveProperty("location")
expect(text).not.toHaveProperty("location")
expect(JSON.parse(JSON.stringify(renamed))).not.toHaveProperty("location")
const history = yield* bus.log({ aggregateID: id }).pipe(Stream.runCollect)
expect(
Array.from(history)
.filter((event): event is Event.Payload => !Bus.isSynced(event))
.every((event) => !event.location),
).toBe(true)
}),
)
it.effect("applies the same routing to typed and multi-type subscriptions", () =>
Effect.gen(function* () {
yield* seed()
const bus = yield* Bus.Service
const typed = yield* bus
.subscribe(SessionEvent.Renamed)
.pipe(
Stream.take(1),
Stream.runCollect,
Effect.provideService(Location.Service, location(b)),
Effect.forkScoped({ startImmediately: true }),
)
const multiple = yield* bus.subscribe([SessionEvent.Renamed, Done]).pipe(
Stream.takeUntil((event) => event.type === Done.type),
Stream.runCollect,
Effect.provideService(Location.Service, location(b)),
Effect.forkScoped({ startImmediately: true }),
)
yield* bus.publish(SessionEvent.Renamed, { sessionID: id, title: "wrong location" })
yield* bus.publish(SessionEvent.Moved, { sessionID: id, location: b, projectID: Project.ID.global })
const expected = yield* bus.publish(SessionEvent.Renamed, { sessionID: id, title: "destination" })
const done = yield* bus.publish(Done, {})
expect(Array.from(yield* Fiber.join(typed))).toEqual([expected])
expect(Array.from(yield* Fiber.join(multiple))).toEqual([expected, done])
}),
)
it.effect("snapshots routing across creation and moves for slow subscribers", () =>
Effect.gen(function* () {
const database = yield* Database.Service
yield* database.db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: a.directory, sandboxes: [] })
.run()
const bus = yield* Bus.Service
const gate = yield* Deferred.make<void>()
const first = yield* watch(bus, a, gate)
const second = yield* watch(bus, b, gate)
const created = yield* bus.publish(SessionEvent.Created, {
sessionID: id,
location: a,
projectID: Project.ID.global,
slug: "routing",
version: "test",
})
const before = yield* bus.publish(SessionEvent.Renamed, { sessionID: id, title: "before" })
const moved = yield* bus.publish(SessionEvent.Moved, { sessionID: id, location: b, projectID: Project.ID.global })
const after = yield* delta(bus)
const done = yield* bus.publish(Done, {})
yield* Deferred.succeed(gate, undefined)
expect(Array.from(yield* Fiber.join(first))).toEqual([created, before, moved, done])
expect(Array.from(yield* Fiber.join(second))).toEqual([moved, after, done])
expect(moved).not.toHaveProperty("location")
}),
)
it.effect("routes a cold Session deletion before its projector removes ownership", () =>
Effect.gen(function* () {
yield* seed()
const bus = yield* Bus.Service
const first = yield* watch(bus, a)
const second = yield* watch(bus, b)
const global = yield* watch(bus)
const deleted = yield* bus.publish(SessionEvent.Deleted, { sessionID: id })
const missing = yield* delta(bus)
const done = yield* bus.publish(Done, {})
const database = yield* Database.Service
expect(yield* database.db.select().from(SessionTable).where(eq(SessionTable.id, id)).get()).toBeUndefined()
expect(Array.from(yield* Fiber.join(first))).toEqual([deleted, done])
expect(Array.from(yield* Fiber.join(second))).toEqual([done])
expect(Array.from(yield* Fiber.join(global))).toEqual([deleted, missing, done])
}),
)
it.effect("preserves routing through a batch that moves and deletes a Session", () =>
Effect.gen(function* () {
yield* seed()
const bus = yield* Bus.Service
const gate = yield* Deferred.make<void>()
const first = yield* watch(bus, a, gate)
const second = yield* watch(bus, b, gate)
const events = yield* bus.publishAll([
[SessionEvent.Renamed, { sessionID: id, title: "before" }],
[SessionEvent.Moved, { sessionID: id, location: b, projectID: Project.ID.global }],
[SessionEvent.Renamed, { sessionID: id, title: "after" }],
[SessionEvent.Deleted, { sessionID: id }],
])
const done = yield* bus.publish(Done, {})
yield* Deferred.succeed(gate, undefined)
expect(Array.from(yield* Fiber.join(first))).toEqual([events[0], events[1], done])
expect(Array.from(yield* Fiber.join(second))).toEqual([events[1], events[2], events[3], done])
}),
)
it.effect("does not change ownership when single or batched moves roll back", () =>
Effect.gen(function* () {
yield* seed()
const bus = yield* Bus.Service
const first = yield* watch(bus, a)
const second = yield* watch(bus, b)
const before = yield* delta(bus)
const single = yield* bus
.publish(
SessionEvent.Moved,
{ sessionID: id, location: b, projectID: Project.ID.global },
{ commit: () => Effect.die("rollback") },
)
.pipe(Effect.exit)
const batch = yield* bus
.publishAll([
[SessionEvent.Moved, { sessionID: id, location: b, projectID: Project.ID.global }],
[SessionEvent.Renamed, { sessionID: id, title: "rollback" }, { commit: () => Effect.die("rollback") }],
])
.pipe(Effect.exit)
const after = yield* delta(bus)
const done = yield* bus.publish(Done, {})
expect(Exit.isFailure(single)).toBe(true)
expect(Exit.isFailure(batch)).toBe(true)
expect(Array.from(yield* Fiber.join(first))).toEqual([before, after, done])
expect(Array.from(yield* Fiber.join(second))).toEqual([done])
}),
)
it.effect("updates cached ownership on silent replay and filters published replay", () =>
Effect.gen(function* () {
yield* seed()
const bus = yield* Bus.Service
yield* delta(bus)
const first = yield* watch(bus, a)
const second = yield* watch(bus, b)
yield* bus.replay({
id: Event.ID.create(),
type: Bus.versionedType(SessionEvent.Moved.type, 1),
seq: 0,
aggregateID: id,
data: { sessionID: id, location: b, projectID: Project.ID.global },
})
const after = yield* delta(bus)
const replayID = Event.ID.create()
yield* bus.replay(
{
id: replayID,
type: Bus.versionedType(SessionEvent.Renamed.type, 1),
seq: 1,
aggregateID: id,
data: { sessionID: id, title: "replayed" },
},
{ publish: true },
)
const done = yield* bus.publish(Done, {})
expect(Array.from(yield* Fiber.join(first))).toEqual([done])
const received = Array.from(yield* Fiber.join(second))
expect(received.map((event) => event.id)).toEqual([after.id, replayID, done.id])
expect(received[1]).not.toHaveProperty("location")
}),
)
})
+27 -21
View File
@@ -9,6 +9,7 @@ import { llmClient } from "@opencode-ai/core/effect/app-node-platform"
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionModelRequest } from "@opencode-ai/core/session/model-request"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { Session } from "@opencode-ai/core/session"
import { Agent } from "@opencode-ai/core/agent"
@@ -38,19 +39,13 @@ const config = Config.testLayer()
const it = testEffect(
Layer.merge(
config,
AppNodeBuilder.build(LayerNode.group([SessionCompaction.node, Config.node, Bus.node]), [
AppNodeBuilder.build(LayerNode.group([SessionCompaction.node, SessionModelRequest.node, Config.node, Bus.node]), [
[
llmClient,
Layer.mock(LLMClient.Service)({
stream: () => Stream.make(LLMEvent.textDelta({ id: "summary", text: "summary" })),
}),
],
[
SessionRunnerModel.node,
Layer.mock(SessionRunnerModel.Service)({
resolve: () => Effect.succeed(resolved),
}),
],
[Config.node, config],
]),
),
@@ -59,6 +54,7 @@ describe("ConfigCompactionPlugin.Plugin", () => {
it.live("merges settings and reloads changed config", () =>
Effect.gen(function* () {
const compaction = yield* SessionCompaction.Service
const modelRequests = yield* SessionModelRequest.Service
const config = yield* Config.Test
const bus = yield* Bus.Service
yield* config.setEntries([
@@ -82,23 +78,33 @@ describe("ConfigCompactionPlugin.Plugin", () => {
const started = yield* bus
.subscribe(SessionEvent.Compaction.Started)
.pipe(Stream.runHead, Effect.forkScoped({ startImmediately: true }))
const messages: SessionMessage.Info[] = [
{
id: SessionMessage.ID.create(),
type: "user",
text: "Older context",
time: { created: DateTime.makeUnsafe(0) },
},
{
id: SessionMessage.ID.create(),
type: "user",
text: "Recent context",
time: { created: DateTime.makeUnsafe(1) },
},
]
expect(
yield* compaction.compactManual({
session,
messages: [
{
id: SessionMessage.ID.create(),
type: "user",
text: "Older context",
time: { created: DateTime.makeUnsafe(0) },
},
{
id: SessionMessage.ID.create(),
type: "user",
text: "Recent context",
time: { created: DateTime.makeUnsafe(1) },
},
],
prepare: modelRequests.prepare,
context: Effect.succeed({
session,
agent: { id: Agent.defaultID, info: Agent.Info.default(Agent.defaultID) },
model: resolved,
initial: "",
messages,
tools: { definitions: [], execute: () => Effect.die("Compaction must not execute tools") },
}),
messages,
inputID: SessionMessage.ID.make("msg_compaction_manual"),
}),
).toEqual({ status: "completed" })
@@ -58,23 +58,6 @@ void checkError
LayerNode.compile(a, [[a, Layer.succeed(A, A.of({}))]])
LayerNode.compile(a, [[a, make({ service: A, layer: Layer.succeed(A, A.of({})), deps: [] })]])
LayerNode.compile(a, [[a, make({ service: A, layer: Layer.mergeAll(aLayer, Layer.succeed(B, B.of({}))), deps: [] })]])
const invalidMissingOutputs = () => {
const empty = make({ service: A, layer: Layer.empty, deps: [] })
const bundle = make({ name: "bundle", layer: Layer.mergeAll(aLayer, Layer.succeed(B, B.of({}))), deps: [] })
const partial = make({ name: "bundle", layer: aLayer, deps: [] })
// @ts-expect-error A node replacement cannot remove all source outputs
LayerNode.compile(a, [[a, empty]])
// @ts-expect-error A node replacement must preserve every bundled output
LayerNode.compile(bundle, [[bundle, partial]])
// @ts-expect-error Hoisting enforces the same replacement output coverage
LayerNode.hoist(bundle, tags.values.app, [[bundle, partial]])
// @ts-expect-error Effective-graph inspection validates replacements too
LayerNode.hasUnbound(inputDependent, inputA, [[a, empty]])
}
void invalidMissingOutputs
// @ts-expect-error Replacement must provide A
LayerNode.compile(a, [[a, Layer.succeed(B, B.of({}))]])
@@ -43,58 +43,6 @@ describe("layer node", () => {
void check
})
test("keeps transitive dependencies private at runtime", async () => {
const program = Effect.gen(function* () {
return [(yield* Greeting).value, (yield* Effect.serviceOption(Value))._tag]
}).pipe(Effect.provide(LayerNode.compile(greeting)))
expect(await Effect.runPromise(program)).toEqual(["hello production", "None"])
})
test("builds roots in order and supplies earlier roots to later roots", async () => {
const acquired: string[] = []
const first = make({
service: Value,
layer: Layer.effect(
Value,
Effect.sync(() => {
acquired.push("first")
return Value.of({ value: "first" })
}),
),
deps: [],
})
const second = make({
service: Greeting,
layer: Layer.effect(
Greeting,
Effect.map(Effect.serviceOption(Value), (value) => {
acquired.push("second")
return Greeting.of({ value: value._tag })
}),
),
deps: [],
})
expect(
await Effect.runPromise(
Effect.map(Greeting, (item) => item.value).pipe(
Effect.provide(LayerNode.compile(LayerNode.group([first, second]))),
),
),
).toBe("Some")
expect(acquired).toEqual(["first", "second"])
acquired.length = 0
expect(
await Effect.runPromise(
Effect.map(Greeting, (item) => item.value).pipe(
Effect.provide(LayerNode.compile(LayerNode.group([second, first]))),
),
),
).toBe("None")
expect(acquired).toEqual(["second", "first"])
})
test("preserves branch-specific implementations across roots", async () => {
const firstValue = make({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "first" })), deps: [] })
const secondValue = make({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "second" })), deps: [] })
@@ -210,128 +158,6 @@ describe("layer node", () => {
expect(await Effect.runPromise(program)).toBe("hello replacement dependency")
})
test("applies earlier replacements inside later replacement nodes", async () => {
const replacement = make({ service: Greeting, layer: greetingLayer, deps: [value] })
const program = Effect.map(Greeting, (item) => item.value).pipe(
Effect.provide(
LayerNode.compile(greeting, [
[value, Layer.succeed(Value, Value.of({ value: "replacement dependency" }))],
[greeting, replacement],
]),
),
)
expect(await Effect.runPromise(program)).toBe("hello replacement dependency")
})
test("rejects replacements matching an actual target in another tag", () => {
const tags = LayerNode.tags({ location: ["global"], global: [] })
const global = tags.make("global")({ service: Value, layer: valueLayer, deps: [] })
const location = tags.make("location")({ service: Value, layer: valueLayer, deps: [] })
const unbound = LayerNode.unbound(Greeting, tags.values.location)
const replacements = [[global, valueLayer]] as const
expect(() => LayerNode.compile(location, replacements)).toThrow("Cannot replace test/LayerNodeValue across tags")
expect(() => LayerNode.hoist(location, tags.values.global, replacements)).toThrow(
"Cannot replace test/LayerNodeValue across tags",
)
expect(() => LayerNode.hasUnbound(location, unbound, replacements)).toThrow(
"Cannot replace test/LayerNodeValue across tags",
)
})
test("preserves same-tag replacements by service name", async () => {
const variant = make({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "variant" })), deps: [] })
const program = Effect.map(Value, (item) => item.value).pipe(
Effect.provide(LayerNode.compile(variant, [[value, Layer.succeed(Value, Value.of({ value: "replacement" }))]])),
)
expect(await Effect.runPromise(program)).toBe("replacement")
})
test("matches equivalent same-name source definitions", async () => {
const source = make({ service: Greeting, layer: greetingLayer, deps: [value] })
const program = Effect.map(Greeting, (item) => item.value).pipe(
Effect.provide(
LayerNode.compile(greeting, [[source, Layer.succeed(Greeting, Greeting.of({ value: "replacement" }))]]),
),
)
expect(await Effect.runPromise(program)).toBe("replacement")
})
test("checks cycles after the final replacement wins", async () => {
const replacementValue = make({
service: Value,
layer: Layer.effect(
Value,
Effect.map(Greeting, (item) => Value.of({ value: item.value })),
),
deps: [greeting],
})
const replacementGreeting = make({ service: Greeting, layer: greetingLayer, deps: [value] })
const cycle = [
[value, replacementValue],
[greeting, replacementGreeting],
] as const
const replacements = [...cycle, [value, value]] as const
expect(() => LayerNode.compile(greeting, cycle)).toThrow("Cycle detected in layer tree")
expect(() => LayerNode.hoist(greeting, tags.values.app, cycle)).toThrow("Cycle detected in layer tree")
const split = LayerNode.hoist(greeting, tags.values.app, replacements)
const read = Effect.map(Greeting, (item) => item.value)
expect(await Effect.runPromise(read.pipe(Effect.provide(LayerNode.compile(greeting, replacements))))).toBe(
"hello production",
)
expect(await Effect.runPromise(read.pipe(Effect.provide(LayerNode.compile(split.hoisted))))).toBe(
"hello production",
)
})
test("applies final overrides to dependencies referencing earlier replacements", async () => {
const profileValue = make({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "profile" })), deps: [] })
const profileGreeting = make({ service: Greeting, layer: greetingLayer, deps: [profileValue] })
const overrideValue = make({
service: Value,
layer: Layer.succeed(Value, Value.of({ value: "override" })),
deps: [],
})
const replacements = [
[value, profileValue],
[greeting, profileGreeting],
[value, overrideValue],
] as const
const read = Effect.map(Greeting, (item) => item.value)
const split = LayerNode.hoist(greeting, tags.values.app, replacements)
expect(await Effect.runPromise(read.pipe(Effect.provide(LayerNode.compile(greeting, replacements))))).toBe(
"hello override",
)
expect(await Effect.runPromise(read.pipe(Effect.provide(LayerNode.compile(split.hoisted))))).toBe("hello override")
})
test("inspects unbound nodes in the effective replacement graph", () => {
const unbound = LayerNode.unbound(Value, tags.values.app)
const independent = make({
service: Greeting,
layer: Layer.succeed(Greeting, Greeting.of({ value: "plain" })),
deps: [],
})
const dependent = make({ service: Greeting, layer: greetingLayer, deps: [unbound] })
expect(LayerNode.hasUnbound(independent, unbound)).toBe(false)
expect(LayerNode.hasUnbound(independent, unbound, [[independent, dependent]])).toBe(true)
expect(LayerNode.hasUnbound(dependent, unbound, [[dependent, independent]])).toBe(false)
expect(LayerNode.hasUnbound(dependent, unbound, [[unbound, value]])).toBe(false)
expect(
LayerNode.hasUnbound(independent, unbound, [
[independent, dependent],
[unbound, value],
]),
).toBe(false)
expect(() => LayerNode.hasUnbound(independent, value)).toThrow("Cannot check non-unbound layer node")
})
test("hoists and compiles tagged graphs", async () => {
const tags = LayerNode.tags({ location: ["global"], global: [] })
const global = tags.make("global")
@@ -411,100 +237,6 @@ describe("layer node", () => {
)
})
test("rejects conflicting implementations below another hoisted node", () => {
const competing = make({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "other" })), deps: [] })
expect(() => LayerNode.hoist(LayerNode.group([greeting, competing]), tags.values.app)).toThrow(
"Tag app has conflicting implementations for test/LayerNodeValue",
)
})
test("rejects hoisted nodes with the same implementation but different dependencies", () => {
const firstValue = LayerNode.make({ service: Value, layer: valueLayer, deps: [] })
const secondValue = LayerNode.make({
service: Value,
layer: Layer.succeed(Value, Value.of({ value: "other" })),
deps: [],
})
const first = make({ service: Greeting, layer: greetingLayer, deps: [firstValue] })
const second = make({ service: Greeting, layer: greetingLayer, deps: [secondValue] })
expect(() => LayerNode.hoist(LayerNode.group([first, second]), tags.values.app)).toThrow(
"Tag app has conflicting implementations for test/LayerNodeGreeting",
)
})
test("deduplicates matching hoisted definitions after applying replacements", async () => {
const duplicate = make({ service: Greeting, layer: greetingLayer, deps: [value] })
const split = LayerNode.hoist(LayerNode.group([greeting, duplicate]), tags.values.app, [
[value, Layer.succeed(Value, Value.of({ value: "replacement" }))],
])
expect(split.hoisted.dependencies).toHaveLength(1)
expect(
await Effect.runPromise(
Effect.map(Greeting, (item) => item.value).pipe(Effect.provide(LayerNode.compile(split.hoisted))),
),
).toBe("hello replacement")
})
test("deduplicates equivalent global closures through transparent dependency groups", () => {
const duplicateValue = make({ service: Value, layer: valueLayer, deps: [] })
const duplicateGreeting = make({
service: Greeting,
layer: greetingLayer,
deps: [LayerNode.group([duplicateValue])],
})
const split = LayerNode.hoist(LayerNode.group([greeting, duplicateGreeting]), tags.values.app)
expect(split.hoisted.dependencies).toEqual([greeting])
})
test("accepts equivalent untagged dependency closures while hoisting", async () => {
const firstValue = LayerNode.make({ service: Value, layer: valueLayer, deps: [] })
const secondValue = LayerNode.make({ service: Value, layer: valueLayer, deps: [] })
const first = make({ service: Greeting, layer: greetingLayer, deps: [firstValue] })
const second = make({ service: Greeting, layer: greetingLayer, deps: [secondValue] })
const split = LayerNode.hoist(LayerNode.group([first, second]), tags.values.app)
expect(split.hoisted.dependencies).toHaveLength(1)
expect(
await Effect.runPromise(
Effect.map(Greeting, (item) => item.value).pipe(Effect.provide(LayerNode.compile(split.hoisted))),
),
).toBe("hello production")
})
test("keeps hoisted services shared outside fresh local builds", async () => {
const acquisitions = { global: 0, local: 0 }
const shared = make({
service: Value,
layer: Layer.effect(
Value,
Effect.sync(() => Value.of({ value: String(++acquisitions.global) })),
),
deps: [],
})
const local = LayerNode.make({
service: Greeting,
layer: Layer.effect(
Greeting,
Effect.map(Value, (item) => Greeting.of({ value: `${item.value}:${++acquisitions.local}` })),
),
deps: [shared],
})
const split = LayerNode.hoist(local, tags.values.app)
const read = Effect.map(Greeting, (item) => item.value).pipe(
Effect.provide(Layer.fresh(LayerNode.compile(split.node))),
)
const program = Effect.gen(function* () {
return [yield* read, yield* read]
}).pipe(Effect.provide(LayerNode.compile(split.hoisted)))
expect(await Effect.runPromise(program)).toEqual(["1:1", "1:2"])
expect(acquisitions).toEqual({ global: 1, local: 2 })
})
test("treats dependency groups as transparent while hoisting", () => {
const tags = LayerNode.tags({ location: ["global"], global: [] })
const global = tags.make("global")
@@ -31,26 +31,6 @@ describe("node build", () => {
expect(await Effect.runPromise(program)).toBe("plain")
})
test("binds a location service map introduced by a replacement", async () => {
const original = Node.makeGlobalNode({
service: Result,
layer: Layer.succeed(Result, Result.of({ value: "original" })),
deps: [],
})
const replacement = Node.makeGlobalNode({
service: Result,
layer: Layer.effect(Result, Effect.as(LocationServiceMap.Service, Result.of({ value: "bound" }))),
deps: [LocationServiceMap.node],
})
const value = await Effect.runPromise(
Effect.map(Result, (result) => result.value).pipe(
Effect.provide(AppNodeBuilder.build(original, [[original, replacement]])),
),
)
expect(value).toBe("bound")
})
test("detects cycles through a replaced location service map", async () => {
const a = Node.makeGlobalNode({
service: CycleA,
+2 -4
View File
@@ -64,10 +64,8 @@ export const registerToolPlugin = <R>(
hook: () => Effect.succeed({ dispose: Effect.void }),
},
tool: {
transform: (callback) =>
tools
.transform((draft) => callback({ add: (tool) => draft.add(tool) }))
.pipe(Effect.orDie, Effect.as({ dispose: Effect.void })),
transform: tools.transform,
reload: tools.reload,
hook: () => Effect.die("registerToolPlugin does not support tool hooks"),
},
})
+37 -8
View File
@@ -81,6 +81,28 @@ const itWithActivity = testEffect(
)
describe("LocationServiceMap", () => {
itWithActivity.effect("does not refresh lifetime from inferred Session routing", () =>
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const bus = yield* Bus.Service
const ref = Location.Ref.make({ directory: AbsolutePath.make("/project") })
const sessionID = Session.ID.make("ses_routing_activity")
yield* Location.Service.pipe(Effect.provide(locations.get(ref)), Effect.scoped)
yield* bus.publish(SessionEvent.Created, {
sessionID,
location: ref,
projectID: Project.ID.global,
slug: "routing",
version: "test",
})
yield* TestClock.adjust("59 minutes")
const event = yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID })
expect(event).not.toHaveProperty("location")
yield* TestClock.adjust("2 minutes")
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([])
}),
)
itWithActivity.effect("refreshes lifetime from Session events only", () =>
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
@@ -775,8 +797,10 @@ describe("LocationServiceMap", () => {
}),
),
)
const failure = yield* SessionRunnerModel.Service.use((models) =>
models.resolve(
const failure = yield* Effect.gen(function* () {
const catalog = yield* Catalog.Service
const models = yield* SessionRunnerModel.Service
return yield* models.resolve(
Session.Info.make({
id: Session.ID.make("ses_unavailable_model"),
projectID: Project.ID.global,
@@ -790,8 +814,9 @@ describe("LocationServiceMap", () => {
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location,
}),
),
).pipe(Effect.provide(LocationServiceMap.Service.get(location)), Effect.flip)
catalog.model.available,
)
}).pipe(Effect.provide(LocationServiceMap.Service.get(location)), Effect.flip)
expect(failure).toMatchObject({
_tag: "SessionRunnerModel.ModelUnavailableError",
@@ -815,8 +840,10 @@ describe("LocationServiceMap", () => {
["azure-cognitive-services", "azure"],
["google-vertex-anthropic", "google-vertex"],
] as const) {
const failure = yield* SessionRunnerModel.Service.use((models) =>
models.resolve(
const failure = yield* Effect.gen(function* () {
const catalog = yield* Catalog.Service
const models = yield* SessionRunnerModel.Service
return yield* models.resolve(
Session.Info.make({
id: Session.ID.make(`ses_removed_${providerID}`),
projectID: Project.ID.global,
@@ -830,8 +857,9 @@ describe("LocationServiceMap", () => {
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location,
}),
),
).pipe(Effect.provide(LocationServiceMap.Service.get(location)), Effect.flip)
catalog.model.available,
)
}).pipe(Effect.provide(LocationServiceMap.Service.get(location)), Effect.flip)
expect(failure).toMatchObject({
_tag: "SessionRunnerModel.ModelUnavailableError",
@@ -883,6 +911,7 @@ describe("LocationServiceMap", () => {
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location,
}),
catalog.model.available,
)
}).pipe(Effect.provide(LocationServiceMap.Service.get(location)))
+117 -5
View File
@@ -36,6 +36,7 @@ import { Session } from "@opencode-ai/core/session"
import { McpTool } from "@opencode-ai/core/tool/mcp"
import { Tool } from "@opencode-ai/core/tool"
import { DateTime, Deferred, Effect, Exit, Fiber, Layer, PubSub, Ref, Schedule, Schema, Sink, Stream } from "effect"
import { TestClock } from "effect/testing"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { ExitCode, makeHandle, ProcessId } from "effect/unstable/process/ChildProcessSpawner"
import { Image } from "@opencode-ai/core/image"
@@ -1281,12 +1282,13 @@ test("serializes concurrent MCP lifecycle operations", async () => {
)
})
testEffect(Layer.empty).live("isolates invalid MCP tools and keeps catalog updates alive", () =>
testEffect(Layer.empty).live("isolates invalid MCP tools and preserves plugin transforms through catalog updates", () =>
Effect.gen(function* () {
const tool = (server: string, name: string) =>
const tool = (server: string, name: string, description = name) =>
new MCP.Tool({
server: MCP.ServerName.make(server),
name,
description,
codemode: false,
inputSchema: { type: "object", properties: {} },
})
@@ -1304,6 +1306,22 @@ testEffect(Layer.empty).live("isolates invalid MCP tools and keeps catalog updat
"other_lookup",
"execute",
])
const override = yield* registry.transform((draft) => {
draft.add({
name: "search",
options: { namespace: "demo", codemode: false },
description: "Override search",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.succeed({ output: "override" }),
})
})
const mutation = yield* registry.transform((draft) => {
draft.update("other_lookup", (tool) => {
tool.description += " updated"
})
draft.remove("repaired_lookup")
})
yield* Ref.set(catalog, [tool("demo", "y".repeat(65)), ...healthy, tool("demo", "added"), namespace])
yield* bus.publish(McpEvent.ToolsChanged, { server: "demo" })
@@ -1314,15 +1332,35 @@ testEffect(Layer.empty).live("isolates invalid MCP tools and keeps catalog updat
"other_lookup",
"execute",
])
expect((yield* toolDefinitions(registry)).find((tool) => tool.name === "demo_search")?.description).toBe(
"Override search",
)
expect((yield* toolDefinitions(registry)).find((tool) => tool.name === "other_lookup")?.description).toBe(
"lookup updated",
)
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" })))),
}).pipe(
Effect.tap((result) =>
Effect.sync(() =>
expect(result).toMatchObject({
status: "completed",
output: name === "demo_search" ? "override" : "healthy",
}),
),
),
),
)
yield* Ref.set(catalog, [tool("demo", "status"), ...healthy, tool("demo", "added"), tool("repaired", "lookup")])
yield* Ref.set(catalog, [
tool("demo", "status"),
tool("other", "lookup"),
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([
@@ -1330,9 +1368,43 @@ testEffect(Layer.empty).live("isolates invalid MCP tools and keeps catalog updat
"demo_search",
"demo_status",
"other_lookup",
"repaired_lookup",
"execute",
])
expect((yield* toolDefinitions(registry)).find((tool) => tool.name === "demo_search")?.description).toBe(
"Override search",
)
expect((yield* toolDefinitions(registry)).find((tool) => tool.name === "other_lookup")?.description).toBe(
"lookup updated",
)
yield* mutation.dispose
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toContain("repaired_lookup")
expect((yield* toolDefinitions(registry)).find((tool) => tool.name === "other_lookup")?.description).toBe(
"lookup",
)
yield* Ref.set(catalog, [tool("demo", "search", "Latest search"), tool("demo", "refreshed")])
yield* bus.publish(McpEvent.ToolsChanged, { server: "demo" })
yield* waitForTool(registry, "demo_refreshed")
expect((yield* toolDefinitions(registry)).find((tool) => tool.name === "demo_search")?.description).toBe(
"Override search",
)
yield* override.dispose
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([
"demo_refreshed",
"demo_search",
"execute",
])
expect((yield* toolDefinitions(registry)).find((tool) => tool.name === "demo_search")?.description).toBe(
"Latest search",
)
expect(
yield* executeTool(registry, {
sessionID: Session.ID.make("ses_mcp_invalid_catalog"),
...toolIdentity,
call: { type: "tool-call", id: "call_restored_search", name: "demo_search", input: {} },
}),
).toMatchObject({ status: "completed", output: "healthy" })
}).pipe(
Effect.provide(
Layer.fresh(
@@ -1361,6 +1433,46 @@ testEffect(Layer.empty).live("isolates invalid MCP tools and keeps catalog updat
}),
)
testEffect(Layer.empty).effect("coalesces queued MCP tool notifications after initial registration", () => {
let reads = 0
return Effect.gen(function* () {
const registry = yield* Tool.Service
const registration = yield* McpTool.Service
const bus = yield* Bus.Service
yield* registration.flush
expect(reads).toBe(1)
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["demo_read_1", "execute"])
yield* bus.publish(McpEvent.ToolsChanged, { server: "demo" })
yield* TestClock.adjust("250 millis")
yield* Effect.forEach(Array.from({ length: 20 }), () => bus.publish(McpEvent.ToolsChanged, { server: "demo" }))
yield* TestClock.adjust("2 seconds")
expect(reads).toBe(3)
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["demo_read_3", "execute"])
}).pipe(
Effect.provide(
AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node, Bus.node]), [
[
MCP.node,
Layer.mock(MCP.Service, {
tools: () =>
Effect.sync(() => [
new MCP.Tool({
server: MCP.ServerName.make("demo"),
name: `read_${++reads}`,
codemode: false,
inputSchema: { type: "object", properties: {} },
}),
]),
}),
],
[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
+1
View File
@@ -120,6 +120,7 @@ export function host(overrides: Overrides = {}): Plugin.Context {
},
tool: overrides.tool ?? {
transform: () => Effect.die("unused tool.transform"),
reload: () => Effect.die("unused tool.reload"),
hook: () => Effect.die("unused tool.hook"),
},
vcs: overrides.vcs ?? {
+1
View File
@@ -72,6 +72,7 @@ const run = Effect.fnUntraced(function* (events: ReadonlyArray<SessionEvent.Agen
},
tool: {
transform: () => Effect.die("unused tool.transform"),
reload: () => Effect.die("unused tool.reload"),
hook: (name, callback) => {
if (name === "execute.after") {
// Hook names and callbacks are correlated, but TypeScript does not narrow this generic registration API.
+144
View File
@@ -634,6 +634,150 @@ describe("fromPromise", () => {
}),
)
it.live("reloads and disposes Promise tools while preserving older snapshots", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const registry = yield* Tool.Service
const host = yield* PluginHost.make(plugins)
const source = { description: "Original", replays: 0 }
const registrations: Array<{ reload: () => Promise<void>; dispose: () => Promise<void> }> = []
yield* PluginPromise.fromPromise(
define({
id: "promise-tool-lifecycle",
setup: async (ctx) => {
expect(Object.keys(ctx.tool).sort()).toEqual(["hook", "reload", "transform"])
const registration = await ctx.tool.transform((draft) => {
source.replays++
const description = source.description
draft.add({
name: "reloadable",
description,
input: Schema.Struct({}),
output: Schema.String,
options: { codemode: false },
execute: async () => ({ output: description }),
})
expect(draft.list().map((tool) => tool.id)).toEqual(["reloadable"])
expect(draft.get("reloadable")?.id).toBe("reloadable")
expect(draft.get("reloadable")?.name).toBe("reloadable")
expect(draft.get("missing")).toBeUndefined()
})
registrations.push({ reload: ctx.tool.reload, dispose: registration.dispose })
},
}),
).effect(host)
const registration = registrations[0]
if (!registration) return yield* Effect.die("Promise tool registration was not captured")
const original = yield* registry.snapshot()
const execute = (snapshot: Tool.Snapshot) =>
snapshot.execute({
sessionID: Session.ID.make("ses_promise_tool_reload"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_promise_tool_reload"),
call: { type: "tool-call", id: "call_promise_tool_reload", name: "reloadable", input: {} },
})
source.description = "Reloaded"
yield* Effect.promise(() => registration.reload())
const reloaded = yield* registry.snapshot()
expect(source.replays).toBe(2)
expect(reloaded.definitions).toContainEqual(
expect.objectContaining({ name: "reloadable", description: "Reloaded" }),
)
expect(yield* execute(reloaded)).toMatchObject({ output: "Reloaded" })
expect(yield* execute(original)).toMatchObject({ output: "Original" })
yield* Effect.promise(() => registration.dispose())
yield* Effect.promise(() => registration.dispose())
expect((yield* registry.snapshot()).definitions.some((tool) => tool.name === "reloadable")).toBe(false)
expect(yield* execute(original)).toMatchObject({ output: "Original" })
expect(yield* execute(reloaded)).toMatchObject({ output: "Reloaded" })
yield* Effect.promise(() => registration.reload())
expect(source.replays).toBe(2)
expect((yield* registry.snapshot()).definitions.some((tool) => tool.name === "reloadable")).toBe(false)
}),
)
it.live("adapts tool updates, executor wrapping, and removal across replay and disposal", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const registry = yield* Tool.Service
const host = yield* PluginHost.make(plugins)
const progress: Tool.Metadata[] = []
let greeting = "Hello"
const registrations: Array<{ dispose: () => Promise<void> }> = []
yield* host.tool.transform((draft) => {
const text = greeting
draft.add({
name: "hello",
description: "Hello",
options: { namespace: "acme", codemode: false },
input: Schema.Struct({ name: Schema.String }),
output: Schema.String,
execute: ({ name }, context) =>
context.progress({ phase: "original" }).pipe(Effect.as({ output: `${text}, ${name}!` })),
})
draft.add({
name: "temporary",
description: "Temporary",
input: Schema.Struct({}),
options: { codemode: false },
execute: () => Effect.succeed({ content: "temporary" }),
})
})
yield* PluginPromise.fromPromise(
define({
id: "promise-tool-mutations",
setup: async (ctx) => {
registrations.push(
await ctx.tool.transform((draft) => {
draft.update("missing", () => {
throw new Error("must not create a tool")
})
draft.update("acme_hello", (tool) => {
const execute = tool.execute
tool.description = "Wrapped"
delete tool.output
tool.execute = async (input, context) => {
const result = await execute(input, context)
return { content: `${result.output} Wrapped.` }
}
})
draft.remove("temporary")
}),
)
greeting = "Hi"
await ctx.tool.reload()
},
}),
).effect(host)
const snapshot = yield* registry.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["acme_hello", "execute"])
expect(snapshot.definitions[0]?.description).toBe("Wrapped")
expect(snapshot.definitions[0]?.outputSchema).toBeUndefined()
expect(
yield* snapshot.execute({
sessionID: Session.ID.make("ses_promise_tool_update"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_promise_tool_update"),
call: { type: "tool-call", id: "call_update", name: "acme_hello", input: { name: "world" } },
progress: (update) =>
Effect.sync(() => {
progress.push(update)
}),
}),
).toMatchObject({ content: [{ type: "text", text: "Hi, world! Wrapped." }] })
expect(progress).toEqual([{ phase: "original" }])
const registration = registrations[0]
if (!registration) return yield* Effect.die("Promise tool registration was not captured")
yield* Effect.promise(() => registration.dispose())
yield* Effect.promise(() => registration.dispose())
const restored = yield* registry.snapshot()
expect(restored.definitions.map((tool) => tool.name)).toEqual(["acme_hello", "temporary", "execute"])
expect(restored.definitions[0]?.description).toBe("Hello")
}),
)
it.effect("returns content-only plugin results through Code Mode", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
+58 -27
View File
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test"
import { LLMClient, LLMEvent, LanguageModel, SystemPart, type LLMRequest } from "@opencode-ai/ai"
import { LLMClient, LLMEvent, LanguageModel, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -8,8 +8,10 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { EventTable } from "@opencode-ai/core/event/sql"
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
import type { SessionContext } from "@opencode-ai/core/session/context"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionModelRequest } from "@opencode-ai/core/session/model-request"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionTable } from "@opencode-ai/core/session/sql"
@@ -73,9 +75,18 @@ const resolved = SessionRunnerModel.resolved(model, {
cost,
limit: { context: 200_000, output: 32_000 },
})
const models = Layer.mock(SessionRunnerModel.Service)({
resolve: () => Effect.succeed(resolved),
})
const context = (
session: Session.Info,
messages: readonly SessionMessage.Info[],
): Effect.Effect<SessionContext.Loaded> =>
Effect.succeed({
session,
agent: { id: Agent.defaultID, info: { ...Agent.Info.default(Agent.defaultID), system: "Working agent system" } },
model: resolved,
initial: "Session instructions",
messages,
tools: { definitions: [], execute: () => Effect.die("Compaction must not execute tools") },
})
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
@@ -85,17 +96,17 @@ const it = testEffect(
SessionStore.node,
PluginHooks.node,
SessionCompaction.node,
SessionModelRequest.node,
]),
[
[Bus.node, Bus.configured({ persist: true })],
[llmClient, client],
[SessionRunnerModel.node, models],
],
),
)
test("compaction prompt preserves detailed work state and relevant files", () => {
const prompt = SessionCompaction.buildPrompt({ context: ["conversation history"] })
const prompt = SessionCompaction.buildPrompt()
expect(prompt).toContain("## Work State\n### Completed")
expect(prompt).toContain("### Active")
@@ -127,7 +138,7 @@ test("compaction truncation does not split surrogate pairs", () => {
})
test("compaction prompt requires the checkpoint headings in order", () => {
const prompt = SessionCompaction.buildPrompt({ context: ["Conversation history"] })
const prompt = SessionCompaction.buildPrompt()
expect(prompt.match(/^#{2,3} .+$/gm)).toEqual([
"## Objective",
"## Important Details",
@@ -242,6 +253,7 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
time: { created: DateTime.makeUnsafe(0) },
}
const session = yield* insertSession(sessionID, { parent_id: parentID })
const modelRequests = yield* SessionModelRequest.Service
const delta = yield* bus
.subscribe(SessionEvent.Compaction.Delta)
@@ -250,6 +262,8 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
expect(
yield* compaction.compactManual({
session,
prepare: modelRequests.prepare,
context: context(session, [userMessage]),
messages: [userMessage],
inputID: SessionMessage.ID.make("msg_manual_compaction"),
}),
@@ -268,6 +282,9 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
"x-opencode-client": "opencode",
})
expect(requests[0]?.generation).toBeUndefined()
expect(requests[0]?.system.map((part) => part.text)).toEqual(["Working agent system", "Session instructions"])
expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user", "user"])
expect(requests[0]?.messages.at(-1)?.content).toEqual([Message.text(SessionCompaction.buildPrompt())])
expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.")
expect(JSON.stringify(requests[0]?.messages)).toContain("Use Effect services and generators.")
expect(yield* store.context(sessionID)).toMatchObject([
@@ -303,17 +320,21 @@ it.effect("forked session compaction reuses the fork root prompt cache key", ()
fork_session_id: rootID,
fork_boundary: { type: "before", messageID: SessionMessage.ID.create() },
})
const modelRequests = yield* SessionModelRequest.Service
const messages: SessionMessage.Info[] = [
{
id: SessionMessage.ID.create(),
type: "user",
text: "Summarize the forked conversation.",
time: { created: DateTime.makeUnsafe(0) },
},
]
expect(
yield* compaction.compactManual({
session,
messages: [
{
id: SessionMessage.ID.create(),
type: "user",
text: "Summarize the forked conversation.",
time: { created: DateTime.makeUnsafe(0) },
},
],
context: context(session, messages),
messages,
prepare: modelRequests.prepare,
inputID: SessionMessage.ID.make("msg_fork_compaction"),
}),
).toEqual({ status: "completed" })
@@ -323,35 +344,45 @@ it.effect("forked session compaction reuses the fork root prompt cache key", ()
}),
)
it.effect("keeps session context hooks away from compaction requests", () =>
it.effect("applies the working agent's context hooks to compaction requests", () =>
Effect.gen(function* () {
requests = []
const compaction = yield* SessionCompaction.Service
// Context hooks shape the agent conversation; compaction is not part of it,
// so it opts out and the transcript passes through unchanged.
const hooks = yield* PluginHooks.Service
yield* hooks.register("session", "context", (event) =>
Effect.sync(() => {
expect(event.agent).toBe(Agent.defaultID)
event.system.push(SystemPart.make("Injected conversation context"))
event.messages.push(Message.user("Additional conversation context"))
}),
)
const session = yield* insertSession(Session.ID.make("ses_hook_compaction"))
const modelRequests = yield* SessionModelRequest.Service
const messages: SessionMessage.Info[] = [
{
id: SessionMessage.ID.create(),
type: "user",
text: "Summarize this conversation.",
time: { created: DateTime.makeUnsafe(0) },
},
]
expect(
yield* compaction.compactManual({
session,
messages: [
{
id: SessionMessage.ID.create(),
type: "user",
text: "Summarize this conversation.",
time: { created: DateTime.makeUnsafe(0) },
},
],
context: context(session, messages),
messages,
prepare: modelRequests.prepare,
inputID: SessionMessage.ID.make("msg_hook_compaction"),
}),
).toEqual({ status: "completed" })
expect(requests).toHaveLength(1)
expect(requests[0]?.system).toEqual([])
expect(requests[0]?.system.map((part) => part.text)).toEqual([
"Working agent system",
"Session instructions",
"Injected conversation context",
])
expect(requests[0]?.messages.at(-2)?.content).toEqual([Message.text("Additional conversation context")])
expect(requests[0]?.messages.at(-1)?.content).toEqual([Message.text(SessionCompaction.buildPrompt())])
}),
)
@@ -6,11 +6,13 @@ import { Image } from "@opencode-ai/core/image"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { Session } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { State } from "@opencode-ai/core/state"
import { Tool } from "@opencode-ai/core/tool"
import type { Info } from "@opencode-ai/schema/tool"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { executeTool, toolDefinitions } from "./lib/tool"
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Logger, Schema, SchemaGetter, SchemaIssue, Scope } from "effect"
import { TestClock } from "effect/testing"
import { z } from "zod"
import { testEffect } from "./lib/effect"
@@ -71,6 +73,240 @@ const transform = (service: Tool.Interface, tools: Readonly<Record<string, Info>
)
describe("Tool", () => {
it.effect("reads the current draft tools by effective name", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
yield* transform(service, { echo: make() }, { namespace: "acme", codemode: false })
yield* service.transform((draft) => {
expect(draft.list().map((tool) => tool.id)).toEqual(["acme_echo"])
expect(draft.get("acme_echo")?.id).toBe("acme_echo")
expect(draft.get("acme_echo")?.name).toBe("echo")
expect(draft.get("missing")).toBeUndefined()
})
}),
)
it.effect("replays mutations on refreshed sources and restores tools on disposal and scope cleanup", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
let text = "original"
const source = yield* Scope.make()
yield* service
.transform((draft) => {
draft.add({ ...constant(text), name: "echo", options: { namespace: "acme", codemode: false } })
draft.add({ ...make(), name: "hidden" })
})
.pipe(Scope.provide(source))
const original = yield* service.snapshot()
const update = yield* service.transform((draft) => {
draft.update("missing", () => {
throw new Error("must not create a tool")
})
draft.remove("missing")
draft.update("acme_echo", (tool) => {
const execute = tool.execute
tool.description = "Updated"
tool.execute = (input, context) =>
execute(input, context).pipe(
Effect.map((result) => ({ ...result, output: { text: `${result.output.text} updated` } })),
)
})
})
const scope = yield* Scope.make()
yield* service.transform((draft) => draft.remove("hidden")).pipe(Scope.provide(scope))
expect((yield* service.snapshot()).codeModeCatalog).toEqual([])
expect((yield* executeTool(service, call("acme_echo"))).output).toEqual({ text: "original updated" })
text = "refreshed"
const reload = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(reload)
const refreshed = yield* service.snapshot()
expect(refreshed.definitions[0]?.description).toBe("Updated")
expect(refreshed.codeModeCatalog).toEqual([])
expect((yield* refreshed.execute(call("acme_echo"))).output).toEqual({ text: "refreshed updated" })
expect((yield* original.execute(call("acme_echo"))).output).toEqual({ text: "original" })
yield* update.dispose
yield* update.dispose
expect((yield* executeTool(service, call("acme_echo"))).output).toEqual({ text: "refreshed" })
yield* Scope.close(scope, Exit.void)
expect((yield* service.snapshot()).codeModeCatalog?.map((tool) => tool.path)).toEqual(["hidden"])
yield* service.transform((draft) =>
draft.update("acme_echo", (tool) => {
tool.description = "Updated again"
}),
)
yield* Scope.close(source, Exit.void)
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
}),
)
it.effect("updates schemas and executors without renaming tools and applies removal in order", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
yield* service.transform((draft) => {
draft.add({ ...make(), options: { namespace: "acme.tools", codemode: false } })
draft.add({ ...make(), name: "removed", options: { codemode: false } })
draft.remove("removed")
draft.update("removed", () => {
throw new Error("must not resurrect a tool")
})
draft.remove("acme_tools_echo")
draft.add({ ...make(), options: { namespace: "acme.tools", codemode: false } })
draft.update("acme_tools_echo", (tool) => {
tool.name = "renamed"
tool.options = { namespace: "other", codemode: false }
tool.input = Schema.Struct({ value: Schema.Finite })
tool.output = Schema.Finite
tool.execute = ({ value }) => Effect.succeed({ output: value + 1 })
})
})
const snapshot = yield* service.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["acme_tools_echo", "execute"])
expect(snapshot.definitions[0]?.inputSchema.properties).toEqual({ value: { type: "number" } })
expect(
(yield* snapshot.execute({
...call("acme_tools_echo"),
call: {
type: "tool-call",
id: "updated",
name: "acme_tools_echo",
input: { value: 2 },
},
})).output,
).toBe(3)
expect(yield* snapshot.execute(call("acme_tools_echo")).pipe(Effect.flip)).toBeInstanceOf(Tool.Error)
}),
)
it.effect("skips invalid updates without dropping the existing definition", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
yield* transform(service, { echo: make() }, { codemode: false })
yield* service.transform((draft) =>
draft.update("echo", (tool) => {
Object.assign(tool, { description: undefined })
}),
)
expect((yield* service.snapshot()).definitions[0]?.description).toBe("Echo text")
expect((yield* executeTool(service, call("echo"))).output).toEqual({ text: "echo" })
}),
)
it.effect("replays empty sources on reload and keeps advertised snapshots", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
let source: Info[] = []
yield* service.transform((draft) => source.forEach((tool) => draft.add(tool)))
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
const tool = { ...constant("first"), name: "echo", options: { codemode: false } }
source = [tool]
const first = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(first)
const advertised = yield* service.snapshot()
expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" })
tool.execute = constant("second").execute
expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" })
const second = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(second)
expect((yield* executeTool(service, call("echo"))).output).toEqual({ text: "second" })
expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" })
source = []
const removed = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(removed)
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" })
}),
)
it.effect("disposes overlays once and replays remaining transforms in order", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
const runs: string[] = []
yield* service.transform((draft) => {
runs.push("base")
draft.add({ ...constant("base"), name: "echo", options: { codemode: false } })
})
const scope = yield* Scope.make()
const overlay = yield* service
.transform((draft) => {
runs.push("overlay")
draft.add({ ...constant("overlay"), name: "echo", options: { codemode: false } })
})
.pipe(Scope.provide(scope))
expect(runs).toEqual(["base", "base", "overlay"])
expect((yield* executeTool(service, call("echo"))).output).toEqual({ text: "overlay" })
yield* overlay.dispose
expect(runs).toEqual(["base", "base", "overlay", "base"])
expect((yield* executeTool(service, call("echo"))).output).toEqual({ text: "base" })
yield* overlay.dispose
yield* Scope.close(scope, Exit.void)
expect(runs).toEqual(["base", "base", "overlay", "base"])
}),
)
it.effect("batches tool publication and suppresses terminal teardown replay", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
const runs: string[] = []
const scope = yield* Scope.make()
yield* State.batch(
Effect.gen(function* () {
yield* service.transform((draft) => {
runs.push("base")
draft.add({ ...constant("base"), name: "echo", options: { codemode: false } })
})
yield* service.transform((draft) => {
runs.push("overlay")
draft.add({ ...constant("overlay"), name: "echo", options: { codemode: false } })
})
expect(runs).toEqual([])
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
}).pipe(Scope.provide(scope)),
)
expect(runs).toEqual(["base", "overlay"])
expect((yield* executeTool(service, call("echo"))).output).toEqual({ text: "overlay" })
yield* State.batch(Scope.close(scope, Exit.void), { flush: false })
expect(runs).toEqual(["base", "overlay"])
}),
)
it.effect("uses the last valid addition on replay and restores earlier transforms on disposal", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
yield* transform(service, { echo_tool: constant("base") }, { codemode: false })
let source = [{ ...constant("overlay"), name: "echo.tool", options: { codemode: false } }]
const registration = yield* service.transform((draft) => source.forEach((tool) => draft.add(tool)))
expect((yield* executeTool(service, call("echo_tool"))).output).toEqual({ text: "overlay" })
source = [...source, { ...constant("collision"), name: "echo_tool", options: { codemode: false } }]
const collision = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(collision)
expect((yield* executeTool(service, call("echo_tool"))).output).toEqual({ text: "collision" })
yield* registration.dispose
expect((yield* executeTool(service, call("echo_tool"))).output).toEqual({ text: "base" })
yield* service.transform((draft) => source.forEach((tool) => draft.add(tool)))
source = [{ ...constant("invalid"), name: "", options: { codemode: false } }]
const invalid = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(invalid)
expect((yield* executeTool(service, call("echo_tool"))).output).toEqual({ text: "base" })
}),
)
it.effect("logs and skips invalid dotted namespaces", () => {
const output: unknown[] = []
const logger = Logger.map(Logger.formatStructured, (entry) => {
@@ -92,7 +328,7 @@ describe("Tool", () => {
}).pipe(Effect.provide(Logger.layer([logger])))
})
it.effect("skips invalid, reserved, and colliding names without dropping healthy tools", () =>
it.effect("skips invalid and reserved names while letting the last normalized name win", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
yield* transform(
@@ -101,18 +337,18 @@ describe("Tool", () => {
before: make(),
"": make(),
["x".repeat(65)]: make(),
"echo.tool": make(),
echo_tool: make(),
"echo.tool": constant("first"),
echo_tool: constant("last"),
execute: make(),
after: make(),
},
{ codemode: false },
)
const snapshot = yield* service.snapshot()
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["after", "before", "execute"])
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["after", "before", "echo_tool", "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((yield* snapshot.execute(call("echo_tool"))).output).toEqual({ text: "last" })
expect(snapshot.codeModeCatalog).toEqual([])
}),
)
+164 -10
View File
@@ -1641,7 +1641,7 @@ describe("SessionRunnerLLM", () => {
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(requests[1]?.messages.at(-1)).toEqual(Message.user(SessionCompaction.buildPrompt()))
expect(userTexts(requests[2])[0]).toContain("<summary>\nEntry summary\n</summary>")
expect(yield* session.inbox(sessionID)).toEqual([])
}),
@@ -2287,7 +2287,7 @@ describe("SessionRunnerLLM", () => {
expect(requests).toHaveLength(4)
expect(userTexts(requests[1])).toContain("Steer after compaction")
expect(userTexts(requests[1])).toContain("Completion after compaction")
expect(userTexts(requests[2])[0]).toContain("Create a new anchored summary")
expect(requests[2]?.messages.at(-1)).toEqual(Message.user(SessionCompaction.buildPrompt()))
expect(userTexts(requests[3])).toContain("Queue after compaction")
expect(yield* SessionInbox.find((yield* Database.Service).db, first.id)).toBeUndefined()
expect((yield* session.messages({ sessionID })).find((message) => message.id === first.id)).toMatchObject({
@@ -2368,12 +2368,17 @@ describe("SessionRunnerLLM", () => {
yield* runPrompt(session, "Earlier question")
requests.length = 0
systemBaseline = "Changed before manual compaction"
yield* TestLLM.push(TestLLM.text("Manual summary", "text-manual-unknown-summary"))
const compaction = yield* session.compact({ sessionID, delivery: "steer" })
yield* session.resume(sessionID)
expect(requests).toHaveLength(1)
expect(userTexts(requests[0])[0]).toContain("Earlier question")
expect(requests[0]?.system.map((part) => part.text)).toEqual([defaultSystem, "Initial context"])
expect(messageRoles(requests[0])).toEqual(["user", "assistant", "system", "user"])
expect(systemTexts(requests[0])).toEqual(["Changed before manual compaction"])
expect(requests[0]?.messages.at(-1)).toEqual(Message.user(SessionCompaction.buildPrompt()))
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
type: "compaction",
status: "completed",
@@ -2404,7 +2409,7 @@ describe("SessionRunnerLLM", () => {
// Steer-delivered compaction runs at the boundary after the active step, ahead of
// the queued prompt, and consuming it does not trigger an input-free model call.
expect(requests).toHaveLength(3)
expect(userTexts(requests[1])[0]).toContain("Create a new anchored summary")
expect(requests[1]?.messages.at(-1)).toEqual(Message.user(SessionCompaction.buildPrompt()))
expect(userTexts(requests[2])).toContain("Queued prompt")
expect(yield* SessionInbox.find((yield* Database.Service).db, compaction.id)).toBeUndefined()
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
@@ -2421,7 +2426,13 @@ describe("SessionRunnerLLM", () => {
currentModel = recoveryModel
const stream = yield* TestLLM.gate
yield* TestLLM.push(
TestLLM.tool("call-active", "echo", { text: "active" }),
TestLLM.complete(
{ reason: { normalized: "tool-calls" } },
LLMEvent.reasoningStart({ id: "reasoning-active" }),
LLMEvent.reasoningDelta({ id: "reasoning-active", text: "Check the active work" }),
LLMEvent.reasoningEnd({ id: "reasoning-active", providerMetadata: { openai: { signature: "signed" } } }),
LLMEvent.toolCall({ id: "call-active", name: "echo", input: { text: "active" } }),
),
[LLMEvent.textDelta({ id: "summary", text: "durable summary" })],
TestLLM.text("Continued", "text-continued-after-compact"),
)
@@ -2435,7 +2446,20 @@ describe("SessionRunnerLLM", () => {
// The compaction summary is requested before the tool turn's continuation step.
expect(requests).toHaveLength(3)
expect(userTexts(requests[1])[0]).toContain("Create a new anchored summary")
expect(requests[1]?.messages.at(-1)).toEqual(Message.user(SessionCompaction.buildPrompt()))
expect(requests[1]?.system).toEqual(requests[0]?.system)
expect(requests[1]?.tools).toEqual(requests[0]?.tools)
expect(requests[1]?.tools.map((tool) => tool.name)).toContain("echo")
expect(messageRoles(requests[1])).toEqual(["user", "assistant", "tool", "user"])
expect(requests[1]?.messages[0]).toEqual(requests[0]?.messages[0])
expect(requests[1]?.messages[1]?.content).toMatchObject([
{ type: "reasoning", text: "Check the active work", providerMetadata: { openai: { signature: "signed" } } },
{ type: "tool-call", id: "call-active", name: "echo", input: { text: "active" } },
])
expect(requests[1]?.messages[2]?.content).toMatchObject([
{ type: "tool-result", id: "call-active", name: "echo", result: { type: "text", value: "active" } },
])
expect(executions).toEqual(["active"])
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
type: "compaction",
status: "completed",
@@ -2444,6 +2468,121 @@ describe("SessionRunnerLLM", () => {
}),
)
for (const mode of ["manual", "auto"] as const) {
it.effect(`uses the last assistant's custom agent after a switch for ${mode} compaction`, () =>
Effect.gen(function* () {
const session = yield* setup
const agents = yield* Agent.Service
const bus = yield* Bus.Service
const hooks = yield* PluginHooks.Service
const seen: Agent.ID[] = []
yield* agents.transform((draft) =>
draft.update(Agent.ID.make("reviewer"), (agent) => {
agent.mode = "primary"
agent.system = "Reviewer instructions"
}),
)
yield* hooks.register("session", "context", (event) =>
Effect.sync(() => {
seen.push(event.agent)
event.system.push(SystemPart.make(`Context hook for ${event.agent}`))
if (event.agent === "build") delete event.tools.echo
}),
)
yield* bus.publish(SessionEvent.AgentSelected, { sessionID, agent: Agent.ID.make("reviewer") })
yield* TestLLM.push(TestLLM.textWithUsage("Earlier answer", "text-custom-agent", 3_950))
yield* runPrompt(session, "Earlier question ".repeat(180))
const original = requests[0]
yield* bus.publish(SessionEvent.AgentSelected, { sessionID, agent: Agent.ID.make("build") })
currentModel = compactModel
requests.length = 0
seen.length = 0
yield* TestLLM.push(TestLLM.text("Reviewer summary", "text-custom-summary"))
if (mode === "manual") yield* session.compact({ sessionID })
if (mode === "auto") {
yield* admit(session, "Recent exact request ".repeat(180))
yield* TestLLM.push(TestLLM.text("Continued by build", "text-custom-continuation"))
}
yield* session.resume(sessionID)
expect(seen).toEqual(
mode === "manual" ? [Agent.ID.make("reviewer")] : [Agent.ID.make("reviewer"), Agent.ID.make("build")],
)
expect(requests[0]?.model).toBe(compactModel)
expect(requests[0]?.system).toEqual(original?.system)
expect(requests[0]?.system.map((part) => part.text)).toEqual([
"Reviewer instructions",
"Initial context",
"Context hook for reviewer",
])
expect(requests[0]?.tools).toEqual(original?.tools)
expect(requests[0]?.tools.map((tool) => tool.name)).toContain("echo")
expect(requests[0]?.messages[0]).toEqual(original?.messages[0])
expect(requests[0]?.messages.at(-1)).toEqual(Message.user(SessionCompaction.buildPrompt()))
expect(yield* session.context(sessionID)).toContainEqual(
expect.objectContaining({ type: "compaction", status: "completed", summary: "Reviewer summary" }),
)
expect((yield* session.get(sessionID))?.agent).toBe(Agent.ID.make("build"))
if (mode === "auto") {
expect(requests[1]?.system.map((part) => part.text)).toContain("Context hook for build")
expect(requests[1]?.tools.map((tool) => tool.name)).not.toContain("echo")
}
if (mode === "manual") {
expect((yield* session.context(sessionID)).some((message) => message.type === "assistant")).toBe(false)
yield* bus.publish(SessionEvent.AgentSelected, { sessionID, agent: Agent.ID.make("build") })
const input = yield* admit(session, "New input without an assistant response")
yield* bus.publish(SessionEvent.InboxDelivered, { sessionID, inboxID: input.id })
yield* TestLLM.push(TestLLM.text("Updated reviewer summary", "text-checkpoint-summary"))
yield* session.compact({ sessionID })
yield* session.resume(sessionID)
expect(requests).toHaveLength(2)
expect(seen).toEqual([Agent.ID.make("reviewer"), Agent.ID.make("reviewer")])
expect(requests[1]?.system).toEqual(original?.system)
expect(requests[1]?.tools).toEqual(original?.tools)
expect(userTexts(requests[1])[0]).toContain("<summary>\nReviewer summary\n</summary>")
expect(userTexts(requests[1])).toContain("New input without an assistant response")
expect(requests[1]?.messages.at(-1)).toEqual(Message.user(SessionCompaction.buildPrompt()))
expect(yield* session.context(sessionID)).toContainEqual(
expect.objectContaining({ type: "compaction", status: "completed", summary: "Updated reviewer summary" }),
)
}
}),
)
}
it.effect("fails manual compaction without executing a summarizer tool call even when it returns text", () =>
Effect.gen(function* () {
const session = yield* setup
yield* TestLLM.push(TestLLM.text("Earlier answer", "text-tool-summary-history"))
yield* runPrompt(session, "Earlier question")
yield* TestLLM.push(
TestLLM.complete(
{ reason: { normalized: "tool-calls" } },
LLMEvent.textDelta({ id: "summary", text: "Must not become a checkpoint" }),
LLMEvent.toolCall({ id: "call-summary", name: "echo", input: { text: "Must not execute" } }),
),
)
const compaction = yield* session.compact({ sessionID })
yield* session.resume(sessionID)
expect(requests).toHaveLength(2)
expect(requests[1]?.tools.map((tool) => tool.name)).toContain("echo")
expect(executions).toEqual([])
expect(authorizations).toEqual([])
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
type: "compaction",
status: "failed",
error: { type: "compaction.failed", message: "Compaction attempted to call a tool" },
})
expect(yield* session.context(sessionID)).toContainEqual(
expect.objectContaining({ type: "user", text: "Earlier question" }),
)
}),
)
it.effect("preserves provider errors from manual compaction", () =>
Effect.gen(function* () {
const session = yield* setup
@@ -2550,7 +2689,12 @@ describe("SessionRunnerLLM", () => {
yield* runPrompt(session, "Recent exact request ".repeat(180))
expect(requests).toHaveLength(2)
expect(userTexts(requests[0])[0]).toContain("## Objective")
expect(messageRoles(requests[0])).toEqual(["user", "assistant", "user"])
expect(userTexts(requests[0])).toEqual(["Earlier question ".repeat(180), SessionCompaction.buildPrompt()])
expect(requests[0]?.messages[1]?.content).toMatchObject([{ type: "text", text: "Earlier answer" }])
expect(requests[0]?.model).toBe(compactModel)
expect(requests[0]?.system).toEqual(requests[1]?.system)
expect(requests[0]?.tools).toEqual(requests[1]?.tools)
expect(userTexts(requests[1])).toHaveLength(1)
expect(userTexts(requests[1])[0]).toContain("<summary>\n## Objective\n- Preserve the task\n</summary>")
expect(userTexts(requests[1])[0]).toContain(`[User]: ${"Recent exact request ".repeat(180)}`)
@@ -2560,8 +2704,10 @@ describe("SessionRunnerLLM", () => {
expect(context[0]).toMatchObject({
type: "compaction",
summary: "## Objective\n- Preserve the task",
recent: `[User]: ${"Recent exact request ".repeat(180)}`,
})
const checkpoint = requests[1]?.messages[0]
requests.length = 0
executions.length = 0
yield* TestLLM.push(
@@ -2571,10 +2717,13 @@ describe("SessionRunnerLLM", () => {
yield* runPrompt(session, "Newest exact request ".repeat(180))
expect(requests).toHaveLength(2)
expect(userTexts(requests[0])[0]).toContain(
"<previous-summary>\n## Objective\n- Preserve the task\n</previous-summary>",
)
expect(requests[0]?.messages[0]).toEqual(checkpoint)
expect(requests[0]?.messages.at(-1)).toEqual(Message.user(SessionCompaction.buildPrompt()))
expect(userTexts(requests[0])[0]).toContain("<summary>\n## Objective\n- Preserve the task\n</summary>")
expect(userTexts(requests[0])[0]).toContain("Recent exact request")
expect(userTexts(requests[0]).join("\n")).not.toContain("<previous-summary>")
expect(userTexts(requests[0]).at(-1)).not.toContain("Preserve the task")
expect(userTexts(requests[0]).join("\n")).not.toContain("Newest exact request")
expect((yield* (yield* SessionStore.Service).context(sessionID))[0]).toMatchObject({
type: "compaction",
summary: "## Objective\n- Preserve the updated task",
@@ -2641,7 +2790,12 @@ describe("SessionRunnerLLM", () => {
yield* runPrompt(session, "Continue")
expect(requests).toHaveLength(3)
expect(userTexts(requests[1])[0]).toContain("## Objective")
expect(requests[1]?.messages.at(-1)).toEqual(Message.user(SessionCompaction.buildPrompt()))
expect(requests[1]?.messages.slice(0, -1)).toEqual(requests[0]?.messages.slice(0, -1))
expect(requests[1]?.system).toEqual(requests[0]?.system)
expect(requests[1]?.tools).toEqual(requests[0]?.tools)
expect(requests[1]?.model).toBe(recoveryModel)
expect(userTexts(requests[1])).not.toContain("Continue")
expect(userTexts(requests[2])[0]).toContain("<summary>\n## Objective\n- Recover overflow\n</summary>")
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "compaction", summary: "## Objective\n- Recover overflow" },
+4
View File
@@ -24,6 +24,8 @@ import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { SessionTitle } from "@opencode-ai/core/session/title"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Location } from "@opencode-ai/core/location"
import { Session } from "@opencode-ai/core/session"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
@@ -127,6 +129,8 @@ const it = testEffect(
[llmClient, client],
[Catalog.node, catalog],
[SessionRunnerModel.node, models],
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
[PluginSupervisor.node, Layer.mock(PluginSupervisor.Service, { flush: Effect.void })],
],
),
)
+25 -12
View File
@@ -5,7 +5,7 @@ import path from "path"
import { describe, expect } from "bun:test"
import { Cause, Deferred, Duration, Effect, Exit, Fiber, Layer, Queue, Scope, Stream } from "effect"
import { Money } from "@opencode-ai/schema/money"
import { Application } from "@opencode-ai/core/application"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { filesystem } from "@opencode-ai/util/effect/app-node-platform"
@@ -146,6 +146,7 @@ const nodes = LayerNode.group([
Job.node,
Session.node,
SessionExecution.node,
PluginRuntime.providerNode,
LocationServiceMap.node,
filesystem,
FSUtil.node,
@@ -156,10 +157,10 @@ const replacements = [
[Permission.node, permission],
[Global.node, tempGlobalLayer],
] satisfies LayerNode.Replacements
const productionIt = testEffect(Application.build(nodes, replacements))
const it = testEffect(Application.build(nodes, [...replacements, [PluginSupervisor.node, shellPluginSupervisor]]))
const productionIt = testEffect(AppNodeBuilder.build(nodes, replacements))
const it = testEffect(AppNodeBuilder.build(nodes, [...replacements, [PluginSupervisor.node, shellPluginSupervisor]]))
const permissionIt = testEffect(
Application.build(LayerNode.group([nodes, PermissionSaved.node]), [
AppNodeBuilder.build(LayerNode.group([nodes, PermissionSaved.node]), [
[SessionExecution.node, executionNode],
[Global.node, tempGlobalLayer],
[PluginSupervisor.node, shellPluginSupervisor],
@@ -1297,6 +1298,17 @@ describe("ShellTool", () => {
const shell = yield* Shell.Service
if (!shellID) return
const id = ShellSchema.ID.make(shellID)
const info = yield* shell.get(id)
expect(settled.content).toEqual([
{
type: "text",
text: `Command moved to the background (shell ID: ${shellID}).\nOutput is streaming to: ${info.file}`,
},
{
type: "text",
text: "You will be notified automatically when the command finishes. Avoid sleep commands or polling for completion; if you need the output before then, read the file directly.",
},
])
expect((yield* shell.list()).map((info) => info.id)).toContain(id)
expect((yield* shell.wait(id)).status).toBe("timeout")
expect((yield* Fiber.join(admitted)).valueOrUndefined?.data.item.payload).toMatchObject({
@@ -1522,19 +1534,20 @@ describe("ShellTool", () => {
const settled = yield* Fiber.join(waiting)
const shellID = typeof settled.metadata?.shellID === "string" ? settled.metadata.shellID : undefined
expect(settled.metadata).toMatchObject({ truncated: false })
expect(settled.content?.[0]).toEqual({
type: "text",
text: "The command was moved to the background.",
})
expect(settled.content?.[1]).toMatchObject({
type: "text",
text: expect.stringContaining("DO NOT sleep, poll"),
})
expect(shellID).toStartWith("sh_")
const shell = yield* Shell.Service
if (!shellID) return
const id = ShellSchema.ID.make(shellID)
const info = yield* shell.get(id)
expect(settled.content?.[0]).toEqual({
type: "text",
text: `Command moved to the background (shell ID: ${shellID}).\nOutput is streaming to: ${info.file}`,
})
expect(settled.content?.[1]).toEqual({
type: "text",
text: "You will be notified automatically when the command finishes. Avoid sleep commands or polling for completion; if you need the output before then, read the file directly.",
})
yield* Effect.sleep(Duration.millis(100))
expect((yield* shell.get(id)).status).toBe("running")
expect((yield* shell.list()).map((info) => info.id)).toContain(id)
+4 -3
View File
@@ -2,7 +2,7 @@ import { describe, expect } from "bun:test"
import { Effect, Fiber, Layer, Schema, Stream } from "effect"
import path from "path"
import { Money } from "@opencode-ai/schema/money"
import { Application } from "@opencode-ai/core/application"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
@@ -111,14 +111,15 @@ const nodes = LayerNode.group([
Job.node,
Session.node,
SessionExecution.node,
PluginRuntime.providerNode,
LocationServiceMap.node,
])
const replacements = [
[SessionExecution.node, executionNode],
[Global.node, tempGlobalLayer],
] satisfies LayerNode.Replacements
const productionIt = testEffect(Application.build(nodes, replacements))
const it = testEffect(Application.build(nodes, [...replacements, [PluginSupervisor.node, subagentPluginSupervisor]]))
const productionIt = testEffect(AppNodeBuilder.build(nodes, replacements))
const it = testEffect(AppNodeBuilder.build(nodes, [...replacements, [PluginSupervisor.node, subagentPluginSupervisor]]))
const withSubagent = (location: Location.Ref) =>
Effect.gen(function* () {
+7 -1
View File
@@ -2,13 +2,18 @@ import { Tool } from "@opencode-ai/schema/tool"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Session } from "@opencode-ai/schema/session"
import type { SessionMessage } from "@opencode-ai/schema/session-message"
import type { JsonSchema } from "effect"
import type { Effect, JsonSchema, Types } from "effect"
import type { Hooks, Transform } from "./registration.js"
export interface ToolDraft {
list(): readonly (Tool.Info & { readonly id: string })[]
get(id: string): (Tool.Info & { readonly id: string }) | undefined
add<Input extends Tool.ValueSchema<any>, Output extends Tool.ValueSchema<any> | undefined>(
tool: Tool.Info<Input, Output>,
): void
/** Updates an existing tool; missing IDs are ignored. */
update(id: string, update: (tool: Types.Mutable<Tool.Info>) => void): void
remove(id: string): void
}
export interface ToolHooks {
@@ -48,5 +53,6 @@ export interface ToolFailures extends Record<keyof ToolHooks, unknown> {
export interface ToolDomain {
readonly transform: Transform<ToolDraft>
readonly reload: () => Effect.Effect<void>
readonly hook: Hooks<ToolHooks, ToolFailures>
}
+42
View File
@@ -99,6 +99,20 @@ export function fromPromise(plugin: Plugin) {
const run = <A, E>(effect: Effect.Effect<A, E>) => Effect.runPromiseWith(context)(effect)
const promiseTool = (tool: Tool.Info & { readonly id: string }): Info & { readonly id: string } => {
const execute = tool.execute
return {
...tool,
execute: (input, context) =>
run(
execute(input, {
...context,
progress: (update) => Effect.promise(() => context.progress(update)),
}),
),
}
}
const adaptApiMethod = <PromiseMethod>(
endpoint: HttpApiEndpoint.Top,
method: (input: never) => Effect.Effect<unknown, unknown>,
@@ -291,15 +305,43 @@ export function fromPromise(plugin: Plugin) {
scan: (options) => run(host.storage.scan(options)),
},
tool: {
reload: () => run(host.tool.reload()),
transform: (callback) =>
register(
host.tool.transform((draft) =>
callback({
list: () => draft.list().map((tool) => promiseTool(tool)),
get: (id) => {
const tool = draft.get(id)
return tool ? promiseTool(tool) : undefined
},
add: (tool: Info) =>
draft.add({
...tool,
execute: (input, context) => executePromiseTool(tool, input, context),
}),
update: (id, update) =>
draft.update(id, (tool) => {
const execute = tool.execute
const value: Info = {
...tool,
execute: (input, context) =>
run(
execute(input, {
...context,
progress: (update) => Effect.promise(() => context.progress(update)),
}),
),
}
update(value)
Object.assign(tool, value, {
output: value.output,
options: value.options,
execute: (input: Parameters<Info["execute"]>[0], context: Tool.Context) =>
executePromiseTool(value, input, context),
})
}),
remove: draft.remove,
}),
),
),
+7 -1
View File
@@ -5,7 +5,7 @@ import { Tool } from "@opencode-ai/schema/tool"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Session } from "@opencode-ai/schema/session"
import type { SessionMessage } from "@opencode-ai/schema/session-message"
import type { JsonSchema } from "effect"
import type { JsonSchema, Types } from "effect"
import type { Hooks, Transform } from "./registration.js"
export interface ToolContext extends Omit<Tool.Context, "progress"> {
@@ -23,9 +23,14 @@ export type Info<
}
interface ToolDraft {
list(): readonly (Info & { readonly id: string })[]
get(id: string): (Info & { readonly id: string }) | undefined
add<Input extends Tool.ValueSchema<any>, Output extends Tool.ValueSchema<any> | undefined>(
tool: Info<Input, Output>,
): void
/** Updates an existing tool; missing IDs are ignored. */
update(id: string, update: (tool: Types.Mutable<Info>) => void): void
remove(id: string): void
}
interface ToolHooks {
@@ -59,5 +64,6 @@ interface ToolHooks {
export interface ToolDomain {
readonly transform: Transform<ToolDraft>
readonly reload: () => Promise<void>
readonly hook: Hooks<ToolHooks>
}
+65
View File
@@ -6,6 +6,7 @@ import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { TestLLM } from "@opencode-ai/ai/testing"
import { llmClient } from "@opencode-ai/core/effect/app-node-platform"
import { makeMemoryDriver } from "@opencode-ai/core/environment/index"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
import { Deferred, Effect, Fiber, Latch, Layer, Option, Ref, Schema, Stream } from "effect"
@@ -34,6 +35,70 @@ const sessionID = (fixture: Fixture) => fixture.sdk.Session.ID.create()
const location = (fixture: Fixture) =>
fixture.sdk.Location.Ref.make({ directory: fixture.sdk.AbsolutePath.make(fixture.directory) })
for (const selection of ["explicit", "default"] as const) {
it.live(`first generate.text waits for inline providers with ${selection} model selection`, () =>
withEmbedded("opencode-embedded-generate-", (fixture) =>
Effect.gen(function* () {
const release = yield* Latch.make()
const llm = yield* TestLLM.Service.pipe(
Effect.provide(TestLLM.layer({ fallback: TestLLM.text("ready", "answer") })),
)
const supervisor = Layer.effect(
PluginSupervisor.Service,
Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
return { flush: release.open.pipe(Effect.andThen(plugins.flush)) }
}),
).pipe(Layer.provide(PluginSupervisor.layer))
const opencode = yield* fixture.sdk.OpenCode.create(
{
config: {
directory: fixture.directory,
project: false,
content: JSON.stringify({
model: "custom/fictional-chat",
providers: {
custom: {
package: "aisdk:@ai-sdk/openai-compatible",
settings: { baseURL: "https://provider.example/v1" },
models: { "fictional-chat": {} },
},
},
}),
},
models: { fetch: false },
fs: { filewatcher: false },
},
{
overrides: [
[llmClient, Layer.succeed(LLMClient.Service, llm.client)],
[PluginSupervisor.node, { ...PluginSupervisor.node, implementation: supervisor }],
],
},
)
// Hold provider activation until the request reaches readiness, regardless of startup speed.
yield* opencode.plugin({ id: "gate-catalog", effect: () => release.await })
const result = yield* opencode.generate.text({
prompt: "Say ready",
...(selection === "explicit"
? {
model: fixture.sdk.Model.Ref.make({
providerID: fixture.sdk.Provider.ID.make("custom"),
id: fixture.sdk.Model.ID.make("fictional-chat"),
}),
}
: {}),
})
expect(result.text).toBe("ready")
expect(llm.requests).toHaveLength(1)
expect(llm.requests[0]?.model).toMatchObject({ provider: "custom", id: "fictional-chat" })
}),
),
)
}
it.live("exposes app metadata to plugins", () =>
withEmbedded("opencode-embedded-app-", (fixture) =>
Effect.gen(function* () {
+12 -2
View File
@@ -7,6 +7,15 @@ import { Global } from "@opencode-ai/util/global"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
import { pluginReadiness } from "./plugin-readiness"
const flushPlugins = pluginReadiness(
() =>
new ServiceUnavailableError({
message: "Model catalog initialization timed out",
service: "model.catalog",
}),
)
export const GenerateHandler = HttpApiBuilder.group(Api, "server.generate", (handlers) =>
Effect.gen(function* () {
@@ -16,7 +25,8 @@ export const GenerateHandler = HttpApiBuilder.group(Api, "server.generate", (han
return handlers.handle(
"generate.text",
Effect.fn("server.generate.text")(function* (request) {
const generate = yield* Generate.Service.pipe(Effect.provide(services))
yield* flushPlugins
const generate = yield* Generate.Service
const text = yield* generate
.text(request.payload)
.pipe(
@@ -27,7 +37,7 @@ export const GenerateHandler = HttpApiBuilder.group(Api, "server.generate", (han
),
)
return { data: { text } }
}),
}, Effect.provide(services)),
)
}),
)
+35 -2
View File
@@ -1,11 +1,44 @@
import { ApplicationOptions } from "@opencode-ai/core/application/options"
import { Database } from "@opencode-ai/core/database/database"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { Schema } from "effect"
export const ServerOptions = Schema.Struct({
...ApplicationOptions.Options.fields,
app: Schema.optional(
Schema.Struct({
name: Schema.optional(Schema.String),
version: Schema.optional(Schema.String),
channel: Schema.optional(Schema.String),
}),
),
hostname: Schema.optional(Schema.String),
port: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(65_535))),
password: Schema.optional(Schema.String),
simulation: Schema.optional(Schema.Boolean),
database: Schema.optional(Database.Options),
events: Schema.optional(
Schema.Struct({
persist: Schema.optional(Schema.Boolean),
}),
),
models: Schema.optional(ModelsDev.Options),
config: Schema.optional(
Schema.Struct({
directory: Schema.optional(Schema.String),
project: Schema.optional(Schema.Boolean),
file: Schema.optional(Schema.String),
content: Schema.optional(Schema.String),
}),
),
windows: Schema.optional(
Schema.Struct({
gitbash: Schema.optional(Schema.String),
}),
),
fs: Schema.optional(
Schema.Struct({
filewatcher: Schema.optional(Schema.Boolean),
fff: Schema.optional(Schema.Boolean),
}),
),
})
export type ServerOptions = typeof ServerOptions.Type
+86 -4
View File
@@ -1,11 +1,35 @@
import { Database } from "@opencode-ai/core/database/database"
import { V1Migration } from "@opencode-ai/core/database/v1-migration"
import { App } from "@opencode-ai/core/app"
import { Application } from "@opencode-ai/core/application"
import type { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Bus } from "@opencode-ai/core/bus"
import { EventLogger } from "@opencode-ai/core/event-logger"
import { FileSystemSearch } from "@opencode-ai/core/filesystem/search"
import { Credential } from "@opencode-ai/core/credential"
import { Config } from "@opencode-ai/core/config"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { PersistentPty } from "@opencode-ai/core/persistent-pty"
import { Project } from "@opencode-ai/core/project"
import { Session } from "@opencode-ai/core/session"
import { SessionTransfer } from "@opencode-ai/core/session/transfer"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { Job } from "@opencode-ai/core/job"
import { MCP } from "@opencode-ai/core/mcp/index"
import { Global } from "@opencode-ai/util/global"
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { LocationActivity } from "@opencode-ai/core/location-activity"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { WellKnown } from "@opencode-ai/core/wellknown"
import { Workspace } from "@opencode-ai/core/workspace"
import { Worktree } from "@opencode-ai/core/worktree"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { HttpRouter } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Context, Effect, Layer, Option } from "effect"
@@ -21,6 +45,32 @@ import { sessionLocationLayer } from "./middleware/session-location"
import { ServerInfo } from "./server-info"
import type { ServerOptions } from "./options"
const applicationServiceNodes = [
Global.node,
Database.node,
Bus.node,
EventLogger.node,
httpClient,
Job.node,
Project.node,
Worktree.node,
Session.node,
SessionTransfer.node,
PluginRuntime.providerNode,
SdkPlugins.node,
PermissionSaved.node,
PtyTicket.node,
PersistentPty.node,
Credential.node,
WellKnown.node,
PtyEnvironment.node,
LocationServiceMap.node,
LocationActivity.node,
SessionRestart.node,
Workspace.node,
] as const
const applicationServices = LayerNode.group(applicationServiceNodes)
export function createRoutes(
options: ServerOptions = {},
serviceURLs: () => ReadonlyArray<string> = () => [],
@@ -47,15 +97,47 @@ function makeRoutes<AuthError, AuthServices>(
// Runtime-profile replacements (e.g. workerd) applied after the standard set, so later entries win.
overrides: LayerNode.Replacements,
) {
const pluginRuntimeCell = PluginRuntime.makeCell()
const standard: LayerNode.Replacements = [
[Database.node, Database.configured(options.database)],
[Bus.node, Bus.configured({ persist: options.events?.persist })],
[App.node, App.configured(options.app)],
[ModelsDev.node, ModelsDev.configured(options.models)],
[Watcher.node, Watcher.configured({ enabled: options.fs?.filewatcher })],
[FileSystemSearch.node, FileSystemSearch.configured({ fff: options.fs?.fff })],
[Global.node, Global.layerWith(options.config?.directory ? { config: options.config.directory } : {})],
[
Config.node,
Config.configured({
project: options.config?.project,
file: options.config?.file,
content: options.config?.content,
}),
],
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: options.config?.project })],
[ShellSelect.node, ShellSelect.configured({ gitbash: options.windows?.gitbash })],
[
MCP.node,
MCP.configured({
clientInfo: {
name: options.app?.name ?? "opencode",
version: options.app?.version ?? "unknown",
},
}),
],
[PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)],
[PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)],
]
const replacements: LayerNode.Replacements = [...standard, ...overrides]
const serviceLayer = options.simulation
? Layer.unwrap(
Effect.gen(function* () {
const { simulationReplacements } = yield* Effect.promise(() => import("@opencode-ai/simulation/backend"))
const simulation = yield* simulationReplacements({ version: App.make(options.app).version })
return Application.layer(options, [...overrides, ...simulation], PtyEnvironment.node)
return AppNodeBuilder.build(applicationServices, [...replacements, ...simulation])
}),
)
: Application.layer(options, overrides, PtyEnvironment.node)
: AppNodeBuilder.build(applicationServices, replacements)
return serviceLayer.pipe(
Layer.flatMap((context) => {
const services = Layer.succeedContext(context)
@@ -0,0 +1,36 @@
import { createSignal, Show } from "solid-js"
import { render } from "solid-js/web"
import { Markdown } from "../src/components/markdown"
import { preloadMarkdown } from "../src/components/markdown-cache"
export async function mountMarkdown(options: { text: string; streaming?: boolean; cached?: boolean }) {
if (options.cached) await preloadMarkdown(options.text, "markdown-test")
const host = document.createElement("div")
host.dataset.testid = "markdown-fixture"
document.body.appendChild(host)
render(() => {
const [text, setText] = createSignal(options.text)
const [streaming, setStreaming] = createSignal(options.streaming ?? false)
const [visible, setVisible] = createSignal(true)
return (
<>
<textarea aria-label="Markdown text" value={text()} onInput={(event) => setText(event.currentTarget.value)} />
<input
aria-label="Streaming"
type="checkbox"
checked={streaming()}
onChange={(event) => setStreaming(event.currentTarget.checked)}
/>
<button onClick={() => setVisible((value) => !value)}>Toggle Markdown</button>
<Show when={visible()}>
<Markdown
text={text()}
streaming={streaming()}
cacheKey={options.cached ? "markdown-test" : undefined}
deferUntilReady
/>
</Show>
</>
)
}, host)
}
@@ -0,0 +1,120 @@
import { fileURLToPath } from "node:url"
import { expect, story } from "../../storybook/playwright/story"
const fixture = `/@fs/${fileURLToPath(new URL("./markdown.fixture.tsx", import.meta.url)).replaceAll("\\", "/")}`
story.beforeEach(async ({ mount }) => {
const root = await mount("components-markdown--complete-response")
await expect(root.locator('[data-component="markdown"]')).toHaveAttribute("data-markdown-ready", "")
})
story("mounts cached completed Markdown with sanitized HTML and decorations", async ({ page }) => {
await page.evaluate(
async ({ fixture, text }) => {
const { mountMarkdown } = await import(fixture)
await mountMarkdown({ text, cached: true })
},
{
fixture,
text: [
"# Completed response",
"`src/file.ts` and `https://example.com/docs` and [link](https://example.com)",
'<img src="missing" onerror="alert(1)"><script>alert(2)</script><a href="javascript:alert(3)">unsafe</a>',
"```ts\nconst answer = 42\n```",
].join("\n\n"),
},
)
const harness = page.getByTestId("markdown-fixture")
const markdown = harness.locator('[data-component="markdown"]')
await expect(markdown).toHaveAttribute("data-markdown-ready", "")
await expect(markdown.getByRole("heading")).toHaveText("Completed response")
await expect(markdown.locator("script, [onerror], [href^='javascript:']")).toHaveCount(0)
await expect(markdown.locator('code[data-inline-code-kind="path"]')).toHaveText("src/file.ts")
await expect(markdown.getByRole("link", { name: "https://example.com/docs" })).toHaveAttribute("target", "_blank")
await expect(markdown.getByRole("link", { name: "https://example.com/docs" })).toHaveAttribute(
"rel",
"noopener noreferrer",
)
await expect(markdown.locator("pre code")).toContainText("const answer = 42")
await expect(markdown.locator("[data-markdown-word]")).toHaveCount(0)
await harness.getByLabel("Markdown text").fill("## Replacement\n\n`new/file.ts`")
await expect(markdown.getByRole("heading")).toHaveText("Replacement")
await expect(markdown).toHaveAttribute("data-markdown-ready", "")
await expect(markdown.locator("pre, h1, a")).toHaveCount(0)
await expect(markdown.locator('code[data-inline-code-kind="path"]')).toHaveText("new/file.ts")
await harness.getByRole("button", { name: "Toggle Markdown" }).click()
await expect(markdown).toHaveCount(0)
await harness.getByRole("button", { name: "Toggle Markdown" }).click()
await expect(markdown.getByRole("heading")).toHaveText("Replacement")
await expect(markdown).toHaveAttribute("data-markdown-ready", "")
await harness.getByLabel("Markdown text").fill("")
await expect(markdown).toBeEmpty()
await expect(markdown).toHaveAttribute("data-markdown-ready", "")
})
story("keeps live elements and selection when a stream completes and later changes", async ({ page }) => {
await page.evaluate(async (fixture) => {
const { mountMarkdown } = await import(fixture)
await mountMarkdown({ text: "Hello **world**", streaming: true })
}, fixture)
const harness = page.getByTestId("markdown-fixture")
const markdown = harness.locator('[data-component="markdown"]')
const paragraph = markdown.locator("p")
await expect(markdown.locator("[data-markdown-word]")).toHaveCount(2)
await paragraph.evaluate((element) => element.setAttribute("data-retained", "true"))
await harness.getByLabel("Markdown text").fill("Hello **world** again")
await expect(markdown.locator("[data-markdown-word]")).toHaveCount(3)
await expect(paragraph).toHaveAttribute("data-retained", "true")
await expect(markdown.locator("[data-markdown-enter]")).not.toHaveCount(0)
await paragraph.evaluate((element) => {
const range = document.createRange()
range.selectNodeContents(element.querySelector("strong")!)
window.getSelection()!.removeAllRanges()
window.getSelection()!.addRange(range)
// Change the control without moving browser focus or selection.
const input = document.querySelector<HTMLInputElement>('[data-testid="markdown-fixture"] input')!
input.checked = false
input.dispatchEvent(new Event("change", { bubbles: true }))
})
await expect(harness.getByLabel("Streaming")).not.toBeChecked()
await expect(markdown).toHaveAttribute("data-markdown-ready", "")
await expect(paragraph).toHaveAttribute("data-retained", "true")
expect(await page.evaluate(() => window.getSelection()?.toString())).toBe("world")
await harness.getByLabel("Markdown text").fill("Changed **content**")
await expect(paragraph).toHaveText("Changed content")
await expect(paragraph).toHaveAttribute("data-retained", "true")
await expect(markdown.locator("[data-markdown-word]")).toHaveCount(0)
await harness.getByRole("button", { name: "Toggle Markdown" }).click()
await expect(markdown).toHaveCount(0)
})
story("replaces completed DOM before live rendering and retains streamed code copy actions", async ({ page }) => {
await page.evaluate(async (fixture) => {
const { mountMarkdown } = await import(fixture)
await mountMarkdown({ text: "Initial **content**" })
}, fixture)
const harness = page.getByTestId("markdown-fixture")
const markdown = harness.locator('[data-component="markdown"]')
await expect(markdown.locator("p")).toHaveText("Initial content")
await harness.getByLabel("Streaming").check()
await harness.getByLabel("Markdown text").fill("Initial **content** continues")
await expect(markdown.locator("p")).toHaveCount(1)
await expect(markdown.locator("[data-markdown-word]")).toHaveCount(3)
await harness.getByLabel("Markdown text").fill("```sh\necho hello\n")
await expect(markdown.locator("pre code")).toHaveText("echo hello\n")
await expect(markdown.locator("p")).toHaveCount(0)
await expect(markdown.locator('[data-component="markdown-code"]')).toHaveAttribute("data-code-kind", "shell")
await page.context().grantPermissions(["clipboard-read", "clipboard-write"])
await markdown.getByRole("button", { name: "Copy" }).click()
await expect(markdown.getByRole("button", { name: "Copied" })).toBeVisible()
expect((await page.evaluate(() => navigator.clipboard.readText())).replaceAll("\r\n", "\n")).toBe("echo hello\n")
await harness.getByLabel("Streaming").uncheck()
await expect(markdown.locator("[data-markdown-complete]")).toHaveAttribute("data-markdown-complete", "true")
await expect(markdown.locator("pre code")).toHaveText("echo hello\n")
await harness.getByLabel("Markdown text").fill("Replacement prose")
await expect(markdown.locator("p")).toHaveText("Replacement prose")
await expect(markdown.locator('pre, [data-slot="markdown-copy-button"]')).toHaveCount(0)
await harness.getByRole("button", { name: "Toggle Markdown" }).click()
await expect(markdown).toHaveCount(0)
})
@@ -1,5 +1,32 @@
import { expect, story } from "../../storybook/playwright/story"
for (const tool of ["shell", "execute", "subagent"]) {
for (const open of [false, true]) {
story(`keeps ${tool} inside an existing ${open ? "open" : "closed"} group through execution`, async ({ mount }) => {
const timeline = await mount("current-session-terminal-work--terminal-commands", {
args: { existingGroup: true, tool },
})
const group = timeline.locator('[data-component="collapsed-tool-group"]')
const trigger = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
await expect(group).toHaveAttribute("data-timeline-part-ids", "tool_context_lifecycle")
if (open) await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", String(open))
await timeline.getByRole("button", { name: "Start tool", exact: true }).click()
await expect(group).toHaveAttribute("data-timeline-part-ids", "tool_context_lifecycle,tool_shell_lifecycle")
const original = await group.elementHandle()
for (const action of [undefined, "Complete input", "Run command", "Complete command"]) {
if (action) await timeline.getByRole("button", { name: action, exact: true }).click()
await expect(group).toHaveAttribute("data-timeline-part-ids", "tool_context_lifecycle,tool_shell_lifecycle")
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
await expect(timeline.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(1)
await expect(trigger).toHaveAttribute("aria-expanded", String(open))
expect(await original!.evaluate((node) => node.isConnected)).toBe(true)
if (open) await expect(group.locator('[data-timeline-part-id="tool_shell_lifecycle"]')).toBeVisible()
}
})
}
}
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 }) => {
@@ -0,0 +1,51 @@
import { expect, story } from "../../storybook/playwright/story"
story("summarizes subagents as Agent while retaining their card titles", async ({ mount }) => {
const root = await mount("current-tool-group--mixed-tools")
const group = root.locator('[data-component="collapsed-tool-group"]')
await expect(group.getByRole("button", { name: "Used Shell, Read, Agent", exact: true })).toBeVisible()
await expect(group.locator('[data-component="tag"]')).toHaveText("4")
await expect(group.locator('[data-component="task-tool-title"]')).toHaveText(["General", "Explore"])
})
for (const width of [840, 390]) {
story(`keeps grouped cards inside their trigger bounds at ${width}px`, async ({ mount, page }) => {
await page.setViewportSize({ width, height: 600 })
const root = await mount("current-tool-group--mixed-tools")
const group = root.locator('[data-component="collapsed-tool-group"]')
const cards = group.locator('[data-component="task-tool-surface"]')
await expect(cards).toHaveCount(2)
await expect
.poll(() =>
cards.evaluateAll((nodes) =>
nodes.map((node) => {
const card = node.getBoundingClientRect()
const trigger = node.closest('[data-component="tool-trigger"]')!.getBoundingClientRect()
const item = node.closest('[data-slot="context-tool-group-item"]')!.getBoundingClientRect()
return (
card.height === 36 &&
card.top >= trigger.top &&
card.bottom <= trigger.bottom &&
card.top >= item.top &&
card.bottom <= item.bottom
)
}),
),
)
.toEqual([true, true])
const shell = group.locator('[data-timeline-part-id="group_shell"]')
await expect(shell.locator('[data-slot="collapsible-trigger"]')).toHaveCSS("height", "28px")
await shell.getByRole("button").click()
await expect(shell.locator('[data-slot="bash-command"]')).toHaveText("printf 'group geometry'")
await expect(shell.locator('[data-slot="bash-result"]')).toHaveText("group geometry")
await expect
.poll(() =>
shell.evaluate((node) => {
const card = node.querySelector('[data-component="bash-output"]')!.getBoundingClientRect()
const item = node.closest('[data-slot="context-tool-group-item"]')!.getBoundingClientRect()
return card.top >= item.top && card.bottom <= item.bottom
}),
)
.toBe(true)
})
}
+22 -17
View File
@@ -617,7 +617,10 @@ function updateBlock(container: HTMLDivElement, index: number, block: RenderedBl
updateCodeBlock(container, current, block, labels)
return
}
const existing = current instanceof HTMLDivElement && current.dataset.markdownKey === block.key ? current : undefined
const existing =
current instanceof HTMLDivElement && current.dataset.markdownKey === block.key && !renderedCodeTokens.has(current)
? current
: undefined
if (existing?.dataset.markdownHash === block.hash) return
const next = existing ?? document.createElement("div")
@@ -625,28 +628,27 @@ function updateBlock(container: HTMLDivElement, index: number, block: RenderedBl
next.dataset.markdownKey = block.key
next.dataset.markdownHash = block.hash
next.style.display = "contents"
const source = document.createElement("div")
const rendered = renderedMarkdown.get(next)
// Keep live renderers in control of their DOM, including after completion.
const source = rendered || block.mode === "live" ? document.createElement("div") : next
source.innerHTML = block.html
markInlineCode(source)
markCodeLinks(source)
const html = source.innerHTML
if (existing) {
const rendered = renderedMarkdown.get(existing)
if (rendered) {
rendered.renderer.update(html, block.mode === "live", rendered.raw !== block.raw)
rendered.raw = block.raw
return
}
existing.innerHTML = ""
renderedMarkdown.set(existing, {
renderer: createMarkdownRenderer(existing, html, block.mode === "live"),
raw: block.raw,
})
if (rendered) {
rendered.renderer.update(source.innerHTML, block.mode === "live", rendered.raw !== block.raw)
rendered.raw = block.raw
return
}
if (block.mode === "live") {
next.replaceChildren()
renderedMarkdown.set(next, {
renderer: createMarkdownRenderer(next, source.innerHTML, true),
raw: block.raw,
})
}
renderedMarkdown.set(next, { renderer: createMarkdownRenderer(next, html, block.mode === "live"), raw: block.raw })
if (existing) return
if (!current) {
container.appendChild(next)
return
@@ -662,7 +664,10 @@ function updateCodeBlock(
block: Extract<RenderedBlock, { mode: "code" }>,
labels: CopyLabels,
) {
const existing = current instanceof HTMLDivElement && current.dataset.markdownKey === block.key ? current : undefined
const existing =
current instanceof HTMLDivElement && current.dataset.markdownKey === block.key && renderedCodeTokens.has(current)
? current
: undefined
const next = existing ?? document.createElement("div")
next.dataset.markdownBlock = ""
next.dataset.markdownKey = block.key
@@ -724,7 +724,9 @@
width: 100%;
}
> [data-component="tool-part-wrapper"] > [data-component="collapsible"] > [data-slot="collapsible-trigger"] {
> [data-component="tool-part-wrapper"]
> [data-component="collapsible"]
> [data-slot="collapsible-trigger"]:not([data-hide-details="true"]) {
height: 28px;
}
+10 -2
View File
@@ -508,7 +508,9 @@ function groupContent(
items.forEach((item) => {
const type =
item.content.type === "tool" ? toolGroupType(item.content, shellToolDefaultOpen, editToolDefaultOpen) : undefined
item.content.type === "tool"
? toolGroupType(item.content, shellToolDefaultOpen, editToolDefaultOpen, adjacent?.type === "context")
: undefined
if (type) {
if (adjacent?.type !== type) flush()
adjacent ??= { type, refs: [] }
@@ -526,7 +528,12 @@ function groupContent(
return groups
}
function toolGroupType(content: Extract<Content, { type: "tool" }>, shellExpanded: boolean, editExpanded: boolean) {
function toolGroupType(
content: Extract<Content, { type: "tool" }>,
shellExpanded: boolean,
editExpanded: boolean,
hasContextGroup: boolean,
) {
if (content.name === "question" || hasLoadedFiles(content)) return undefined
if (content.state.status === "error") {
if ((content.name === "shell" || content.name === "execute") && shellExpanded) return undefined
@@ -535,6 +542,7 @@ function toolGroupType(content: Extract<Content, { type: "tool" }>, shellExpande
return "context"
}
if (
!hasContextGroup &&
(content.state.status !== "completed" ||
("metadata" in content.state && content.state.metadata?.status === "running")) &&
(content.name === "shell" || content.name === "execute" || content.name === "subagent")
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test"
import type { SessionMessageAssistantTool, SessionMessageInfo } from "@opencode-ai/client/promise"
import { Timeline, TimelineRow } from "./projection"
import { storyDocument, storyTool } from "../storybook/current-session-scenarios"
import { createTimelineProjection, Timeline, TimelineRow } from "./projection"
describe("current session timeline rows", () => {
test("derives turns and tagged rows from chronological current messages", () => {
@@ -724,7 +725,72 @@ describe("current session timeline rows", () => {
expect(rows.flatMap((row) => (row._tag === "AssistantPart" ? [row.group.type] : []))).toEqual([...types])
})
test("keeps active and background work visible outside collapsed stacks", () => {
test.each(["shell", "execute", "subagent"])("keeps %s in an existing group throughout execution", (name) => {
const initial = createTimelineProjection({
sessionMessages: storyDocument([storyTool("earlier", "read", "completed", {})]).messages,
status: { type: "busy" },
showReasoningSummaries: false,
})
const phases = [
{ status: "streaming" },
{ status: "running" },
{ status: "completed", metadata: { status: "running" } },
{ status: "completed" },
{ status: "error" },
] as const
phases.reduce((previousRows, phase, index) => {
const result = createTimelineProjection({
sessionMessages: [
...storyDocument([storyTool("earlier", "read", "completed", {})]).messages,
...storyDocument([
storyTool("active", name, phase.status, {}, "metadata" in phase ? { metadata: phase.metadata } : {}),
])
.messages.filter((message) => message.type === "assistant")
.map((message) => ({ ...message, id: "next-step" })),
],
status: { type: "busy" },
showReasoningSummaries: false,
previousRows,
})
const groups = result.rows.filter((row) => row._tag === "AssistantPart")
expect(groups).toHaveLength(1)
expect(groups[0].group).toMatchObject({
type: "context",
refs: [
{ messageID: "msg_tool_projection_assistant", partID: "earlier" },
{ messageID: "next-step", partID: "active" },
],
})
expect(TimelineRow.key(groups[0])).toBe(TimelineRow.key(initial.rows[1]))
if (index > 0) expect(groups[0]).toBe(previousRows.find((row) => row._tag === "AssistantPart")!)
return result.rows
}, initial.rows)
})
test.each([
{ name: "shell", expanded: true, types: ["context", "part"] },
{ name: "execute", expanded: true, types: ["context", "part"] },
{ name: "subagent", expanded: true, types: ["context"] },
{ name: "shell", separator: "text", types: ["context", "part", "part"] },
{ name: "shell", separator: "reasoning", showReasoning: true, types: ["context", "part", "part"] },
{ name: "shell", separator: "reasoning", showReasoning: false, types: ["context"] },
] as const)("respects active tool grouping boundaries: %j", (profile) => {
const content = [
storyTool("earlier", "read", "completed", {}),
...(profile.separator ? [{ type: profile.separator, text: "Visible boundary" }] : []),
storyTool("active", profile.name, "running", {}),
]
const rows = Timeline.constructSessionMessageRows(
storyDocument(content).messages,
profile.showReasoning ?? false,
{ type: "busy" },
undefined,
profile.expanded ?? false,
).rows
expect(rows.flatMap((row) => (row._tag === "AssistantPart" ? [row.group.type] : []))).toEqual([...profile.types])
})
test("keeps active and background work standalone when no group precedes them", () => {
const source: SessionMessageInfo[] = [
{ id: "msg_user", type: "user", text: "work", time: { created: 1 } },
{
@@ -125,34 +125,63 @@ export const TestFailed = {
),
}
function InteractiveCommandStory(props: { expanded?: boolean; streaming?: boolean }) {
function InteractiveCommandStory(props: {
expanded?: boolean
streaming?: boolean
existingGroup?: boolean
tool?: "shell" | "execute" | "subagent"
}) {
const [state, setState] = createStore({
phase: props.streaming ? "streaming" : "completed",
started: !props.existingGroup,
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: "" } : {}),
}),
...(props.existingGroup
? [storyTool("tool_context_lifecycle", "read", "completed", { filePath: "/workspace/README.md" })]
: []),
...(state.started
? [
storyTool(
"tool_shell_lifecycle",
props.tool ?? "shell",
phase === "input" ? "streaming" : phase,
phase === "streaming"
? {}
: props.tool === "execute"
? { code: 'console.log("ready")' }
: props.tool === "subagent"
? { description: "Inspect lifecycle", agent: "explore", prompt: "Inspect lifecycle" }
: { command: "printf ready" },
{
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) },
...storyDocument(content, state.started && phase !== "completed"),
status: { type: (state.started && 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">
<div class="flex flex-wrap gap-3">
{props.existingGroup && (
<button type="button" onClick={() => setState({ started: true, phase: "streaming" })}>
Start tool
</button>
)}
<button type="button" onClick={() => setState("phase", "input")}>
Complete input
</button>
@@ -182,16 +211,19 @@ function InteractiveCommandStory(props: { expanded?: boolean; streaming?: boolea
)
}
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),
args: { scenario: "command", expanded: false, streaming: false, existingGroup: false, tool: "shell" },
argTypes: {
scenario: { control: "select", options: ["command", "collapsed"] },
tool: { control: "select", options: ["shell", "execute", "subagent"] },
},
render: (args: {
scenario: string
expanded: boolean
streaming: boolean
existingGroup: boolean
tool: "shell" | "execute" | "subagent"
}) => (args.scenario === "collapsed" ? CollapsedShell.render() : <InteractiveCommandStory {...args} />),
}
export const FixedAndPassed = {
@@ -0,0 +1,35 @@
import { createSignal } from "solid-js"
import { CurrentSessionProviders } from "../storybook/current-session-story"
import { storyDocument, storyTool } from "../storybook/current-session-scenarios"
import { CurrentContextToolGroup } from "./tool-renderer"
export default {
title: "OpenCode/Work/Tool group",
id: "current-tool-group",
component: CurrentContextToolGroup,
}
export const MixedTools = {
render: () => {
const [open, setOpen] = createSignal(true)
const tools = [
storyTool(
"group_shell",
"shell",
"completed",
{ command: "printf 'group geometry'" },
{ output: "group geometry" },
),
storyTool("group_read", "read", "completed", { path: "src/group.ts" }),
storyTool("group_general", "subagent", "completed", { agent: "general", description: "Inspect grouped tools" }),
storyTool("group_explore", "subagent", "completed", { agent: "explore", description: "Check card geometry" }),
]
return (
<section style={{ width: "100%", "max-width": "720px", padding: "24px" }}>
<CurrentSessionProviders document={storyDocument(tools)}>
<CurrentContextToolGroup tools={tools} busy={false} open={open()} onOpenChange={setOpen} />
</CurrentSessionProviders>
</section>
)
},
}
@@ -487,8 +487,7 @@ export function CurrentContextToolGroup(props: {
props.tools.map((tool) => {
const input = currentToolInput(tool)
if (tool.name === "skill") return i18n.t("ui.tool.skill")
if (tool.name === "subagent" && typeof input.agent === "string" && input.agent)
return input.agent[0]!.toUpperCase() + input.agent.slice(1)
if (tool.name === "subagent") return i18n.t("ui.tool.agent.default")
return getToolInfo(tool.name, input, currentToolMetadata(tool)).title
}),
),
+2 -2
View File
@@ -86,12 +86,12 @@ export const settings: Setting[] = [
keywords: ["syntax", "concealment", "rendering"],
},
{
title: "Grouping",
title: "Tool grouping",
category: "Session",
path: ["session", "grouping"],
default: "auto",
values: ["none", "auto"],
keywords: ["transcript", "messages"],
keywords: ["transcript", "messages", "reads", "searches"],
},
{
title: "Transcript images",
+9
View File
@@ -75,6 +75,15 @@ test("shows the TPS default in session settings", () => {
expect(setting?.default).toBe(true)
})
test("names tool grouping explicitly in settings", () => {
expect(settings.find((setting) => setting.path.join(".") === "session.grouping")).toMatchObject({
title: "Tool grouping",
category: "Session",
default: "auto",
values: ["none", "auto"],
})
})
test("validates terminal copy behavior", () => {
expect(decodeInfo({ terminal: { copy: "manual" } })).toEqual({ terminal: { copy: "manual" } })
expect(decodeInfo({ terminal: { copy: "select" } })).toEqual({ terminal: { copy: "select" } })
+66 -78
View File
@@ -118,15 +118,9 @@ type CheckReplacementErrors<SourceError, ReplacementError> = [Exclude<Replacemen
? unknown
: { readonly "New replacement errors": Exclude<ReplacementError, SourceError> }
type CheckReplacementOutputs<SourceOutput, ReplacementOutput> = [Exclude<SourceOutput, ReplacementOutput>] extends [
never,
]
? unknown
: { readonly "Missing replacement outputs": Exclude<SourceOutput, ReplacementOutput> }
type CheckReplacement<Item> = Item extends readonly [Node<infer A, infer E, infer T>, infer Replacement]
? Replacement extends Node<infer A2, infer E2, T>
? CheckReplacementOutputs<A, NoInfer<A2>> & CheckReplacementErrors<E, NoInfer<E2>>
? Replacement extends Node<NoInfer<A>, infer E2, T>
? CheckReplacementErrors<E, NoInfer<E2>>
: Replacement extends Layer.Layer<NoInfer<A>, infer E2, never>
? CheckReplacementErrors<E, NoInfer<E2>>
: { readonly "Invalid replacement": Replacement }
@@ -224,41 +218,28 @@ export function hoist<A, E, T extends Tag, const Items extends Replacements = re
} {
const hoisted = new Map<string, AnyNode>()
const replacementMap = replacementMapFrom(replacements)
const definitions = new Map<string, AnyNode>()
// Validate the entire effective closure, including dependencies below hoisted roots.
const effective = walk<AnyNode>(
const node = walk<AnyNode>(
root,
(node, context) => {
const dependencies = node.dependencies.map(context.visit)
const result = dependencies.every((dependency, index) => dependency === node.dependencies[index])
? node
: { ...node, dependencies }
if (node.kind !== "group" && node.tag === tag) {
const existing = definitions.get(node.name)
if (existing && !sameDefinition(existing, result)) {
if (node.kind === "group") {
return { ...node, dependencies: node.dependencies.map(context.visit) }
}
if (node.tag === tag) {
const existing = hoisted.get(node.name)
if (existing && existing.implementation !== node.implementation) {
throw new Error(`Tag ${tag} has conflicting implementations for ${node.name}`)
}
if (existing) return existing
definitions.set(node.name, result)
hoisted.set(node.name, rewriteReplacementDependencies(node, replacementMap))
return group([])
}
if (node.kind === "unbound") {
return node
}
return result
},
{ resolve: (node) => resolveReplacement(node, replacementMap) },
)
const node = walk<AnyNode>(effective, (node, context) => {
if (node.kind === "group") {
return { ...node, dependencies: node.dependencies.map(context.visit) }
}
if (node.tag === tag) {
hoisted.set(node.name, node)
return group([])
}
if (node.kind === "unbound") {
return node
}
return { ...node, dependencies: node.dependencies.map(context.visit) }
})
},
{ resolve: (node) => replacementMap.get(node.name) ?? node },
)
return {
node: node as Node<A, E>,
@@ -283,7 +264,7 @@ export function compile<A, E, const Items extends Replacements = readonly []>(
? implementation
: implementation.pipe(Layer.provide(dependencies as [RuntimeLayer, ...RuntimeLayer[]]))
},
{ cache, resolve: (node) => resolveReplacement(node, replacementMap) },
{ cache, resolve: (node) => replacementMap.get(node.name) ?? node },
)
const layers = flatten(root).map((node) => compileNode(node))
const layer = layers.reduce<RuntimeLayer>((result, layer) => layer.pipe(Layer.provideMerge(result)), Layer.empty)
@@ -291,51 +272,58 @@ export function compile<A, E, const Items extends Replacements = readonly []>(
}
function replacementMapFrom(replacements?: Replacements) {
// Resolve dependencies only after the last override wins, not in intermediate graphs.
return new Map(replacements?.map(([source, replacement]) => [source.name, replacementNode(source, replacement)]))
}
function resolveReplacement(node: AnyNode, replacements: ReturnType<typeof replacementMapFrom>) {
const replacement = replacements.get(node.name)
if (!replacement) return node
if (node.tag !== replacement.tag) {
throw new Error(`Cannot replace ${node.name} across tags`)
}
return replacement
}
function sameDefinition(left: AnyNode, right: AnyNode): boolean {
if (left === right) return true
if (
left.kind !== right.kind ||
left.name !== right.name ||
left.tag !== right.tag ||
left.implementation !== right.implementation
)
return false
const leftDependencies = left.dependencies.flatMap(flatten)
const rightDependencies = right.dependencies.flatMap(flatten)
return (
leftDependencies.length === rightDependencies.length &&
leftDependencies.every((dependency, index) => sameDefinition(dependency, rightDependencies[index]))
replacements?.reduce((map, [source, replacement]) => {
const normalized = rewriteReplacementDependencies(replacementNode(source, replacement), map)
const current = new Map([[source.name, normalized]])
for (const [name, node] of map) map.set(name, rewriteReplacementDependencies(node, current))
map.set(source.name, normalized)
return map
}, new Map<string, AnyNode>()) ?? new Map<string, AnyNode>()
)
}
export function hasUnbound<const Items extends Replacements = readonly []>(
root: Node<unknown, unknown, any>,
source: AnyNode,
replacements?: ValidReplacements<Items>,
): boolean {
function rewriteReplacementDependencies(root: AnyNode, replacements: ReadonlyMap<string, AnyNode>) {
if (replacements.size === 0) return root
const cache = new Map<AnyNode, AnyNode>()
const visiting = new Set<AnyNode>()
const stack: AnyNode[] = []
const recur = (node: AnyNode, isRoot = false): AnyNode => {
const target = isRoot ? node : (replacements.get(node.name) ?? node)
const cached = cache.get(target)
if (cached !== undefined || cache.has(target)) return cached!
if (visiting.has(target)) {
const start = stack.indexOf(target)
throw new Error(
`Cycle detected in layer tree: ${[...stack.slice(start), target].map((item) => item.name).join(" -> ")}`,
)
}
visiting.add(target)
stack.push(target)
try {
const dependencies = target.dependencies.map((dependency) => recur(dependency))
const result = dependencies.every((dependency, index) => dependency === target.dependencies[index])
? target
: { ...target, dependencies }
cache.set(target, result)
return result
} finally {
stack.pop()
visiting.delete(target)
}
}
return recur(root, true)
}
export function hasUnbound(root: Node<unknown, unknown, any>, source: AnyNode): boolean {
if (source.kind !== "unbound") throw new Error(`Cannot check non-unbound layer node: ${source.name}`)
const replacementMap = replacementMapFrom(replacements)
return walk<boolean>(
root,
(node, context) => {
if (node === source) return true
return node.dependencies.some(context.visit)
},
{ resolve: (node) => resolveReplacement(node, replacementMap) },
)
return walk<boolean>(root, (node, context) => {
if (node === source) return true
return node.dependencies.some(context.visit)
})
}
function flatten(node: AnyNode): readonly AnyNode[] {
@@ -829,6 +829,10 @@ interface StorageDomain {
Register typed tools with Effect `Schema`. The executor receives decoded input and returns an Effect containing typed
output, display content, or metadata.
The draft supports `add`, `update`, and `remove`. The transform callback is synchronous: it must not return an Effect or Promise. Load
external data before registering or reloading. OpenCode replays active transforms in registration order on a fresh
draft; for the same effective tool name, a later valid registration overrides an earlier one.
```ts
effect: (ctx) =>
Effect.gen(function* () {
@@ -850,18 +854,51 @@ effect: (ctx) =>
}),
```
Call `yield* ctx.tool.reload()` after changing source data captured by the callback. Reload replays active transforms
without changing their order; it does not rerun the plugin effect.
Tools have an `id` containing their effective name, and `get()` returns `undefined` when that ID is not present. Use
`update` and `remove` with the effective tool name, including its namespace (`acme_greeting` above). Dots in
namespaces and unsupported characters in tool names become `_`. Missing names are ignored; creating a tool requires
`add` with a complete definition. Updates preserve the name and namespace. Assign new schemas or options to replace
them rather than mutating nested values. Invalid updates are logged and leave the previous definition intact.
```ts
yield* ctx.tool.transform((draft) => {
draft.update("acme_greeting", (tool) => {
tool.description = "Greet the user by name"
})
draft.remove("acme_obsolete")
})
```
Updates and removals replay in order with additions, including after MCP catalog refreshes.
`transform` returns a scoped registration. Run `yield* registration.dispose` to remove its transform and rebuild
from the remaining transforms, revealing any earlier definition it overrode. Disposal is idempotent, and closing
the plugin scope also disposes its registrations.
Each model request captures a tool snapshot. Reload and disposal affect future snapshots, not the definitions or
executors already captured by an existing request. Executors that close over mutable plugin data still observe
that data; capture a value inside the transform when it must remain tied to that definition.
Schemas: [`Tool.Content`](/api#schema-Tool.Content), [`Tool.TextContent`](/api#schema-Tool.TextContent),
[`Tool.FileContent`](/api#schema-Tool.FileContent).
```ts
interface ToolDraft {
list(): readonly (Tool.Info & { readonly id: string })[]
get(id: string): (Tool.Info & { readonly id: string }) | undefined
add<Input extends Tool.ValueSchema<any>, Output extends Tool.ValueSchema<any> | undefined>(
tool: Tool.Info<Input, Output>,
): void
update(id: string, update: (tool: Types.Mutable<Tool.Info>) => void): void
remove(id: string): void
}
interface ToolDomain {
readonly transform: Transform<ToolDraft>
readonly reload: () => Effect.Effect<void>
}
```
@@ -784,10 +784,12 @@ interface StorageScanResult {
### Tools
Register tools with a transform.
Register, update, and remove tools with a transform. The callback is synchronous, including in Promise plugins; load external
data before registering or reloading. OpenCode replays active transforms in registration order on a fresh draft.
For the same effective tool name, a later valid registration overrides an earlier one.
```ts
await ctx.tool.transform((draft) => {
const registration = await ctx.tool.transform((draft) => {
draft.add({
name: "greeting",
description: "Create a greeting",
@@ -806,6 +808,43 @@ await ctx.tool.transform((draft) => {
})
```
Call `reload()` after changing source data captured by the callback. Reload replays the active transforms without
changing their order; it does not rerun plugin setup.
```ts
await ctx.tool.reload()
```
Use `list()` and `get()` to inspect tools currently in the draft. Tools have an `id` containing their effective name,
and `get()` returns `undefined` when that ID is not present. Use `update` and `remove` with the effective tool name,
including its namespace (`acme_greeting` above). Dots in
namespaces and unsupported characters in tool names become `_`. Missing names are ignored; creating a tool requires
`add` with a complete definition. Updates preserve the name and namespace. Assign new schemas or options to replace
them rather than mutating nested values. Invalid updates are logged and leave the previous definition intact.
```ts
await ctx.tool.transform((draft) => {
draft.update("acme_greeting", (tool) => {
tool.description = "Greet the user by name"
})
draft.remove("acme_obsolete")
})
```
Updates and removals replay in order with additions, including after MCP catalog refreshes. Disposing their
registration removes those changes and rebuilds from the remaining transforms.
Dispose a registration to remove its transform and rebuild from the remaining transforms, revealing any earlier
definition it overrode. Disposal is idempotent, and unloading the plugin also disposes its registrations.
```ts
await registration.dispose()
```
Each model request captures a tool snapshot. Reload and disposal affect future snapshots, not the definitions or
executors already captured by an existing request. Executors that close over mutable plugin data still observe
that data; capture a value inside the transform when it must remain tied to that definition.
#### Reference
Schemas: [`Tool.Content`](/api#schema-Tool.Content), [`Tool.TextContent`](/api#schema-Tool.TextContent),
@@ -814,10 +853,15 @@ Schemas: [`Tool.Content`](/api#schema-Tool.Content), [`Tool.TextContent`](/api#s
```ts
interface ToolContext {
transform(callback: (draft: ToolDraft) => void): Promise<Registration>
reload(): Promise<void>
}
interface ToolDraft {
list(): readonly (ToolInfo & { readonly id: string })[]
get(id: string): (ToolInfo & { readonly id: string }) | undefined
add(tool: ToolInfo): void
update(id: string, update: (tool: Types.Mutable<ToolInfo>) => void): void
remove(id: string): void
}
```