Compare commits

..

26 Commits

Author SHA1 Message Date
Kit Langton 6cb2f00d15 refactor(core): simplify interrupt continuation 2026-08-15 14:57:49 -04:00
opencode-agent[bot] 73700065bf chore: generate 2026-08-15 18:35:53 +00:00
Kit Langton 07a82a442e fix(core): keep queued work parked after interrupt (#42682) 2026-08-15 18:34:43 +00:00
Kit Langton 55be895e14 fix(tui): scope session picker by directory (#42804) 2026-08-15 18:30:35 +00:00
Kit Langton 58a36a1560 fix(tui): preserve path suffix in prompt footer (#42800) 2026-08-15 18:21:07 +00:00
Dax 3d782efee4 docs(core): fix opencode skill source link (#42792) 2026-08-15 16:37:34 +00:00
opencode-agent[bot] c054bb183e chore: generate 2026-08-15 16:30:19 +00:00
Dax 0d7904c91f refactor(core): model provider activation explicitly (#42791)
Co-authored-by: Michael Hart <mhart@cloudflare.com>
2026-08-15 16:29:09 +00:00
opencode-agent[bot] 75ec0b454c chore: generate 2026-08-15 12:13:32 +00:00
Luke Parker f4baba2824 fix(app): use fixed message page size (#42743) 2026-08-15 22:12:20 +10:00
Luke Parker 79fc74afbf fix(app): show skill names (#42749) 2026-08-15 20:20:53 +10:00
Luke Parker 30dfe5352c fix(app): show read tool filenames (#42740) 2026-08-15 20:05:13 +10:00
Luke Parker f725443e30 fix(core): normalize Windows location keys (#42745) 2026-08-15 19:56:43 +10:00
opencode-agent[bot] cae205a3d9 fix(core): bound fuzzy search memory (#42741)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-15 19:40:24 +10:00
Luke Parker d35c6f04ed fix(ui): add keybind label padding (#42726) 2026-08-15 17:54:31 +10:00
opencode-agent[bot] 4d021b4660 fix(app): fade tab titles beneath close button (#42730)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-15 17:10:56 +10:00
Luke Parker f63f912178 test(app): typecheck complete e2e suite (#42728) 2026-08-15 17:06:58 +10:00
opencode-agent[bot] 6564d1442a chore: generate 2026-08-15 06:56:31 +00:00
Luke Parker a5f3e9e735 fix(app): preserve assistant content order (#42721) 2026-08-15 16:55:22 +10:00
Luke Parker d24a24a2ca fix(app): route global events without directory sentinel (#42719) 2026-08-15 16:47:37 +10:00
Luke Parker 8610d90838 fix(app): project sent messages through inbox events (#42714) 2026-08-15 15:57:46 +10:00
Luke Parker 57b050e9fc fix(desktop): render v2 patch metadata (#42716) 2026-08-15 15:57:34 +10:00
Luke Parker 51091be7e4 fix(desktop): verify updates before install (#42715) 2026-08-15 14:39:01 +10:00
Luke Parker c8584ec0c8 fix(app): derive popular providers from integrations (#42713) 2026-08-15 14:29:19 +10:00
Luke Parker 7301c5e798 fix(desktop): keep staged updates fresh with silent re-checks (#42707) 2026-08-15 14:22:24 +10:00
Luke Parker 41f70bfbb1 fix(app): generate blob ids without crypto.subtle in non-secure contexts (#42706)
Co-authored-by: OpenCode Vite Investigation <opencode-vite-investigation@local>
2026-08-15 14:12:57 +10:00
105 changed files with 2746 additions and 2631 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Simplify interrupt continuation: the steer-scoped resume decision now lives in SessionExecution as a post-cleanup inbox check, and the run coordinator drops its continuation state machine. Wakes arriving during cancellation cleanup now restart a normal full drain, and interrupting an idle session with continue now resumes pending steering input.
+1 -1
View File
@@ -86,7 +86,7 @@ async function writeProtocolStream(session: CDPSession, handle: string, file: st
try { try {
while (true) { while (true) {
const chunk = await session.send("IO.read", { handle }) const chunk = await session.send("IO.read", { handle })
await output.write(chunk.base64Encoded ? Buffer.from(chunk.data, "base64") : chunk.data) await (chunk.base64Encoded ? output.write(Buffer.from(chunk.data, "base64")) : output.write(chunk.data))
if (chunk.eof) break if (chunk.eof) break
} }
} finally { } finally {
@@ -125,17 +125,20 @@ export async function installTimelineStreamProbe(
const scrollTo = Element.prototype.scrollTo const scrollTo = Element.prototype.scrollTo
const scrollTop = Object.getOwnPropertyDescriptor(Element.prototype, "scrollTop")! const scrollTop = Object.getOwnPropertyDescriptor(Element.prototype, "scrollTop")!
if (profileVisual) { if (profileVisual) {
Element.prototype.scrollTo = function (...args) { function measuredScrollTo(this: Element, options?: ScrollToOptions): void
function measuredScrollTo(this: Element, x: number, y: number): void
function measuredScrollTo(this: Element, first?: number | ScrollToOptions, second?: number) {
state.scroll.calls += 1 state.scroll.calls += 1
const top = typeof args[0] === "object" ? args[0]?.top : args[1] const top = typeof first === "object" ? first?.top : second
if (typeof top === "number") { if (typeof top === "number") {
const target = Math.min(top, this.scrollHeight - this.clientHeight) const target = Math.min(top, this.scrollHeight - this.clientHeight)
if (Math.abs(this.scrollTop - target) < 1) state.scroll.callNoops += 1 if (Math.abs(this.scrollTop - target) < 1) state.scroll.callNoops += 1
} }
if (state.scroll.lastCallFrame === state.scroll.frame) state.scroll.sameFrameCalls += 1 if (state.scroll.lastCallFrame === state.scroll.frame) state.scroll.sameFrameCalls += 1
state.scroll.lastCallFrame = state.scroll.frame state.scroll.lastCallFrame = state.scroll.frame
return scrollTo.apply(this, args) Reflect.apply(scrollTo, this, typeof first === "number" ? [first, second] : [first])
} }
Element.prototype.scrollTo = measuredScrollTo
Object.defineProperty(Element.prototype, "scrollTop", { Object.defineProperty(Element.prototype, "scrollTop", {
configurable: true, configurable: true,
get: scrollTop.get, get: scrollTop.get,
@@ -267,18 +267,19 @@ const childMessages = Array.from({ length: 4 }, (_, index) => [
userMessage(childID, index + 2000, 120), userMessage(childID, index + 2000, 120),
assistantMessage(childID, index + 2000, id("msg_user", index + 2000), [textPart(index + 2000, 0, 240)]), assistantMessage(childID, index + 2000, id("msg_user", index + 2000), [textPart(index + 2000, 0, 240)]),
]).flat() ]).flat()
const messages: Record<string, Message[]> = {
[sourceID]: sourceMessages,
[targetID]: targetMessages,
[childID]: childMessages,
}
function renderable(part: MessagePart) { function renderable(part: MessagePart) {
if (part.type === "tool" && part.tool === "todowrite") return false if (part.type === "tool" && part.tool === "todowrite") return false
if (part.type === "text") return !!part.text.trim() if (part.type === "text") return !!part.text?.trim()
if (part.type === "reasoning") return !!part.text.trim() if (part.type === "reasoning") return !!part.text?.trim()
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch" return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
} }
function orderedParts(message: Message) {
return message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
}
export const fixture = { export const fixture = {
directory, directory,
project: { project: {
@@ -333,7 +334,7 @@ export const fixture = {
sourceID, sourceID,
targetID, targetID,
childID, childID,
messages: { [sourceID]: sourceMessages, [targetID]: targetMessages, [childID]: childMessages }, messages,
expected: { expected: {
sourceTitle: "Uncommitted changes inquiry", sourceTitle: "Uncommitted changes inquiry",
targetTitle: "Example Game: sample jump movement & sample physics analysis", targetTitle: "Example Game: sample jump movement & sample physics analysis",
@@ -345,16 +346,12 @@ export const fixture = {
.filter((message) => message.info.role === "user") .filter((message) => message.info.role === "user")
.map((message) => message.info.id), .map((message) => message.info.id),
childMessageIDs: childMessages.filter((message) => message.info.role === "user").map((message) => message.info.id), childMessageIDs: childMessages.filter((message) => message.info.role === "user").map((message) => message.info.id),
targetPartIDs: targetMessages.flatMap((message) => targetPartIDs: targetMessages.flatMap((message) => message.parts.filter(renderable).map((part) => part.id)),
orderedParts(message)
.filter(renderable)
.map((part) => part.id),
),
}, },
} }
export function pageMessages(sessionID: string, limit: number, before?: string) { export function pageMessages(sessionID: string, limit: number, before?: string) {
const messages = fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] const messages = fixture.messages[sessionID] ?? []
const end = before const end = before
? Math.max( ? Math.max(
0, 0,
@@ -364,6 +361,6 @@ export function pageMessages(sessionID: string, limit: number, before?: string)
const start = Math.max(0, end - limit) const start = Math.max(0, end - limit)
return { return {
items: messages.slice(start, end), items: messages.slice(start, end),
cursor: start > 0 ? messages[start]!.info.id : undefined, cursor: start > 0 ? messages[start].info.id : undefined,
} }
} }
@@ -220,7 +220,8 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
} }
if (url.pathname === "/api/project/current") if (url.pathname === "/api/project/current")
return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory }) return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory })
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} }) if (url.pathname === "/api/session")
return json(route, { data: sessions.map((session) => currentSession(session)), cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} }) if (url.pathname === "/api/session/active") return json(route, { data: {} })
const currentSessionInfo = sessions.find((session) => url.pathname === `/api/session/${session.id}`) const currentSessionInfo = sessions.find((session) => url.pathname === `/api/session/${session.id}`)
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) }) if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
@@ -16,9 +16,8 @@ import { mockOpenCodeServer } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport" import { installSseTransport } from "../utils/sse-transport"
import { expectSessionTitle } from "../utils/waits" import { expectSessionTitle } from "../utils/waits"
const initialPageSize = 20 const messagePageSize = 200
const historyPageSize = 200 const messages = Array.from({ length: messagePageSize / 2 + 1 }, (_, index) => {
const messages = Array.from({ length: initialPageSize + 1 }, (_, index) => {
const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user` const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user`
return [ return [
userMessage(undefined, { id, created: 1700000000000 + index * 2_000 }), userMessage(undefined, { id, created: 1700000000000 + index * 2_000 }),
@@ -26,7 +25,7 @@ const messages = Array.from({ length: initialPageSize + 1 }, (_, index) => {
id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`, id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
parentID: id, parentID: id,
created: 1700000001000 + index * 2_000, created: 1700000001000 + index * 2_000,
completed: index < initialPageSize, completed: index < messagePageSize / 2,
}), }),
] ]
}).flat() }).flat()
@@ -160,21 +159,18 @@ for (const scenario of scenarios) {
await expect(page.locator(`[data-timeline-part-id="${userPartID}"]`)).toBeVisible() await expect(page.locator(`[data-timeline-part-id="${userPartID}"]`)).toBeVisible()
const viewport = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) const viewport = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
await viewport.hover() await viewport.hover()
const deadline = Date.now() + 10_000 const deadline = Date.now() + 30_000
while (requests.filter((request) => request.phase === "start").length < 2) { while (requests.filter((request) => request.phase === "start").length < 2) {
if (Date.now() >= deadline) throw new Error("Timed out scrolling to the history boundary") if (Date.now() >= deadline) throw new Error("Timed out scrolling to the history boundary")
await page.mouse.wheel(0, -240) await page.mouse.wheel(0, -1_200)
await page.waitForTimeout(20) await page.waitForTimeout(20)
} }
expect(requests.filter((request) => request.phase === "end")).toHaveLength(1) expect(requests.filter((request) => request.phase === "end")).toHaveLength(1)
expect(sequence.slice(0, 3)).toEqual([ expect(sequence.slice(0, 3)).toEqual([
"messages:start:latest", "messages:start:latest",
"messages:end:latest", "messages:end:latest",
`messages:start:${messages.at(-initialPageSize)!.info.id}`, `messages:start:${messages.at(-messagePageSize)!.info.id}`,
]) ])
await expect(page.locator('[data-timeline-part-id*="_history_root_assistant:text:0"]')).toHaveCount(
initialPageSize / 2,
)
await page.evaluate(() => { await page.evaluate(() => {
;( ;(
window as Window & { window as Window & {
@@ -186,15 +182,12 @@ for (const scenario of scenarios) {
expect(await visibleContentHidden(page)).toBe(false) expect(await visibleContentHidden(page)).toBe(false)
const beforeHistory = await probeSamples(page) const beforeHistory = await probeSamples(page)
history.resolve() history.resolve()
await expect
.poll(() => page.locator('[data-timeline-part-id*="_history_root_assistant:text:0"]').count())
.toBeGreaterThan(initialPageSize / 2)
await expect.poll(() => requests.filter((request) => request.phase === "end").length).toBe(2) await expect.poll(() => requests.filter((request) => request.phase === "end").length).toBe(2)
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible() await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
await waitForProbeSamples(page, beforeHistory) await waitForProbeSamples(page, beforeHistory)
expect(pages).toEqual([ expect(pages).toEqual([
{ before: undefined, limit: initialPageSize }, { before: undefined, limit: messagePageSize },
{ before: messages.at(-initialPageSize)!.info.id, limit: historyPageSize }, { before: messages.at(-messagePageSize)!.info.id, limit: messagePageSize },
]) ])
expect(roots).toEqual([]) expect(roots).toEqual([])
@@ -82,6 +82,7 @@ test("moves busy through retry and recovery to final idle content", async ({ pag
file: "src/retry.ts", file: "src/retry.ts",
additions: 1, additions: 1,
deletions: 1, deletions: 1,
status: "modified",
patch: "@@ -1 +1 @@\n-export const retry = false\n+export const retry = true", patch: "@@ -1 +1 @@\n-export const retry = false\n+export const retry = true",
}, },
], ],
@@ -1,28 +1,27 @@
import { expect, test } from "@playwright/test" import { expect, test } from "@playwright/test"
import type { SessionMessageInfo } from "@opencode-ai/client/promise" import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
import { session, sessionID, setupTimeline } from "../performance/timeline-stability/fixture" import { session, sessionID, setupTimeline } from "../performance/timeline-stability/fixture"
const user = { id: "msg_user", type: "user", text: "Run it", time: { created: 1 } } satisfies SessionMessageInfo const user = { id: "msg_user", type: "user", text: "Run it", time: { created: 1 } } satisfies SessionMessageInfo
const assistant = (completed: boolean, tool = false, childID?: string) => const assistant = (completed: boolean, tool = false, childID?: string): SessionMessageAssistant => ({
({ id: "msg_assistant",
id: "msg_assistant", type: "assistant",
type: "assistant", agent: "build",
agent: "build", model: { id: "model", providerID: "provider" },
model: { id: "model", providerID: "provider" }, content: tool
content: tool ? [
? [ {
{ type: "tool",
type: "tool", id: "call_subagent",
id: "call_subagent", name: "subagent",
name: "subagent", state: { status: "running", input: {}, metadata: childID ? { sessionID: childID } : {} },
state: { status: "running", input: {}, metadata: childID ? { sessionID: childID } : {} }, time: { created: 2 },
time: { created: 2 }, },
}, ]
] : [{ type: "text", text: "Working" }],
: [{ type: "text", text: "Working" }], time: { created: 2, ...(completed ? { completed: 3 } : {}) },
time: { created: 2, ...(completed ? { completed: 3 } : {}) }, })
}) satisfies SessionMessageInfo
test("renders current protocol notices in CLI order", async ({ page }) => { test("renders current protocol notices in CLI order", async ({ page }) => {
const ownerWarnings: string[] = [] const ownerWarnings: string[] = []
@@ -280,6 +280,7 @@ function summaryDiff(index: number) {
file: `src/diff-${index}.ts`, file: `src/diff-${index}.ts`,
additions: 1, additions: 1,
deletions: 1, deletions: 1,
status: "modified" as const,
patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`, patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`,
} }
} }
@@ -23,11 +23,10 @@ test("groups singleton and separated context operations at correct boundaries",
] ]
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] }) await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
await expect( await expect(page.locator('[data-timeline-part-ids="prt_boundary_01_read"]')).toBeVisible()
page.locator('[data-timeline-part-ids="prt_boundary_01_read,prt_boundary_03_glob,prt_boundary_04_grep"]'), await expect(page.locator('[data-timeline-part-ids="prt_boundary_03_glob,prt_boundary_04_grep"]')).toBeVisible()
).toBeVisible()
await expect(page.locator('[data-timeline-part-ids="prt_boundary_06_list"]')).toBeVisible() await expect(page.locator('[data-timeline-part-ids="prt_boundary_06_list"]')).toBeVisible()
await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(4) await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(5)
}) })
test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => { test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => {
@@ -131,6 +131,7 @@ test("allows paint rounding for every framed row but not fixed turn gaps", async
file: "src/summary.ts", file: "src/summary.ts",
additions: 1, additions: 1,
deletions: 1, deletions: 1,
status: "modified",
patch: "@@ -1 +1 @@\n-export const value = 1\n+export const value = 2", patch: "@@ -1 +1 @@\n-export const value = 1\n+export const value = 2",
}, },
], ],
@@ -83,6 +83,40 @@ test("labels all web search provider variants", async ({ page }) => {
await expect(page.getByRole("button", { name: /^Web Search/ })).toBeVisible() await expect(page.getByRole("button", { name: /^Web Search/ })).toBeVisible()
}) })
test("labels V2 read tools from their path input", async ({ page }) => {
const id = "prt_read_path"
await setupTimeline(page, {
messages: [userMessage(), assistantMessage([toolPart(id, "read", "completed", { path: "src/a.ts" })])],
})
const group = page.locator(`[data-timeline-part-ids="${id}"]`)
await group.locator('[data-slot="collapsible-trigger"]').click()
await expect(group.locator('[data-slot="basic-tool-tool-subtitle"]')).toHaveText("a.ts")
})
test("labels V2 skill tools from IDs and result metadata", async ({ page }) => {
const pending = "prt_skill_id"
const completed = "prt_skill_name"
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(pending, "skill", "running", { id: "sample-skill" }),
toolPart(completed, "skill", "completed", { id: "opencode" }, { metadata: { name: "OpenCode" } }),
]),
],
})
await expect(page.locator(`[data-timeline-part-id="${pending}"] [data-component="text-shimmer"]`)).toHaveAttribute(
"aria-label",
"sample-skill",
)
await expect(page.locator(`[data-timeline-part-id="${completed}"] [data-component="text-shimmer"]`)).toHaveAttribute(
"aria-label",
"OpenCode",
)
})
function questionInput() { function questionInput() {
return { questions: [{ header: "Stability", question: "Keep it stable?", options: [] }] } return { questions: [{ header: "Stability", question: "Keep it stable?", options: [] }] }
} }
@@ -21,7 +21,7 @@ type EventPayload = {
payload: Record<string, unknown> payload: Record<string, unknown>
} }
test.use({ viewport: { width: 1440, height: 900 }, reducedMotion: "no-preference" }) test.use({ viewport: { width: 1440, height: 900 } })
test("animates todo opening without replaying it across session tabs", async ({ page }) => { test("animates todo opening without replaying it across session tabs", async ({ page }) => {
test.setTimeout(90_000) test.setTimeout(90_000)
@@ -57,7 +57,6 @@ test("animates todo opening without replaying it across session tabs", async ({
default: { providerID: "opencode", modelID: "claude-opus-4-6" }, default: { providerID: "opencode", modelID: "claude-opus-4-6" },
}, },
sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)], sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)],
sessionStatus: { [sourceID]: { type: "busy" } },
pageMessages: () => ({ items: [] }), pageMessages: () => ({ items: [] }),
events: () => events.splice(0, 1), events: () => events.splice(0, 1),
eventRetry: 16, eventRetry: 16,
@@ -90,7 +90,8 @@ async function mockServer(page: Page) {
if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname)) if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname))
return new Promise(() => {}) return new Promise(() => {})
if (url.pathname === "/api/event") return sse(route) if (url.pathname === "/api/event") return sse(route)
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} }) if (url.pathname === "/api/session")
return json(route, { data: sessions.map((session) => currentSession(session)), cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} }) if (url.pathname === "/api/session/active") return json(route, { data: {} })
const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`) const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`)
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) }) if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
@@ -222,30 +222,29 @@ function turn(index: number): Message[] {
return [user, assistantMessage(targetID, index, user.info.id, parts)] return [user, assistantMessage(targetID, index, user.info.id, parts)]
} }
const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat() const targetMessages = Array.from({ length: 101 }, (_, index) => turn(index)).flat()
const sourceMessages = Array.from({ length: 12 }, (_, index) => [ const sourceMessages = Array.from({ length: 12 }, (_, index) => [
userMessage(sourceID, index + 1000, 120), userMessage(sourceID, index + 1000, 120),
assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [textPart(index + 1000, 0, 240)]), assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [textPart(index + 1000, 0, 240)]),
]).flat() ]).flat()
const messages: Record<string, Message[]> = { [sourceID]: sourceMessages, [targetID]: targetMessages }
function renderable(part: MessagePart) { function renderable(part: MessagePart) {
if (part.type === "tool" && part.tool === "todowrite") return false if (part.type === "tool" && part.tool === "todowrite") return false
if (part.type === "text") return !!part.text.trim() if (part.type === "text") return !!part.text?.trim()
if (part.type === "reasoning") return !!part.text.trim() if (part.type === "reasoning") return !!part.text?.trim()
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch" return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
} }
function currentPartIDs(message: Message) { function currentPartIDs(message: Message) {
const ordinals = { text: 0, reasoning: 0 } const ordinals = { text: 0, reasoning: 0 }
return message.parts return message.parts.flatMap((part) => {
.flatMap((part) => { if (!renderable(part)) return []
if (!renderable(part)) return [] if (part.type === "text") return [`${message.info.id}:text:${ordinals.text++}`]
if (part.type === "text") return [`${message.info.id}:text:${ordinals.text++}`] if (part.type === "reasoning") return [`${message.info.id}:reasoning:${ordinals.reasoning++}`]
if (part.type === "reasoning") return [`${message.info.id}:reasoning:${ordinals.reasoning++}`] if (part.type === "tool") return [typeof part.callID === "string" ? part.callID : part.id]
if (part.type === "tool") return [typeof part.callID === "string" ? part.callID : part.id] return []
return [] })
})
.sort()
} }
export const fixture = { export const fixture = {
@@ -292,7 +291,7 @@ export const fixture = {
], ],
sourceID, sourceID,
targetID, targetID,
messages: { [sourceID]: sourceMessages, [targetID]: targetMessages }, messages,
expected: { expected: {
sourceTitle: "Uncommitted changes inquiry", sourceTitle: "Uncommitted changes inquiry",
targetTitle: "Example Game: sample jump movement & sample physics analysis", targetTitle: "Example Game: sample jump movement & sample physics analysis",
@@ -306,7 +305,7 @@ export const fixture = {
} }
export function pageMessages(sessionID: string, limit: number, before?: string) { export function pageMessages(sessionID: string, limit: number, before?: string) {
const messages = fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] const messages = fixture.messages[sessionID] ?? []
const end = before const end = before
? Math.max( ? Math.max(
0, 0,
@@ -316,6 +315,6 @@ export function pageMessages(sessionID: string, limit: number, before?: string)
const start = Math.max(0, end - limit) const start = Math.max(0, end - limit)
return { return {
items: messages.slice(start, end), items: messages.slice(start, end),
cursor: start > 0 ? messages[start]!.info.id : undefined, cursor: start > 0 ? messages[start].info.id : undefined,
} }
} }
@@ -124,7 +124,7 @@ test.describe("smoke: session timeline", () => {
provider: fixture.provider, provider: fixture.provider,
directory: fixture.directory, directory: fixture.directory,
project: fixture.project, project: fixture.project,
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }), pageMessages: (sessionID) => ({ items: fixture.messages[sessionID] ?? [] }),
}) })
await configureSmokePage(page, fixture.directory) await configureSmokePage(page, fixture.directory)
await page.addInitScript( await page.addInitScript(
@@ -188,7 +188,11 @@ test.describe("smoke: session timeline", () => {
const bottom = root const bottom = root
.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]') .querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
?.getBoundingClientRect() ?.getBoundingClientRect()
samples.push({ ids: visible, last: visible.includes(last), bottomError: bottom?.bottom - view.bottom }) samples.push({
ids: visible,
last: visible.includes(last),
bottomError: bottom ? bottom.bottom - view.bottom : undefined,
})
if ( if (
!firstPaint && !firstPaint &&
visible.includes(last) && visible.includes(last) &&
@@ -263,7 +267,7 @@ test.describe("smoke: session timeline", () => {
provider: fixture.provider, provider: fixture.provider,
directory: fixture.directory, directory: fixture.directory,
project: fixture.project, project: fixture.project,
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }), pageMessages: (sessionID) => ({ items: fixture.messages[sessionID] ?? [] }),
}) })
await configureSmokePage(page, fixture.directory) await configureSmokePage(page, fixture.directory)
await page.addInitScript( await page.addInitScript(
@@ -723,7 +727,7 @@ function expectCompleteScroll(
).toEqual([]) ).toEqual([])
expect(new Set(expectedPartIDs).size).toBe(expectedPartIDs.length) expect(new Set(expectedPartIDs).size).toBe(expectedPartIDs.length)
expect(new Set(expectedMessageIDs).size).toBe(expectedMessageIDs.length) expect(new Set(expectedMessageIDs).size).toBe(expectedMessageIDs.length)
expect(expectedPartIDs.length).toBe(331) expect(expectedPartIDs.length).toBe(465)
} }
async function selectHomeProject(page: Page, projectName: string) { async function selectHomeProject(page: Page, projectName: string) {
+3 -13
View File
@@ -1,21 +1,11 @@
{ {
"extends": "../tsconfig.json", "extends": "../tsconfig.json",
"compilerOptions": { "compilerOptions": {
"composite": false,
"emitDeclarationOnly": false,
"noEmit": true, "noEmit": true,
"rootDir": "..", "rootDir": "..",
"types": ["node", "bun"] "types": ["node", "bun"]
}, },
"include": [ "include": ["./**/*.ts", "./**/*.tsx", "../src/types.ts"]
"./performance/timeline-stability/**/*.spec.ts",
"./performance/timeline-stability/fixture.test.ts",
"./performance/timeline-stability/fixture.ts",
"./performance/unit/visual-stability.test.ts",
"./reproduction/timeline-suspense/**/*.ts",
"./reproduction/timeline-suspense/**/*.tsx",
"../src/types.ts",
"../src/pages/session/timeline/observe-element-offset.ts",
"./regression/new-session-panel-corner.spec.ts",
"./regression/session-timeline-context-resize.spec.ts",
"./utils/**/*.ts"
]
} }
@@ -0,0 +1,321 @@
import { describe, expect, test } from "bun:test"
import type { Prompt } from "@/context/prompt"
import { buildPromptRequest } from "./build-prompt-request"
describe("buildPromptRequest", () => {
test("builds text, files, and agents from the prompt", () => {
const prompt: Prompt = [
{ type: "text", content: "hello", start: 0, end: 5 },
{
type: "file",
path: "src/foo.ts",
content: "@src/foo.ts",
start: 5,
end: 16,
selection: { startLine: 4, startChar: 1, endLine: 6, endChar: 1 },
},
{ type: "agent", name: "planner", content: "@planner", start: 16, end: 24 },
]
const result = buildPromptRequest({
prompt,
context: [{ key: "ctx:1", type: "file", path: "src/bar.ts", comment: "check this" }],
images: [
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
],
text: "hello @src/foo.ts @planner",
sessionDirectory: "/repo",
})
expect(result.text).toContain("hello @src/foo.ts @planner")
expect(result.text).toContain("check this")
expect(result.displayText).toBe("hello @src/foo.ts @planner")
expect(result.comments).toMatchObject([{ path: "src/bar.ts", comment: "check this" }])
expect(result.agents).toEqual([{ name: "planner", mention: { start: 16, end: 24, text: "@planner" } }])
expect(result.files.some((file) => file.uri.startsWith("file:///repo/src/foo.ts"))).toBe(true)
expect(result.files.find((file) => file.uri.startsWith("file:///repo/src/foo.ts"))?.mention).toEqual({
start: 5,
end: 16,
text: "@src/foo.ts",
})
})
test("keeps multiple uploaded attachments in order", () => {
const result = buildPromptRequest({
prompt: [{ type: "text", content: "check these", start: 0, end: 11 }],
context: [],
images: [
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
{
type: "image",
id: "img_2",
filename: "b.pdf",
mime: "application/pdf",
dataUrl: "data:application/pdf;base64,BBB",
},
],
text: "check these",
sessionDirectory: "/repo",
})
const uploads = result.files.filter((file) => file.uri.startsWith("data:"))
expect(uploads).toHaveLength(2)
expect(uploads.map((file) => file.name)).toEqual(["a.png", "b.pdf"])
})
test("preserves an external attachment source path for the model", () => {
const result = buildPromptRequest({
prompt: [],
context: [],
images: [
{
type: "image",
id: "img_external",
filename: "opencode.global.dat",
sourcePath: "C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
mime: "text/plain",
dataUrl: "data:text/plain;base64,AAA",
},
],
text: "inspect this",
sessionDirectory: "C:\\Repos\\sst\\opencode",
})
expect(result.files[0]?.name).toBe(
"C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
)
})
test("preserves reference aliases as directory files", () => {
const result = buildPromptRequest({
prompt: [
{
type: "file",
path: "/repo/../docs",
content: "@docs",
start: 0,
end: 5,
mime: "application/x-directory",
filename: "docs",
},
],
context: [],
images: [],
text: "@docs",
sessionDirectory: "/repo/app",
})
expect(result.files[0]).toEqual({
uri: "file:///repo/../docs",
mime: "application/x-directory",
name: "docs",
mention: { start: 0, end: 5, text: "@docs" },
})
})
test("deduplicates context files when prompt already includes same path", () => {
const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
const result = buildPromptRequest({
prompt,
context: [
{ key: "ctx:dup", type: "file", path: "src/foo.ts" },
{ key: "ctx:comment", type: "file", path: "src/foo.ts", comment: "focus here" },
],
images: [],
text: "@src/foo.ts",
sessionDirectory: "/repo",
})
const fooFiles = result.files.filter((file) => file.uri.startsWith("file:///repo/src/foo.ts"))
expect(fooFiles).toHaveLength(2)
expect(result.text).toContain("focus here")
})
test("adds files for @mentions inside comment text", () => {
const result = buildPromptRequest({
prompt: [{ type: "text", content: "look", start: 0, end: 4 }],
context: [
{
key: "ctx:comment-mention",
type: "file",
path: "src/review.ts",
comment: "Compare with @src/shared.ts and @src/review.ts.",
},
],
images: [],
text: "look",
sessionDirectory: "/repo",
})
expect(result.files).toHaveLength(2)
expect(result.files.some((file) => file.uri === "file:///repo/src/review.ts")).toBe(true)
expect(result.files.some((file) => file.uri === "file:///repo/src/shared.ts")).toBe(true)
})
test("handles Windows paths correctly (simulated on macOS)", () => {
const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }]
const result = buildPromptRequest({
prompt,
context: [],
images: [],
text: "@src\\foo.ts",
sessionDirectory: "D:\\projects\\myapp", // Windows path
})
const file = result.files[0]
expect(file).toBeDefined()
// URL should be parseable
expect(() => new URL(file!.uri)).not.toThrow()
// Should not have encoded backslashes in wrong place
expect(file!.uri).not.toContain("%5C")
// Should have normalized to forward slashes
expect(file!.uri).toContain("/src/foo.ts")
})
test("handles Windows absolute path with special characters", () => {
const prompt: Prompt = [{ type: "file", path: "file#name.txt", content: "@file#name.txt", start: 0, end: 14 }]
const result = buildPromptRequest({
prompt,
context: [],
images: [],
text: "@file#name.txt",
sessionDirectory: "C:\\Users\\test\\Documents", // Windows path
})
const file = result.files[0]
expect(file).toBeDefined()
// URL should be parseable
expect(() => new URL(file!.uri)).not.toThrow()
// Special chars should be encoded
expect(file!.uri).toContain("file%23name.txt")
// Should have Windows drive letter properly encoded
expect(file!.uri).toMatch(/file:\/\/\/[A-Z]:/)
})
test("handles Linux absolute paths correctly", () => {
const prompt: Prompt = [{ type: "file", path: "src/app.ts", content: "@src/app.ts", start: 0, end: 10 }]
const result = buildPromptRequest({
prompt,
context: [],
images: [],
text: "@src/app.ts",
sessionDirectory: "/home/user/project",
})
expect(result.files[0]?.uri).toBe("file:///home/user/project/src/app.ts")
})
test("handles macOS paths correctly", () => {
const prompt: Prompt = [{ type: "file", path: "README.md", content: "@README.md", start: 0, end: 9 }]
const result = buildPromptRequest({
prompt,
context: [],
images: [],
text: "@README.md",
sessionDirectory: "/Users/kelvin/Projects/opencode",
})
expect(result.files[0]?.uri).toBe("file:///Users/kelvin/Projects/opencode/README.md")
})
test("handles context files with Windows paths", () => {
const result = buildPromptRequest({
prompt: [],
context: [
{ key: "ctx:1", type: "file", path: "src\\utils\\helper.ts" },
{ key: "ctx:2", type: "file", path: "test\\unit.test.ts", comment: "check tests" },
],
images: [],
text: "test",
sessionDirectory: "D:\\workspace\\app",
})
expect(result.files).toHaveLength(2)
// All file URLs should be valid
result.files.forEach((file) => {
expect(() => new URL(file.uri)).not.toThrow()
expect(file.uri).not.toContain("%5C") // No encoded backslashes
})
})
test("handles absolute Windows paths (user manually specifies full path)", () => {
const prompt: Prompt = [
{ type: "file", path: "D:\\other\\project\\file.ts", content: "@D:\\other\\project\\file.ts", start: 0, end: 25 },
]
const result = buildPromptRequest({
prompt,
context: [],
images: [],
text: "@D:\\other\\project\\file.ts",
sessionDirectory: "C:\\current\\project",
})
const file = result.files[0]
expect(file).toBeDefined()
// Should handle absolute path that differs from sessionDirectory
expect(() => new URL(file!.uri)).not.toThrow()
expect(file!.uri).toContain("/D:/other/project/file.ts")
})
test("handles selection with query parameters on Windows", () => {
const prompt: Prompt = [
{
type: "file",
path: "src\\App.tsx",
content: "@src\\App.tsx",
start: 0,
end: 11,
selection: { startLine: 10, startChar: 0, endLine: 20, endChar: 5 },
},
]
const result = buildPromptRequest({
prompt,
context: [],
images: [],
text: "@src\\App.tsx",
sessionDirectory: "C:\\project",
})
const file = result.files[0]
expect(file).toBeDefined()
// Should have query parameters
expect(file!.uri).toContain("?start=10&end=20")
// Should be valid URL
expect(() => new URL(file!.uri)).not.toThrow()
// Query params should parse correctly
const url = new URL(file!.uri)
expect(url.searchParams.get("start")).toBe("10")
expect(url.searchParams.get("end")).toBe("20")
})
test("handles file paths with dots and special segments on Windows", () => {
const prompt: Prompt = [
{ type: "file", path: "..\\..\\shared\\util.ts", content: "@..\\..\\shared\\util.ts", start: 0, end: 21 },
]
const result = buildPromptRequest({
prompt,
context: [],
images: [],
text: "@..\\..\\shared\\util.ts",
sessionDirectory: "C:\\projects\\myapp\\src",
})
const file = result.files[0]
expect(file).toBeDefined()
// Should be valid URL
expect(() => new URL(file!.uri)).not.toThrow()
// Should preserve .. segments (backend normalizes)
expect(file!.uri).toContain("/..")
})
})
@@ -0,0 +1,115 @@
import { getFilename } from "@opencode-ai/core/util/path"
import type { FileSelection } from "@/context/file"
import { encodeFilePath } from "@/context/file/path"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
import { formatCommentNote, type PromptComment } from "@/utils/comment-note"
// Network fields feed both boundaries; display fields keep desktop-only rendering details in the local echo.
type PromptRequest = {
text: string
displayText: string
files: { uri: string; mime: string; name?: string; mention?: { start: number; end: number; text: string } }[]
agents: { name: string; mention?: { start: number; end: number; text: string } }[]
comments: PromptComment[]
}
type ContextFile = {
key: string
type: "file"
path: string
selection?: FileSelection
comment?: string
commentID?: string
commentOrigin?: "review" | "file"
preview?: string
}
type BuildPromptRequestInput = {
prompt: Prompt
context: ContextFile[]
images: (Omit<ImageAttachmentPart, "blob"> & { dataUrl: string })[]
text: string
sessionDirectory: string
}
const absolute = (directory: string, path: string) => {
if (path.startsWith("/")) return path
if (/^[A-Za-z]:[\\/]/.test(path) || /^[A-Za-z]:$/.test(path)) return path
if (path.startsWith("\\\\") || path.startsWith("//")) return path
return `${directory.replace(/[\\/]+$/, "")}/${path}`
}
const fileQuery = (selection: FileSelection | undefined) =>
selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
const mention = /(^|[\s([{"'])@(\S+)/g
const parseCommentMentions = (comment: string) => {
return Array.from(comment.matchAll(mention)).flatMap((match) => {
const path = (match[2] ?? "").replace(/[.,!?;:)}\]"']+$/, "")
if (!path) return []
return [path]
})
}
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
export function buildPromptRequest(input: BuildPromptRequestInput): PromptRequest {
const files = input.prompt.filter(isFileAttachment).map((attachment) => {
const path = absolute(input.sessionDirectory, attachment.path)
return {
uri: attachment.url ?? `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`,
mime: attachment.mime ?? "text/plain",
name: attachment.filename ?? getFilename(attachment.path),
mention: { start: attachment.start, end: attachment.end, text: attachment.content },
}
})
const agents = input.prompt.filter(isAgentAttachment).map((attachment) => ({
name: attachment.name,
mention: { start: attachment.start, end: attachment.end, text: attachment.content },
}))
const used = new Set(files.map((file) => file.uri))
const comments: PromptComment[] = []
const context = input.context.flatMap((item) => {
const path = absolute(input.sessionDirectory, item.path)
const uri = `file://${encodeFilePath(path)}${fileQuery(item.selection)}`
const comment = item.comment?.trim()
if (!comment && used.has(uri)) return []
used.add(uri)
const file = { uri, mime: "text/plain", name: getFilename(item.path) }
if (!comment) return [file]
comments.push({
path: item.path,
selection: item.selection,
comment,
preview: item.preview,
origin: item.commentOrigin,
})
const mentions = parseCommentMentions(comment).flatMap((path) => {
const uri = `file://${encodeFilePath(absolute(input.sessionDirectory, path))}`
if (used.has(uri)) return []
used.add(uri)
return [{ uri, mime: "text/plain", name: getFilename(path) }]
})
return [file, ...mentions]
})
const images = input.images.map((attachment) => ({
uri: attachment.dataUrl,
mime: attachment.mime,
name: attachment.sourcePath ?? attachment.filename,
}))
return {
text: [...(input.text.trim() ? [input.text] : []), ...comments.map(formatCommentNote)].join("\n"),
displayText: input.text,
files: [...files, ...context, ...images],
agents,
comments,
}
}
@@ -1,396 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { Prompt } from "@/context/prompt"
import { buildRequestParts } from "./build-request-parts"
describe("buildRequestParts", () => {
test("builds typed request and optimistic parts without cast path", () => {
const prompt: Prompt = [
{ type: "text", content: "hello", start: 0, end: 5 },
{
type: "file",
path: "src/foo.ts",
content: "@src/foo.ts",
start: 5,
end: 16,
selection: { startLine: 4, startChar: 1, endLine: 6, endChar: 1 },
},
{ type: "agent", name: "planner", content: "@planner", start: 16, end: 24 },
]
const result = buildRequestParts({
prompt,
context: [{ key: "ctx:1", type: "file", path: "src/bar.ts", comment: "check this" }],
images: [
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
],
text: "hello @src/foo.ts @planner",
messageID: "msg_1",
sessionID: "ses_1",
sessionDirectory: "/repo",
})
expect(result.requestParts[0]?.type).toBe("text")
expect(result.requestParts.some((part) => part.type === "agent")).toBe(true)
expect(
result.requestParts.some((part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts")),
).toBe(true)
expect(result.requestParts.some((part) => part.type === "text" && part.synthetic)).toBe(true)
expect(
result.requestParts.some(
(part) =>
part.type === "text" &&
part.synthetic &&
part.metadata?.opencodeComment &&
(part.metadata.opencodeComment as { comment?: string }).comment === "check this",
),
).toBe(true)
expect(result.optimisticParts).toHaveLength(result.requestParts.length)
expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
})
test("keeps multiple uploaded attachments in order", () => {
const result = buildRequestParts({
prompt: [{ type: "text", content: "check these", start: 0, end: 11 }],
context: [],
images: [
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
{
type: "image",
id: "img_2",
filename: "b.pdf",
mime: "application/pdf",
dataUrl: "data:application/pdf;base64,BBB",
},
],
text: "check these",
messageID: "msg_multi",
sessionID: "ses_multi",
sessionDirectory: "/repo",
})
const files = result.requestParts.filter((part) => part.type === "file" && part.url.startsWith("data:"))
expect(files).toHaveLength(2)
expect(files.map((part) => (part.type === "file" ? part.filename : ""))).toEqual(["a.png", "b.pdf"])
})
test("preserves an external attachment source path for the model", () => {
const result = buildRequestParts({
prompt: [],
context: [],
images: [
{
type: "image",
id: "img_external",
filename: "opencode.global.dat",
sourcePath: "C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
mime: "text/plain",
dataUrl: "data:text/plain;base64,AAA",
},
],
text: "inspect this",
messageID: "msg_external",
sessionID: "ses_external",
sessionDirectory: "C:\\Repos\\sst\\opencode",
})
expect(result.requestParts.find((part) => part.type === "file")?.filename).toBe(
"C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
)
})
test("preserves reference aliases as directory file parts", () => {
const result = buildRequestParts({
prompt: [
{
type: "file",
path: "/repo/../docs",
content: "@docs",
start: 0,
end: 5,
mime: "application/x-directory",
filename: "docs",
},
],
context: [],
images: [],
text: "@docs",
messageID: "msg_reference",
sessionID: "ses_reference",
sessionDirectory: "/repo/app",
})
const filePart = result.requestParts.find((part) => part.type === "file")
expect(filePart).toBeDefined()
if (filePart?.type === "file") {
expect(filePart.mime).toBe("application/x-directory")
expect(filePart.filename).toBe("docs")
expect(filePart.url).toBe("file:///repo/../docs")
expect(filePart.source?.type).toBe("file")
if (filePart.source?.type === "file") {
expect(filePart.source.path).toBe("/repo/../docs")
expect(filePart.source.text.value).toBe("@docs")
}
}
})
test("deduplicates context files when prompt already includes same path", () => {
const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
const result = buildRequestParts({
prompt,
context: [
{ key: "ctx:dup", type: "file", path: "src/foo.ts" },
{ key: "ctx:comment", type: "file", path: "src/foo.ts", comment: "focus here" },
],
images: [],
text: "@src/foo.ts",
messageID: "msg_2",
sessionID: "ses_2",
sessionDirectory: "/repo",
})
const fooFiles = result.requestParts.filter(
(part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts"),
)
const synthetic = result.requestParts.filter((part) => part.type === "text" && part.synthetic)
expect(fooFiles).toHaveLength(2)
expect(synthetic).toHaveLength(1)
})
test("adds file parts for @mentions inside comment text", () => {
const result = buildRequestParts({
prompt: [{ type: "text", content: "look", start: 0, end: 4 }],
context: [
{
key: "ctx:comment-mention",
type: "file",
path: "src/review.ts",
comment: "Compare with @src/shared.ts and @src/review.ts.",
},
],
images: [],
text: "look",
messageID: "msg_comment_mentions",
sessionID: "ses_comment_mentions",
sessionDirectory: "/repo",
})
const files = result.requestParts.filter((part) => part.type === "file")
expect(files).toHaveLength(2)
expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/review.ts")).toBe(true)
expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/shared.ts")).toBe(true)
})
test("handles Windows paths correctly (simulated on macOS)", () => {
const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }]
const result = buildRequestParts({
prompt,
context: [],
images: [],
text: "@src\\foo.ts",
messageID: "msg_win_1",
sessionID: "ses_win_1",
sessionDirectory: "D:\\projects\\myapp", // Windows path
})
// Should create valid file URLs
const filePart = result.requestParts.find((part) => part.type === "file")
expect(filePart).toBeDefined()
if (filePart?.type === "file") {
// URL should be parseable
expect(() => new URL(filePart.url)).not.toThrow()
// Should not have encoded backslashes in wrong place
expect(filePart.url).not.toContain("%5C")
// Should have normalized to forward slashes
expect(filePart.url).toContain("/src/foo.ts")
}
})
test("handles Windows absolute path with special characters", () => {
const prompt: Prompt = [{ type: "file", path: "file#name.txt", content: "@file#name.txt", start: 0, end: 14 }]
const result = buildRequestParts({
prompt,
context: [],
images: [],
text: "@file#name.txt",
messageID: "msg_win_2",
sessionID: "ses_win_2",
sessionDirectory: "C:\\Users\\test\\Documents", // Windows path
})
const filePart = result.requestParts.find((part) => part.type === "file")
expect(filePart).toBeDefined()
if (filePart?.type === "file") {
// URL should be parseable
expect(() => new URL(filePart.url)).not.toThrow()
// Special chars should be encoded
expect(filePart.url).toContain("file%23name.txt")
// Should have Windows drive letter properly encoded
expect(filePart.url).toMatch(/file:\/\/\/[A-Z]:/)
}
})
test("handles Linux absolute paths correctly", () => {
const prompt: Prompt = [{ type: "file", path: "src/app.ts", content: "@src/app.ts", start: 0, end: 10 }]
const result = buildRequestParts({
prompt,
context: [],
images: [],
text: "@src/app.ts",
messageID: "msg_linux_1",
sessionID: "ses_linux_1",
sessionDirectory: "/home/user/project",
})
const filePart = result.requestParts.find((part) => part.type === "file")
expect(filePart).toBeDefined()
if (filePart?.type === "file") {
// URL should be parseable
expect(() => new URL(filePart.url)).not.toThrow()
// Should be a normal Unix path
expect(filePart.url).toBe("file:///home/user/project/src/app.ts")
}
})
test("handles macOS paths correctly", () => {
const prompt: Prompt = [{ type: "file", path: "README.md", content: "@README.md", start: 0, end: 9 }]
const result = buildRequestParts({
prompt,
context: [],
images: [],
text: "@README.md",
messageID: "msg_mac_1",
sessionID: "ses_mac_1",
sessionDirectory: "/Users/kelvin/Projects/opencode",
})
const filePart = result.requestParts.find((part) => part.type === "file")
expect(filePart).toBeDefined()
if (filePart?.type === "file") {
// URL should be parseable
expect(() => new URL(filePart.url)).not.toThrow()
// Should be a normal Unix path
expect(filePart.url).toBe("file:///Users/kelvin/Projects/opencode/README.md")
}
})
test("handles context files with Windows paths", () => {
const prompt: Prompt = []
const result = buildRequestParts({
prompt,
context: [
{ key: "ctx:1", type: "file", path: "src\\utils\\helper.ts" },
{ key: "ctx:2", type: "file", path: "test\\unit.test.ts", comment: "check tests" },
],
images: [],
text: "test",
messageID: "msg_win_ctx",
sessionID: "ses_win_ctx",
sessionDirectory: "D:\\workspace\\app",
})
const fileParts = result.requestParts.filter((part) => part.type === "file")
expect(fileParts).toHaveLength(2)
// All file URLs should be valid
fileParts.forEach((part) => {
if (part.type === "file") {
expect(() => new URL(part.url)).not.toThrow()
expect(part.url).not.toContain("%5C") // No encoded backslashes
}
})
})
test("handles absolute Windows paths (user manually specifies full path)", () => {
const prompt: Prompt = [
{ type: "file", path: "D:\\other\\project\\file.ts", content: "@D:\\other\\project\\file.ts", start: 0, end: 25 },
]
const result = buildRequestParts({
prompt,
context: [],
images: [],
text: "@D:\\other\\project\\file.ts",
messageID: "msg_abs",
sessionID: "ses_abs",
sessionDirectory: "C:\\current\\project",
})
const filePart = result.requestParts.find((part) => part.type === "file")
expect(filePart).toBeDefined()
if (filePart?.type === "file") {
// Should handle absolute path that differs from sessionDirectory
expect(() => new URL(filePart.url)).not.toThrow()
expect(filePart.url).toContain("/D:/other/project/file.ts")
}
})
test("handles selection with query parameters on Windows", () => {
const prompt: Prompt = [
{
type: "file",
path: "src\\App.tsx",
content: "@src\\App.tsx",
start: 0,
end: 11,
selection: { startLine: 10, startChar: 0, endLine: 20, endChar: 5 },
},
]
const result = buildRequestParts({
prompt,
context: [],
images: [],
text: "@src\\App.tsx",
messageID: "msg_sel",
sessionID: "ses_sel",
sessionDirectory: "C:\\project",
})
const filePart = result.requestParts.find((part) => part.type === "file")
expect(filePart).toBeDefined()
if (filePart?.type === "file") {
// Should have query parameters
expect(filePart.url).toContain("?start=10&end=20")
// Should be valid URL
expect(() => new URL(filePart.url)).not.toThrow()
// Query params should parse correctly
const url = new URL(filePart.url)
expect(url.searchParams.get("start")).toBe("10")
expect(url.searchParams.get("end")).toBe("20")
}
})
test("handles file paths with dots and special segments on Windows", () => {
const prompt: Prompt = [
{ type: "file", path: "..\\..\\shared\\util.ts", content: "@..\\..\\shared\\util.ts", start: 0, end: 21 },
]
const result = buildRequestParts({
prompt,
context: [],
images: [],
text: "@..\\..\\shared\\util.ts",
messageID: "msg_dots",
sessionID: "ses_dots",
sessionDirectory: "C:\\projects\\myapp\\src",
})
const filePart = result.requestParts.find((part) => part.type === "file")
expect(filePart).toBeDefined()
if (filePart?.type === "file") {
// Should be valid URL
expect(() => new URL(filePart.url)).not.toThrow()
// Should preserve .. segments (backend normalizes)
expect(filePart.url).toContain("/..")
}
})
})
@@ -1,216 +0,0 @@
import { getFilename } from "@opencode-ai/core/util/path"
import type { AgentPart as MessageAgentPart, FilePart, Part, TextPart } from "@/types"
import type { FileSelection } from "@/context/file"
import { encodeFilePath } from "@/context/file/path"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
import { Identifier } from "@/utils/id"
import { createCommentMetadata, formatCommentNote } from "@/utils/comment-note"
type PromptRequestPart =
| (Omit<TextPart, "id" | "sessionID" | "messageID"> & { id: string })
| (Omit<FilePart, "id" | "sessionID" | "messageID"> & { id: string })
| (Omit<MessageAgentPart, "id" | "sessionID" | "messageID"> & { id: string })
type ContextFile = {
key: string
type: "file"
path: string
selection?: FileSelection
comment?: string
commentID?: string
commentOrigin?: "review" | "file"
preview?: string
}
type BuildRequestPartsInput = {
prompt: Prompt
context: ContextFile[]
images: (Omit<ImageAttachmentPart, "blob"> & { dataUrl: string })[]
text: string
messageID: string
sessionID: string
sessionDirectory: string
}
const absolute = (directory: string, path: string) => {
if (path.startsWith("/")) return path
if (/^[A-Za-z]:[\\/]/.test(path) || /^[A-Za-z]:$/.test(path)) return path
if (path.startsWith("\\\\") || path.startsWith("//")) return path
return `${directory.replace(/[\\/]+$/, "")}/${path}`
}
const fileQuery = (selection: FileSelection | undefined) =>
selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
const mention = /(^|[\s([{"'])@(\S+)/g
const parseCommentMentions = (comment: string) => {
return Array.from(comment.matchAll(mention)).flatMap((match) => {
const path = (match[2] ?? "").replace(/[.,!?;:)}\]"']+$/, "")
if (!path) return []
return [path]
})
}
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
const toOptimisticPart = (part: PromptRequestPart, sessionID: string, messageID: string): Part => {
if (part.type === "text") {
return {
id: part.id,
type: "text",
text: part.text,
synthetic: part.synthetic,
ignored: part.ignored,
time: part.time,
metadata: part.metadata,
sessionID,
messageID,
}
}
if (part.type === "file") {
return {
id: part.id,
type: "file",
mime: part.mime,
filename: part.filename,
url: part.url,
source: part.source,
sessionID,
messageID,
}
}
return {
id: part.id,
type: "agent",
name: part.name,
source: part.source,
sessionID,
messageID,
}
}
export function buildRequestParts(input: BuildRequestPartsInput) {
const requestParts: PromptRequestPart[] = input.text.trim()
? [
{
id: Identifier.ascending("part"),
type: "text",
text: input.text,
},
]
: []
const files = input.prompt.filter(isFileAttachment).map((attachment) => {
const path = absolute(input.sessionDirectory, attachment.path)
const source = attachment.source
? {
...attachment.source,
text: {
value: attachment.content,
start: attachment.start,
end: attachment.end,
},
}
: {
type: "file" as const,
text: {
value: attachment.content,
start: attachment.start,
end: attachment.end,
},
path,
}
return {
id: Identifier.ascending("part"),
type: "file",
mime: attachment.mime ?? "text/plain",
url: attachment.url ?? `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`,
filename: attachment.filename ?? getFilename(attachment.path),
source,
} satisfies PromptRequestPart
})
const agents = input.prompt.filter(isAgentAttachment).map((attachment) => {
return {
id: Identifier.ascending("part"),
type: "agent",
name: attachment.name,
source: {
value: attachment.content,
start: attachment.start,
end: attachment.end,
},
} satisfies PromptRequestPart
})
const used = new Set(files.map((part) => part.url))
const context = input.context.flatMap((item) => {
const path = absolute(input.sessionDirectory, item.path)
const url = `file://${encodeFilePath(path)}${fileQuery(item.selection)}`
const comment = item.comment?.trim()
if (!comment && used.has(url)) return []
used.add(url)
const filePart = {
id: Identifier.ascending("part"),
type: "file",
mime: "text/plain",
url,
filename: getFilename(item.path),
} satisfies PromptRequestPart
if (!comment) return [filePart]
const mentions = parseCommentMentions(comment).flatMap((path) => {
const url = `file://${encodeFilePath(absolute(input.sessionDirectory, path))}`
if (used.has(url)) return []
used.add(url)
return [
{
id: Identifier.ascending("part"),
type: "file",
mime: "text/plain",
url,
filename: getFilename(path),
} satisfies PromptRequestPart,
]
})
return [
{
id: Identifier.ascending("part"),
type: "text",
text: formatCommentNote({ path: item.path, selection: item.selection, comment }),
synthetic: true,
metadata: createCommentMetadata({
path: item.path,
selection: item.selection,
comment,
preview: item.preview,
origin: item.commentOrigin,
}),
} satisfies PromptRequestPart,
filePart,
...mentions,
]
})
const images = input.images.map((attachment) => {
return {
id: Identifier.ascending("part"),
type: "file",
mime: attachment.mime,
url: attachment.dataUrl,
filename: attachment.sourcePath ?? attachment.filename,
} satisfies PromptRequestPart
})
requestParts.push(...files, ...context, ...agents, ...images)
return {
requestParts,
optimisticParts: requestParts.map((part) => toOptimisticPart(part, input.sessionID, input.messageID)),
}
}
@@ -11,15 +11,17 @@ type SessionCreateInput = {
model?: { id: string; providerID: string; variant?: string } model?: { id: string; providerID: string; variant?: string }
location?: { directory: string } location?: { directory: string }
} }
const optimistic: Array<{ const admitted: Array<{
directory?: string directory?: string
sessionID?: string sessionID: string
message: { messageID: string
agent: string text: string
model: { providerID: string; modelID: string } displayText: string
variant?: string agent: string
} model: { providerID: string; modelID: string; variant?: string }
comments: unknown[]
}> = [] }> = []
const confirmed: unknown[] = []
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {} const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = [] const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
const sentShellDirectories: string[] = [] const sentShellDirectories: string[] = []
@@ -35,9 +37,11 @@ const switchedModels: Array<{
const sessionRequestOrder: string[] = [] const sessionRequestOrder: string[] = []
const updatedDrafts: Array<{ draftID: string; worktree?: string }> = [] const updatedDrafts: Array<{ draftID: string; worktree?: string }> = []
const syncedServers: string[] = [] const syncedServers: string[] = []
const optimisticServers: string[] = [] const admittedServers: string[] = []
const promptCaptures: Array<{ scope?: unknown; target?: unknown }> = [] const promptCaptures: Array<{ scope?: unknown; target?: unknown }> = []
let serverSessionSyncs = 0 let serverSessionSyncs = 0
let restoredPrompts = 0
let clearEchoCalls = 0
let params: { id?: string } = {} let params: { id?: string } = {}
let search: { draftId?: string } = {} let search: { draftId?: string } = {}
@@ -47,6 +51,8 @@ let createSessionGate: Promise<void> | undefined
let createWorktreeGate: Promise<void> | undefined let createWorktreeGate: Promise<void> | undefined
let worktreeFailure: Error | undefined let worktreeFailure: Error | undefined
let locationFailure: Error | undefined let locationFailure: Error | undefined
let promptFailure: Error | undefined
let clearEchoResult = true
let worktreeCreates = 0 let worktreeCreates = 0
let activeSDK = "server-a" let activeSDK = "server-a"
let activeServerSync = "server-a" let activeServerSync = "server-a"
@@ -74,7 +80,7 @@ const prompt = {
set: () => undefined, set: () => undefined,
}, },
reset: () => undefined, reset: () => undefined,
set: () => undefined, set: () => restoredPrompts++,
context: { context: {
add: () => undefined, add: () => undefined,
remove: () => undefined, remove: () => undefined,
@@ -116,7 +122,16 @@ const clientFor = (directory: string) => {
sessionRequestOrder.push("prompt") sessionRequestOrder.push("prompt")
sentPrompts.push(sessionDirectories[(input as { sessionID: string }).sessionID] ?? directory) sentPrompts.push(sessionDirectories[(input as { sessionID: string }).sessionID] ?? directory)
promptInputs.push(input) promptInputs.push(input)
return { data: undefined } if (promptFailure) throw promptFailure
const prompt = input as { sessionID: string; id: string; text: string }
return {
id: prompt.id,
sessionID: prompt.sessionID,
timeCreated: 1,
type: "user" as const,
delivery: "steer" as const,
payload: { text: prompt.text },
}
}, },
switchAgent: async (input: { sessionID: string; agent: string }) => { switchAgent: async (input: { sessionID: string; agent: string }) => {
sessionRequestOrder.push("agent") sessionRequestOrder.push("agent")
@@ -235,16 +250,27 @@ beforeAll(async () => {
return { return {
data: { command: commands, project: "project" }, data: { command: commands, project: "project" },
session: { session: {
optimistic: { inbox: {
add: (value: { echo: (value: {
directory?: string directory?: string
sessionID?: string sessionID: string
message: { agent: string; model: { providerID: string; modelID: string; variant?: string } } messageID: string
text: string
displayText: string
agent: string
model: { providerID: string; modelID: string; variant?: string }
comments: unknown[]
}) => { }) => {
optimisticServers.push(server) admittedServers.push(server)
optimistic.push(value) admitted.push(value)
},
confirm: (value: unknown) => {
confirmed.push(value)
},
clearEcho: () => {
clearEchoCalls++
return clearEchoResult
}, },
remove: () => undefined,
}, },
}, },
set: () => undefined, set: () => undefined,
@@ -304,7 +330,8 @@ beforeAll(async () => {
beforeEach(() => { beforeEach(() => {
createdSessions.length = 0 createdSessions.length = 0
optimistic.length = 0 admitted.length = 0
confirmed.length = 0
promotedDrafts.length = 0 promotedDrafts.length = 0
updatedDrafts.length = 0 updatedDrafts.length = 0
sentCommands.length = 0 sentCommands.length = 0
@@ -314,8 +341,10 @@ beforeEach(() => {
switchedModels.length = 0 switchedModels.length = 0
sessionRequestOrder.length = 0 sessionRequestOrder.length = 0
syncedServers.length = 0 syncedServers.length = 0
optimisticServers.length = 0 admittedServers.length = 0
promptCaptures.length = 0 promptCaptures.length = 0
restoredPrompts = 0
clearEchoCalls = 0
params = {} params = {}
search = {} search = {}
sentShell.length = 0 sentShell.length = 0
@@ -333,6 +362,8 @@ beforeEach(() => {
createWorktreeGate = undefined createWorktreeGate = undefined
worktreeFailure = undefined worktreeFailure = undefined
locationFailure = undefined locationFailure = undefined
promptFailure = undefined
clearEchoResult = true
worktreeCreates = 0 worktreeCreates = 0
for (const key of Object.keys(draftServers)) delete draftServers[key] for (const key of Object.keys(draftServers)) delete draftServers[key]
for (const key of Object.keys(sessionDirectories)) delete sessionDirectories[key] for (const key of Object.keys(sessionDirectories)) delete sessionDirectories[key]
@@ -421,7 +452,7 @@ describe("prompt submit worktree selection", () => {
expect(updatedDrafts).toEqual([{ draftID: "draft-1", worktree: undefined }]) expect(updatedDrafts).toEqual([{ draftID: "draft-1", worktree: undefined }])
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server-a", sessionId: "session-1" }]) expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server-a", sessionId: "session-1" }])
expect(syncedServers.every((server) => server === "server-a")).toBe(true) expect(syncedServers.every((server) => server === "server-a")).toBe(true)
expect(optimisticServers).toEqual(["server-a"]) expect(admittedServers).toEqual(["server-a"])
expect(promptCaptures.at(-1)?.target).toEqual({ server: "project-server-a", scope: ServerScope.local }) expect(promptCaptures.at(-1)?.target).toEqual({ server: "project-server-a", scope: ServerScope.local })
expect(submitted).toBe(0) expect(submitted).toBe(0)
}) })
@@ -441,13 +472,15 @@ describe("prompt submit worktree selection", () => {
await submit.handleSubmit(event) await submit.handleSubmit(event)
await Bun.sleep(0) await Bun.sleep(0)
expect(optimistic).toHaveLength(1) expect(admitted).toHaveLength(1)
expect(optimistic[0]).toMatchObject({ expect(admitted[0]).toMatchObject({
message: { sessionID: "session-1",
agent: "agent", text: "ls",
model: { providerID: "provider", modelID: "model", variant: "high" }, agent: "agent",
}, model: { providerID: "provider", modelID: "model", variant: "high" },
}) })
expect(admitted[0]?.messageID).toStartWith("msg_")
expect(confirmed).toMatchObject([{ id: admitted[0]?.messageID, sessionID: "session-1" }])
expect(sentPrompts).toEqual(["/repo/main"]) expect(sentPrompts).toEqual(["/repo/main"])
expect(switchedAgents).toEqual([{ sessionID: "session-1", agent: "agent" }]) expect(switchedAgents).toEqual([{ sessionID: "session-1", agent: "agent" }])
expect(switchedModels).toEqual([ expect(switchedModels).toEqual([
@@ -466,6 +499,22 @@ describe("prompt submit worktree selection", () => {
expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_") expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_")
}) })
test("keeps a confirmed echo when the prompt response is lost", async () => {
params = { id: "session-1" }
promptFailure = new Error("connection lost")
clearEchoResult = false
const submit = makeSubmit({
info: () => ({ id: "session-1", agent: "agent", model: { id: "model", providerID: "provider" } }),
})
await submit.handleSubmit(event)
await settle()
expect(admitted).toHaveLength(1)
expect(clearEchoCalls).toBe(1)
expect(restoredPrompts).toBe(0)
})
test("submits slash commands through the current session API", async () => { test("submits slash commands through the current session API", async () => {
params = { id: "session-1" } params = { id: "session-1" }
variant = "high" variant = "high"
@@ -1,10 +1,9 @@
import type { Message } from "@/types"
import type { SessionInfo } from "@opencode-ai/client/promise" import type { SessionInfo } from "@opencode-ai/client/promise"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { base64Encode } from "@opencode-ai/core/util/encode" import { base64Encode } from "@opencode-ai/core/util/encode"
import { Binary } from "@opencode-ai/core/util/binary" import { Binary } from "@opencode-ai/core/util/binary"
import { useNavigate, useParams, useSearchParams } from "@solidjs/router" import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { batch, startTransition, type Accessor } from "solid-js" import { startTransition, type Accessor } from "solid-js"
import { useTabs } from "@/context/tabs" import { useTabs } from "@/context/tabs"
import { useServerSync, type ServerSync } from "@/context/server-sync" import { useServerSync, type ServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@@ -15,7 +14,7 @@ import { useSDK, type DirectorySDK } from "@/context/sdk"
import { useSync, type DirectorySync } from "@/context/sync" import { useSync, type DirectorySync } from "@/context/sync"
import { Identifier } from "@/utils/id" import { Identifier } from "@/utils/id"
import { getDirectory } from "@opencode-ai/core/util/path" import { getDirectory } from "@opencode-ai/core/util/path"
import { buildRequestParts } from "./build-request-parts" import { buildPromptRequest } from "./build-prompt-request"
import { setCursorPosition } from "./editor-dom" import { setCursorPosition } from "./editor-dom"
import { formatServerError } from "@/utils/server-errors" import { formatServerError } from "@/utils/server-errors"
import { ScopedKey } from "@/utils/server-scope" import { ScopedKey } from "@/utils/server-scope"
@@ -100,43 +99,22 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
dataUrl: await blobDataUrl(attachment.blob, attachment.mime), dataUrl: await blobDataUrl(attachment.blob, attachment.mime),
})), })),
) )
const { requestParts, optimisticParts } = buildRequestParts({ const request = buildPromptRequest({
prompt: input.draft.prompt, prompt: input.draft.prompt,
context: input.draft.context, context: input.draft.context,
images: encodedImages, images: encodedImages,
text, text,
sessionID: input.draft.sessionID,
messageID,
sessionDirectory: input.draft.sessionDirectory, sessionDirectory: input.draft.sessionDirectory,
}) })
const message: Message = { setBusy()
id: messageID, input.sync.session.inbox.echo({
directory: input.draft.sessionDirectory,
sessionID: input.draft.sessionID, sessionID: input.draft.sessionID,
role: "user", messageID,
time: { created: Date.now() },
agent: input.draft.agent, agent: input.draft.agent,
model: { ...input.draft.model, variant: input.draft.variant }, model: { ...input.draft.model, variant: input.draft.variant },
} ...request,
const add = () =>
input.sync.session.optimistic.add({
directory: input.draft.sessionDirectory,
sessionID: input.draft.sessionID,
message,
parts: optimisticParts,
})
const remove = () =>
input.sync.session.optimistic.remove({
directory: input.draft.sessionDirectory,
sessionID: input.draft.sessionID,
messageID,
})
batch(() => {
setBusy()
add()
}) })
try { try {
@@ -159,40 +137,23 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
}) })
} }
await input.api.prompt({ const admitted = await input.api.prompt({
sessionID: input.draft.sessionID, sessionID: input.draft.sessionID,
id: messageID, id: messageID,
text: requestParts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"), text: request.text,
files: requestParts.flatMap((part) => { files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
if (part.type !== "file") return [] agents: request.agents,
const text = part.source?.text
return [
{
uri: part.url,
name: part.filename,
mention: text ? { start: text.start, end: text.end, text: text.value } : undefined,
},
]
}),
agents: requestParts.flatMap((part) =>
part.type === "agent"
? [
{
name: part.name,
mention: part.source
? { start: part.source.start, end: part.source.end, text: part.source.value }
: undefined,
},
]
: [],
),
}) })
input.sync.session.inbox.confirm(admitted)
return true return true
} catch (err) { } catch (err) {
batch(() => { const failed = input.sync.session.inbox.clearEcho({
setIdle() directory: input.draft.sessionDirectory,
remove() sessionID: input.draft.sessionID,
messageID,
}) })
if (!failed) return true
setIdle()
throw err throw err
} }
} }
@@ -538,14 +499,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim()) const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
const messageID = Identifier.ascending("message") const messageID = Identifier.ascending("message")
const removeOptimisticMessage = () => {
submissionSync.session.optimistic.remove({
directory: sessionDirectory,
sessionID: session.id,
messageID,
})
}
for (const item of commentItems) submission.target().context.remove(item.key) for (const item of commentItems) submission.target().context.remove(item.key)
clearInput() clearInput()
@@ -565,7 +518,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
title: language.t("prompt.toast.promptSendFailed.title"), title: language.t("prompt.toast.promptSendFailed.title"),
description: errorMessage(err), description: errorMessage(err),
}) })
removeOptimisticMessage()
if (restoreInput()) restoreCommentItems(submission.target(), commentItems) if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
}) })
} finally { } finally {
@@ -119,9 +119,11 @@ export function createProviderConnectionController(options: {
const finish = async () => { const finish = async () => {
cancelPolling() cancelPolling()
const directory = options.directory() const directory = options.directory()
await queryClient const key = directory ? pathKey(directory) : null
.refetchQueries(serverSync.queryOptions.providers(directory ? pathKey(directory) : null)) await Promise.all([
.catch(() => undefined) queryClient.refetchQueries(serverSync.queryOptions.providers(key)).catch(() => undefined),
queryClient.refetchQueries(serverSync.queryOptions.integrations(key)).catch(() => undefined),
])
if (polling.disposed) return if (polling.disposed) return
options.onComplete() options.onComplete()
} }
@@ -4,6 +4,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { popularProviders, useProviders } from "@/hooks/use-providers" import { popularProviders, useProviders } from "@/hooks/use-providers"
import { useIntegrations } from "@/hooks/use-integrations"
import { createMemo, type Component, For, Show } from "solid-js" import { createMemo, type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
@@ -40,7 +41,9 @@ export const SettingsProvidersV2: Component<{
const serverSdk = useServerSDK() const serverSdk = useServerSDK()
const serverSync = useServerSync() const serverSync = useServerSync()
const providers = useProviders(() => props.directory) const providers = useProviders(() => props.directory)
const integrations = useIntegrations(() => props.directory)
const providerConnect = useProviderConnectController({ onBack: props.onBack }) const providerConnect = useProviderConnectController({ onBack: props.onBack })
const integration = (providerID: string) => integrations.list().find((item) => item.id === providerID)
const connect = (provider?: string) => { const connect = (provider?: string) => {
providerConnect.select(provider) providerConnect.select(provider)
@@ -73,7 +76,14 @@ export const SettingsProvidersV2: Component<{
return items return items
}) })
// Connection state comes from the integration list like the TUI: credential
// connections mean an API key or OAuth grant, env connections mean detected
// environment variables, and a connectionless integration is config-provided.
const source = (item: ProviderItem): ProviderSource | undefined => { const source = (item: ProviderItem): ProviderSource | undefined => {
const current = integration(item.id)
if (current?.connections.some((connection) => connection.type === "credential")) return "api"
if (current?.connections.some((connection) => connection.type === "env")) return "env"
if (current) return "config"
if (!("source" in item)) return if (!("source" in item)) return
const value = item.source const value = item.source
if (value === "env" || value === "api" || value === "config" || value === "custom") return value if (value === "env" || value === "api" || value === "config" || value === "custom") return value
@@ -92,7 +102,11 @@ export const SettingsProvidersV2: Component<{
return language.t("settings.providers.tag.other") return language.t("settings.providers.tag.other")
} }
const canDisconnect = (item: ProviderItem) => source(item) !== "env" && !isConfigCustom(item.id) const canDisconnect = (item: ProviderItem) => {
const current = integration(item.id)
if (current) return current.connections.some((connection) => connection.type === "credential")
return source(item) !== "env" && !isConfigCustom(item.id)
}
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
@@ -68,7 +68,8 @@
display: none; display: none;
} }
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-link"] { [data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-link"],
[data-titlebar-tab]:is(:hover, [data-active="true"]):not([data-editing="true"]) [data-slot="tab-link"] {
--tab-title-fade-offset: 4px; --tab-title-fade-offset: 4px;
-webkit-mask-image: linear-gradient( -webkit-mask-image: linear-gradient(
to right, to right,
@@ -86,7 +87,8 @@
); );
} }
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]):dir(rtl) [data-slot="tab-link"] { [data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]):dir(rtl) [data-slot="tab-link"],
[data-titlebar-tab]:is(:hover, [data-active="true"]):not([data-editing="true"]):dir(rtl) [data-slot="tab-link"] {
-webkit-mask-image: linear-gradient( -webkit-mask-image: linear-gradient(
to left, to left,
black 0, black 0,
@@ -103,8 +105,7 @@
); );
} }
[data-titlebar-tab][data-title-overflow="true"]:is(:hover, [data-active="true"]):not([data-editing="true"]) [data-titlebar-tab]:is(:hover, [data-active="true"]):not([data-editing="true"]) [data-slot="tab-link"] {
[data-slot="tab-link"] {
--tab-title-fade-offset: 24px; --tab-title-fade-offset: 24px;
} }
+10 -28
View File
@@ -1,10 +1,10 @@
import { Binary } from "@opencode-ai/core/util/binary" import { Binary } from "@opencode-ai/core/util/binary"
import type { Message, Part } from "@/types" import type { SessionInboxInfo, SessionInfo } from "@opencode-ai/client/promise"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { createMemo } from "solid-js" import { createMemo } from "solid-js"
import { produce, reconcile, type SetStoreFunction } from "solid-js/store" import { produce, reconcile, type SetStoreFunction } from "solid-js/store"
import type { createServerSdkContext } from "./server-sdk" import type { createServerSdkContext } from "./server-sdk"
import type { createServerSyncContextInner } from "./server-sync" import type { createServerSyncContextInner } from "./server-sync"
import type { PromptEcho } from "./server-session"
import type { State } from "./global-sync/types" import type { State } from "./global-sync/types"
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
@@ -82,34 +82,16 @@ export const createDirSyncContext = (
const session = serverSync.session.get(sessionID) const session = serverSync.session.get(sessionID)
if (session?.location.directory === directory) return session if (session?.location.directory === directory) return session
}, },
optimistic: { inbox: {
add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) { echo(input: PromptEcho & { directory?: string }) {
serverSync.session.optimistic.add(input) serverSync.session.inbox.echo(input)
}, },
remove(input: { directory?: string; sessionID: string; messageID: string }) { confirm(input: SessionInboxInfo) {
serverSync.session.optimistic.remove(input) return serverSync.session.inbox.confirm(input)
},
clearEcho(input: { directory?: string; sessionID: string; messageID: string }) {
return serverSync.session.inbox.clearEcho(input)
}, },
},
addOptimisticMessage(input: {
sessionID: string
messageID: string
parts: Part[]
agent: string
model: { providerID: string; modelID: string }
variant?: string
}) {
serverSync.session.optimistic.add({
sessionID: input.sessionID,
message: {
id: input.messageID,
sessionID: input.sessionID,
role: "user",
time: { created: Date.now() },
agent: input.agent,
model: { ...input.model, variant: input.variant },
},
parts: input.parts,
})
}, },
async sync(sessionID: string, options?: { force?: boolean }) { async sync(sessionID: string, options?: { force?: boolean }) {
await serverSync.session.sync(sessionID, options) await serverSync.session.sync(sessionID, options)
+1 -1
View File
@@ -143,7 +143,7 @@ describe("encodeFilePath", () => {
}) })
test("should handle mixed separator path (Windows + Unix)", () => { test("should handle mixed separator path (Windows + Unix)", () => {
// This is what happens in build-request-parts.ts when concatenating paths // This is what happens in build-prompt-request.ts when concatenating paths
const mixedPath = "D:\\dev\\projects\\opencode/README.bs.md" const mixedPath = "D:\\dev\\projects\\opencode/README.bs.md"
const result = encodeFilePath(mixedPath) const result = encodeFilePath(mixedPath)
const fileUrl = `file://${result}` const fileUrl = `file://${result}`
+2 -1
View File
@@ -287,7 +287,8 @@ export function createServerNotificationState(input: { sdk: ServerSDK; sync: Ser
) )
return return
const directory = e.name const directory = event.current?.location?.directory
if (!directory) return
const time = Date.now() const time = Date.now()
if (event.type === "session.execution.failed") { if (event.type === "session.execution.failed") {
handleSessionError(directory, event, time) handleSessionError(directory, event, time)
+1 -1
View File
@@ -194,7 +194,7 @@ export function createServerPermissionState(input: { sdk: ServerSDK; sync: Serve
const handlePermission = (e: PermissionEvent) => { const handlePermission = (e: PermissionEvent) => {
const event = e.details const event = e.details
if (event?.type !== "permission.asked") return if (event?.type !== "permission.asked") return
void respondPending(event.properties, e.name) void respondPending(event.properties, event.current?.location?.directory)
} }
const unsubscribe = input.sdk.event.listen((event) => { const unsubscribe = input.sdk.event.listen((event) => {
+12 -21
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { OpenCodeEvent } from "@opencode-ai/client/promise" import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk" import { adaptServerEvent, coalesceServerEvents, resumeStreamAfterPageShow } from "./server-sdk"
describe("resumeStreamAfterPageShow", () => { describe("resumeStreamAfterPageShow", () => {
test("restarts a stream only after a back-forward cache restore", () => { test("restarts a stream only after a back-forward cache restore", () => {
@@ -45,23 +45,21 @@ describe("adaptServerEvent", () => {
}) })
describe("current event buffering", () => { describe("current event buffering", () => {
const delta = (id: string, value: string, ordinal = 0) => ({ const delta = (id: string, value: string, ordinal = 0) =>
directory: "/repo", adaptServerEvent({
payload: adaptServerEvent({
id, id,
created: 1, created: 1,
type: "session.text.delta", type: "session.text.delta",
location: { directory: "/repo" }, location: { directory: "/repo" },
data: { sessionID: "ses", assistantMessageID: "msg", ordinal, delta: value }, data: { sessionID: "ses", assistantMessageID: "msg", ordinal, delta: value },
} as OpenCodeEvent), } as OpenCodeEvent)
})
test("merges adjacent text deltas for the same message and ordinal", () => { test("merges adjacent text deltas for the same message and ordinal", () => {
const result = coalesceServerEvents([delta("evt_1", "hello "), delta("evt_2", "world")]) const result = coalesceServerEvents([delta("evt_1", "hello "), delta("evt_2", "world")])
expect(result).toHaveLength(1) expect(result).toHaveLength(1)
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } }) expect(result[0]?.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
expect(result[0]?.payload.properties).toMatchObject({ delta: "hello world" }) expect(result[0]?.properties).toMatchObject({ delta: "hello world" })
}) })
test("coalesces current tool input deltas by tool ID", () => { test("coalesces current tool input deltas by tool ID", () => {
@@ -74,26 +72,19 @@ describe("current event buffering", () => {
data: { sessionID: "ses", assistantMessageID: "msg", id, delta }, data: { sessionID: "ses", assistantMessageID: "msg", id, delta },
} as OpenCodeEvent) } as OpenCodeEvent)
const result = coalesceServerEvents([ const result = coalesceServerEvents([
{ directory: "/repo", payload: current("evt_1", "call_1", "{") }, current("evt_1", "call_1", "{"),
{ directory: "/repo", payload: current("evt_2", "call_1", "}") }, current("evt_2", "call_1", "}"),
{ directory: "/repo", payload: current("evt_3", "call_2", "[]") }, current("evt_3", "call_2", "[]"),
]) ])
expect(result).toHaveLength(2) expect(result).toHaveLength(2)
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { id: "call_1", delta: "{}" } }) expect(result[0]?.current).toMatchObject({ id: "evt_2", data: { id: "call_1", delta: "{}" } })
expect(result[1]?.payload.current).toMatchObject({ id: "evt_3", data: { id: "call_2", delta: "[]" } }) expect(result[1]?.current).toMatchObject({ id: "evt_3", data: { id: "call_2", delta: "[]" } })
}) })
test("preserves boundaries between distinct delta streams", () => { test("preserves boundaries between distinct delta streams", () => {
const events = [delta("evt_1", "a"), delta("evt_2", "b", 1), delta("evt_3", "c")] const events = [delta("evt_1", "a"), delta("evt_2", "b", 1), delta("evt_3", "c")]
expect(coalesceServerEvents(events).map((event) => event.payload.current?.id)).toEqual(["evt_1", "evt_2", "evt_3"]) expect(coalesceServerEvents(events).map((event) => event.current?.id)).toEqual(["evt_1", "evt_2", "evt_3"])
})
test("preserves current event order when enqueuing", () => {
const events: Parameters<typeof enqueueServerEvent>[0] = []
;[delta("evt_1", "a"), delta("evt_2", "b", 1)].forEach((event) => enqueueServerEvent(events, event))
expect(events.map((event) => event.payload.current?.id)).toEqual(["evt_1", "evt_2"])
}) })
}) })
+31 -29
View File
@@ -12,7 +12,7 @@ import { ServerScope } from "@/utils/server-scope"
import { useServer } from "./server" import { useServer } from "./server"
export type ServerEvent = Event & { id?: string; current?: OpenCodeEvent } export type ServerEvent = Event & { id?: string; current?: OpenCodeEvent }
type QueuedServerEvent = { directory: string; payload: ServerEvent } type ServerEventMap = { [Type in ServerEvent["type"]]: Extract<ServerEvent, { type: Type }> }
type CurrentDelta = Extract< type CurrentDelta = Extract<
OpenCodeEvent, OpenCodeEvent,
{ type: "session.text.delta" | "session.reasoning.delta" | "session.tool.input.delta" | "session.compaction.delta" } { type: "session.text.delta" | "session.reasoning.delta" | "session.tool.input.delta" | "session.compaction.delta" }
@@ -22,22 +22,17 @@ export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
} }
export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServerEvent) { export function coalesceServerEvents(events: ServerEvent[]) {
queue.push(event) const output: ServerEvent[] = []
return true
}
export function coalesceServerEvents(events: QueuedServerEvent[]) {
const output: QueuedServerEvent[] = []
events.forEach((event) => { events.forEach((event) => {
const current = currentDelta(event.payload.current) const current = currentDelta(event.current)
if (current) { if (current) {
const previous = output[output.length - 1] const previous = output[output.length - 1]
const prior = currentDelta(previous?.payload.current) const prior = currentDelta(previous?.current)
if ( if (
previous && previous &&
prior && prior &&
previous.directory === event.directory && prior.location?.directory === current.location?.directory &&
currentDeltaKey(prior) === currentDeltaKey(current) currentDeltaKey(prior) === currentDeltaKey(current)
) { ) {
const fragment = currentDeltaFragment(prior) + currentDeltaFragment(current) const fragment = currentDeltaFragment(prior) + currentDeltaFragment(current)
@@ -46,13 +41,10 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) {
? { ...current.data, text: fragment } ? { ...current.data, text: fragment }
: { ...current.data, delta: fragment } : { ...current.data, delta: fragment }
output[output.length - 1] = { output[output.length - 1] = {
directory: event.directory, ...event,
payload: { properties: data,
...event.payload, current: { ...current, data } as CurrentDelta,
properties: data, } as ServerEvent
current: { ...current, data } as CurrentDelta,
} as ServerEvent,
}
return return
} }
output.push(event) output.push(event)
@@ -89,7 +81,8 @@ export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: ()
start() start()
} }
type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<{ [key: string]: ServerEvent }>> type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<ServerEventMap>>
type ServerLocationEventEmitter = ReturnType<typeof createGlobalEmitter<{ [directory: string]: ServerEvent }>>
export type ServerConnectionStatus = "connecting" | "connected" | "reconnecting" export type ServerConnectionStatus = "connecting" | "connected" | "reconnecting"
type ServerSDKBase = { type ServerSDKBase = {
server: ServerConnection.Any server: ServerConnection.Any
@@ -104,6 +97,9 @@ type ServerSDKBase = {
event: { event: {
on: ServerEventEmitter["on"] on: ServerEventEmitter["on"]
listen: ServerEventEmitter["listen"] listen: ServerEventEmitter["listen"]
location: {
on: ServerLocationEventEmitter["on"]
}
} }
} }
@@ -123,18 +119,16 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
})() })()
const eventApi = createApiForServer({ server: server.http, fetch: eventFetch }) const eventApi = createApiForServer({ server: server.http, fetch: eventFetch })
const emitter = createGlobalEmitter<{ const emitter = createGlobalEmitter<ServerEventMap>()
[key: string]: ServerEvent const locations = createGlobalEmitter<{ [directory: string]: ServerEvent }>()
}>()
type Queued = QueuedServerEvent
const FLUSH_FRAME_MS = 16 const FLUSH_FRAME_MS = 16
const STREAM_YIELD_MS = 8 const STREAM_YIELD_MS = 8
const CONNECT_TIMEOUT_MS = 2_000 const CONNECT_TIMEOUT_MS = 2_000
const RECONNECT_DELAY_MS = 1_000 const RECONNECT_DELAY_MS = 1_000
let queue: Queued[] = [] let queue: ServerEvent[] = []
let buffer: Queued[] = [] let buffer: ServerEvent[] = []
let timer: ReturnType<typeof setTimeout> | undefined let timer: ReturnType<typeof setTimeout> | undefined
let last = 0 let last = 0
@@ -152,7 +146,11 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
last = Date.now() last = Date.now()
const output = coalesceServerEvents(events) const output = coalesceServerEvents(events)
batch(() => { batch(() => {
output.forEach((event) => emitter.emit(event.directory, event.payload)) output.forEach((event) => {
emitter.emit(event.type, event)
const directory = event.current?.location?.directory
if (directory) locations.emit(directory, event)
})
}) })
buffer.length = 0 buffer.length = 0
@@ -165,8 +163,8 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
} }
function publish(event: OpenCodeEvent) { function publish(event: OpenCodeEvent) {
const directory = event.location?.directory ?? "global" queue.push(adaptServerEvent(event))
if (enqueueServerEvent(queue, { directory, payload: adaptServerEvent(event) })) schedule() schedule()
} }
function wait(delay: number, signal: AbortSignal) { function wait(delay: number, signal: AbortSignal) {
@@ -313,6 +311,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
queue = [] queue = []
buffer = [] buffer = []
emitter.clear() emitter.clear()
locations.clear()
}) })
const api = createApiForServer({ server: server.http, fetch: platform.fetch }) const api = createApiForServer({ server: server.http, fetch: platform.fetch })
@@ -330,6 +329,9 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
event: { event: {
on: emitter.on.bind(emitter), on: emitter.on.bind(emitter),
listen: emitter.listen.bind(emitter), listen: emitter.listen.bind(emitter),
location: {
on: locations.on.bind(locations),
},
}, },
} }
} }
@@ -365,7 +367,7 @@ export type DirectorySDK = {
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): DirectorySDK { function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): DirectorySDK {
const emitter = createGlobalEmitter<SDKEventMap>() const emitter = createGlobalEmitter<SDKEventMap>()
const unsub = serverSDK.event.on(directory, (event) => { const unsub = serverSDK.event.location.on(directory, (event) => {
emitter.emit(event.type, event) emitter.emit(event.type, event)
}) })
onCleanup(unsub) onCleanup(unsub)
@@ -6,6 +6,32 @@ const event = (input: object) => input as OpenCodeEvent
const base = { created: 1, location: { directory: "/repo" }, durable: { aggregateID: "ses_1", seq: 1, version: 1 } } const base = { created: 1, location: { directory: "/repo" }, durable: { aggregateID: "ses_1", seq: 1, version: 1 } }
describe("v2 session reducer", () => { describe("v2 session reducer", () => {
test("moves a repeated inbox payload to the current event position", () => {
const reducer = createV2SessionReducer()
const result = reducer.reduce(
[
{ id: "msg_user", type: "user", text: "local", time: { created: 0 } },
{ id: "msg_agent", type: "agent-switched", agent: "review", time: { created: 1 } },
],
event({
...base,
id: "evt_admitted",
type: "session.inbox.enqueued",
data: {
sessionID: "ses_1",
inboxID: "msg_user",
item: { type: "user", delivery: "steer", payload: { text: "durable" } },
},
}),
)
expect(result?.messages).toEqual([
{ id: "msg_agent", type: "agent-switched", agent: "review", time: { created: 1 } },
{ id: "msg_user", type: "user", text: "durable", time: { created: 1 } },
])
expect(result?.touched).toEqual(["msg_user"])
})
test("projects promoted input and streaming assistant content", () => { test("projects promoted input and streaming assistant content", () => {
const reducer = createV2SessionReducer() const reducer = createV2SessionReducer()
let messages: SessionMessageInfo[] = [] let messages: SessionMessageInfo[] = []
@@ -1,4 +1,10 @@
import type { OpenCodeEvent, SessionInboxItem, SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise" import type {
OpenCodeEvent,
SessionInboxInfo,
SessionInboxItem,
SessionInfo,
SessionMessageInfo,
} from "@opencode-ai/client/promise"
type Assistant = Extract<SessionMessageInfo, { type: "assistant" }> type Assistant = Extract<SessionMessageInfo, { type: "assistant" }>
type Compaction = Extract<SessionMessageInfo, { type: "compaction" }> type Compaction = Extract<SessionMessageInfo, { type: "compaction" }>
@@ -29,12 +35,14 @@ export function createV2SessionReducer() {
}) })
const append = (message: SessionMessageInfo) => const append = (message: SessionMessageInfo) =>
result(source.some((item) => item.id === message.id) ? [...source] : [...source, message], [message.id]) result(source.some((item) => item.id === message.id) ? [...source] : [...source, message], [message.id])
const replace = (message: SessionMessageInfo) =>
result([...source.filter((item) => item.id !== message.id), message], [message.id])
switch (event.type) { switch (event.type) {
case "session.inbox.enqueued": case "session.inbox.enqueued":
pending.set(key(sessionID, event.data.inboxID), event.data.item) pending.set(key(sessionID, event.data.inboxID), event.data.item)
if (event.data.item.type === "user") if (event.data.item.type === "user")
return append({ return replace({
id: event.data.inboxID, id: event.data.inboxID,
type: "user", type: "user",
metadata: event.data.item.payload.metadata, metadata: event.data.item.payload.metadata,
@@ -44,7 +52,7 @@ export function createV2SessionReducer() {
time: { created: event.created }, time: { created: event.created },
}) })
if (event.data.item.type !== "synthetic") return result([...source]) if (event.data.item.type !== "synthetic") return result([...source])
return append({ return replace({
id: event.data.inboxID, id: event.data.inboxID,
type: "synthetic", type: "synthetic",
metadata: event.data.item.payload.metadata, metadata: event.data.item.payload.metadata,
@@ -480,6 +488,9 @@ export function createV2SessionReducer() {
return { return {
reduce, reduce,
confirm(item: SessionInboxInfo) {
pending.set(key(item.sessionID, item.id), item)
},
clear(sessionID: string) { clear(sessionID: string) {
for (const id of pending.keys()) { for (const id of pending.keys()) {
if (id.startsWith(`${sessionID}:`)) pending.delete(id) if (id.startsWith(`${sessionID}:`)) pending.delete(id)
+427 -265
View File
@@ -185,6 +185,16 @@ const textPart = (messageID: string, input: Partial<TextPart> = {}): TextPart =>
id: `${messageID}:text:${input.id === "pending" ? 1 : 0}`, id: `${messageID}:text:${input.id === "pending" ? 1 : 0}`,
}) })
const promptEcho = (messageID: string, text = "hello") => ({
sessionID: "child",
messageID,
text,
displayText: text,
agent: "build",
model: { providerID: "provider", modelID: "model" },
comments: [],
})
const response = (data: MessageResponse["data"] = [], cursor?: string): MessageResponse => ({ const response = (data: MessageResponse["data"] = [], cursor?: string): MessageResponse => ({
data, data,
response: { headers: new Headers(cursor ? { "x-next-cursor": cursor } : undefined) }, response: { headers: new Headers(cursor ? { "x-next-cursor": cursor } : undefined) },
@@ -299,6 +309,26 @@ function setup(sessions: Record<string, SessionInfo>) {
} }
describe("server session", () => { describe("server session", () => {
test("hydrates session info after a native session.created event", async () => {
const ctx = setup({ created: session("created") })
ctx.store.apply({
type: "session.created",
properties: {
sessionID: "created",
projectID: "project",
location: { directory: "/repo" },
slug: "created",
version: "test",
},
})
expect(ctx.store.get("created")).toBeUndefined()
await ctx.store.resolve("created")
expect(ctx.store.get("created")?.location.directory).toBe("/repo")
expect(ctx.get).toEqual([{ sessionID: "created" }])
})
test("projects V2 session events into current and legacy message state", () => { test("projects V2 session events into current and legacy message state", () => {
const ctx = setup({ child: session("child") }) const ctx = setup({ child: session("child") })
ctx.store.remember(session("child")) ctx.store.remember(session("child"))
@@ -340,14 +370,38 @@ describe("server session", () => {
location: { directory: "/repo" }, location: { directory: "/repo" },
data: { sessionID: "child", assistantMessageID: "msg_2_assistant", ordinal: 0, delta: "world" }, data: { sessionID: "child", assistantMessageID: "msg_2_assistant", ordinal: 0, delta: "world" },
}) })
apply({
id: "evt_tool_z",
created: 5,
type: "session.tool.input.started",
durable: { aggregateID: "child", seq: 3, version: 1 },
location: { directory: "/repo" },
data: { sessionID: "child", assistantMessageID: "msg_2_assistant", id: "call_z", name: "shell" },
})
apply({
id: "evt_tool_a",
created: 6,
type: "session.tool.input.started",
durable: { aggregateID: "child", seq: 4, version: 1 },
location: { directory: "/repo" },
data: { sessionID: "child", assistantMessageID: "msg_2_assistant", id: "call_a", name: "shell" },
})
expect(ctx.store.data.session_message.child?.at(-1)).toMatchObject({ expect(ctx.store.data.session_message.child?.at(-1)).toMatchObject({
id: "msg_2_assistant", id: "msg_2_assistant",
type: "assistant", type: "assistant",
content: [{ type: "text", text: "world" }], content: [
{ type: "text", text: "world" },
{ type: "tool", id: "call_z" },
{ type: "tool", id: "call_a" },
],
}) })
expect(ctx.store.data.message.child?.map((message) => message.id)).toEqual(["msg_1_user", "msg_2_assistant"]) expect(ctx.store.data.message.child?.map((message) => message.id)).toEqual(["msg_1_user", "msg_2_assistant"])
expect(ctx.store.data.part.msg_2_assistant).toMatchObject([{ type: "text", text: "world" }]) expect(ctx.store.data.part.msg_2_assistant?.map((part) => part.id)).toEqual([
"msg_2_assistant:text:0",
"call_z",
"call_a",
])
}) })
test("projects V2 pending inputs and forms", () => { test("projects V2 pending inputs and forms", () => {
@@ -564,7 +618,7 @@ describe("server session", () => {
await ctx.store.sync("root") await ctx.store.sync("root")
expect(ctx.get).toEqual([{ sessionID: "root" }]) expect(ctx.get).toEqual([{ sessionID: "root" }])
expect(ctx.messages).toEqual([{ sessionID: "root", limit: 20, order: "desc" }]) expect(ctx.messages).toEqual([{ sessionID: "root", limit: 200, order: "desc" }])
expect(ctx.store.data.message.root).toEqual([]) expect(ctx.store.data.message.root).toEqual([])
}) })
@@ -575,8 +629,32 @@ describe("server session", () => {
ctx.store.invalidate() ctx.store.invalidate()
await ctx.store.sync("root") await ctx.store.sync("root")
expect(ctx.store.data.message.root).toEqual([])
expect(ctx.get).toHaveLength(2) expect(ctx.get).toHaveLength(2)
expect(ctx.messages).toHaveLength(2) expect(ctx.messages).toEqual([
{ sessionID: "root", limit: 200, order: "desc" },
{ sessionID: "root", limit: 200, order: "desc" },
])
})
test("keeps a fixed page size after the local message cache exceeds the API limit", async () => {
const client = messageClient(response(), response())
const store = createServerSession(client)
await store.sync("child")
Array.from({ length: 428 }, (_, index) =>
store.apply({
type: "message.updated",
properties: { info: userMessage(`message-${index}`, { time: { created: index } }) },
}),
)
expect(store.data.message.child).toHaveLength(428)
await store.sync("child", { force: true })
expect(client.requests).toEqual([
{ sessionID: "child", limit: 200, order: "desc" },
{ sessionID: "child", limit: 200, order: "desc" },
])
}) })
test("loads current session content through the current message API", async () => { test("loads current session content through the current message API", async () => {
@@ -601,11 +679,50 @@ describe("server session", () => {
await store.sync("root") await store.sync("root")
expect(requests).toEqual([{ sessionID: "root", limit: 20, order: "desc" }]) expect(requests).toEqual([{ sessionID: "root", limit: 200, order: "desc" }])
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id]) expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
expect(store.data.message.root.map((message) => message.id)).toEqual([user.id, assistant.id]) expect(store.data.message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
}) })
test("preserves assistant content order from message history", async () => {
const source = [
{ id: "msg_user", type: "user", text: "inspect it", time: { created: 1 } },
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [
{ type: "text", text: "I will inspect it." },
{
type: "tool",
id: "call_z",
name: "shell",
state: { status: "streaming", input: "" },
time: { created: 2 },
},
{
type: "tool",
id: "call_a",
name: "shell",
state: { status: "streaming", input: "" },
time: { created: 3 },
},
],
time: { created: 2 },
},
] satisfies SessionMessageInfo[]
const messageApi = {
list: async () => ({ data: source.toReversed(), cursor: { previous: null, next: null } }),
} as unknown as MessageApi
const store = createServerSession({} as SessionApi, messageApi)
store.remember(session("root"))
await store.sync("root")
expect(store.data.part.msg_assistant?.map((part) => part.id)).toEqual(["msg_assistant:text:0", "call_z", "call_a"])
})
test("extends a current page to include the user for split assistant turns", async () => { test("extends a current page to include the user for split assistant turns", async () => {
const user = { id: "msg_1_user", type: "user", text: "hello", time: { created: 1 } } as const const user = { id: "msg_1_user", type: "user", text: "hello", time: { created: 1 } } as const
const assistant = (id: string, created: number) => ({ const assistant = (id: string, created: number) => ({
@@ -638,8 +755,8 @@ describe("server session", () => {
await store.sync("root") await store.sync("root")
expect(requests).toEqual([ expect(requests).toEqual([
{ sessionID: "root", limit: 20, order: "desc" }, { sessionID: "root", limit: 200, order: "desc" },
{ sessionID: "root", limit: 20, cursor: "older" }, { sessionID: "root", limit: 200, cursor: "older" },
]) ])
expect(store.data.message.root.map((message) => message.id)).toEqual([ expect(store.data.message.root.map((message) => message.id)).toEqual([
user.id, user.id,
@@ -648,6 +765,26 @@ describe("server session", () => {
expect(assistants.map((item) => store.data.part[item.id]?.[0]?.type)).toEqual(["text", "text", "text"]) expect(assistants.map((item) => store.data.part[item.id]?.[0]?.type)).toEqual(["text", "text", "text"])
}) })
test("loads older messages by cursor with the fixed page size", async () => {
const older = userMessage("message-1")
const latest = userMessage("message-2", { time: { created: 2 } })
const client = messageClient(
response([{ info: latest, parts: [] }], "older"),
response([{ info: older, parts: [] }]),
)
const store = createServerSession(client)
await store.sync("child")
await store.history.loadMore("child")
expect(client.requests).toEqual([
{ sessionID: "child", limit: 200, order: "desc" },
{ sessionID: "child", limit: 200, cursor: "older" },
])
expect(store.data.message.child).toEqual([older, latest])
expect(store.history.more("child")).toBe(false)
})
// V2 messages are ordered projections and do not expose V1 assistant parent IDs. // V2 messages are ordered projections and do not expose V1 assistant parent IDs.
describe.skip("V1 assistant parent projections", () => { describe.skip("V1 assistant parent projections", () => {
test("backfills an assistant-only initial page through its user root", async () => { test("backfills an assistant-only initial page through its user root", async () => {
@@ -661,7 +798,7 @@ describe("server session", () => {
await store.sync("child") await store.sync("child")
expect(client.requests).toEqual([{ sessionID: "child", limit: 20, order: "desc" }]) expect(client.requests).toEqual([{ sessionID: "child", limit: 200, order: "desc" }])
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }]) expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }])
expect(store.data.message.child).toEqual([user, ...assistants]) expect(store.data.message.child).toEqual([user, ...assistants])
expect(store.history.more("child")).toBe(false) expect(store.history.more("child")).toBe(false)
@@ -710,19 +847,17 @@ describe("server session", () => {
expect(store.data.part[parent.id]).toBeUndefined() expect(store.data.part[parent.id]).toBeUndefined()
}) })
test("does not let an optimistic user suppress initial root backfill", async () => { test("does not let an admitted user suppress initial root backfill", async () => {
const user = userMessage("message-1") const user = userMessage("message-1")
const part = textPart(user.id)
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)] const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
const client = rootMessageClient( const client = rootMessageClient(
[response(assistants.map((info) => ({ info, parts: [] })))], [response(assistants.map((info) => ({ info, parts: [] })))],
[singleResponse(user)], [singleResponse(user)],
) )
const store = createServerSession(client) const store = createServerSession(client)
store.optimistic.add({ sessionID: "child", message: user, parts: [part] }) store.inbox.echo(promptEcho(user.id, "text"))
await store.sync("child") await store.sync("child")
store.optimistic.remove({ sessionID: "child", messageID: user.id })
expect(client.requests).toHaveLength(1) expect(client.requests).toHaveLength(1)
expect(client.rootRequests).toHaveLength(1) expect(client.rootRequests).toHaveLength(1)
@@ -783,28 +918,6 @@ describe("server session", () => {
expect(store.data.part[stale.id]).toEqual([freshPart]) expect(store.data.part[stale.id]).toEqual([freshPart])
}) })
test("refreshes a confirmed optimistic parent while preserving pending parts", async () => {
const stale = userMessage("message-1", { summary: { title: "stale", diffs: [] } })
const fresh = { ...stale, summary: { title: "fresh", diffs: [] } }
const confirmed = textPart(stale.id, { id: "confirmed", text: "stale" })
const refreshed = { ...confirmed, text: "fresh" }
const pending = textPart(stale.id, { id: "pending", text: "pending" })
const assistant = assistantMessage("message-2", stale.id)
const client = rootMessageClient(
[response([{ info: stale, parts: [confirmed] }]), response([{ info: assistant, parts: [] }])],
[singleResponse(fresh, [refreshed])],
)
const store = createServerSession(client)
store.optimistic.add({ sessionID: "child", message: stale, parts: [confirmed, pending] })
await store.sync("child")
await store.sync("child", { force: true })
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }])
expect(store.data.message.child).toEqual([fresh, assistant])
expect(store.data.part[stale.id]).toEqual([refreshed, pending])
})
test("uses a parent received by SSE during the replacement load", async () => { test("uses a parent received by SSE during the replacement load", async () => {
const pending = deferredResponse() const pending = deferredResponse()
const user = userMessage("message-1") const user = userMessage("message-1")
@@ -1040,30 +1153,6 @@ describe("server session", () => {
expect(store.data.part[message.id]).toBeUndefined() expect(store.data.part[message.id]).toBeUndefined()
}) })
test("preserves optimistic parts re-added after removal during a refresh", async () => {
const pending = deferredResponse()
const message = userMessage("message")
const stale = textPart(message.id, { id: "stale", text: "stale" })
const part = textPart(message.id, { id: "optimistic", text: "optimistic" })
const store = createServerSession(
messageClient(response([{ info: message, parts: [] }]), pending.promise, response()),
)
await store.sync("child")
const refreshing = store.sync("child", { force: true })
store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
store.optimistic.add({ sessionID: "child", message, parts: [part] })
pending.resolve(response([{ info: message, parts: [stale] }]))
await refreshing
expect(store.data.message.child).toEqual([message])
expect(store.data.part[message.id]).toEqual([part])
await store.sync("child", { force: true })
expect(store.data.message.child).toEqual([message])
expect(store.data.part[message.id]).toEqual([part])
})
test("drops stale event content omitted by a complete initial page", async () => { test("drops stale event content omitted by a complete initial page", async () => {
const stale = userMessage("stale") const stale = userMessage("stale")
const store = createServerSession(messageClient(response())) const store = createServerSession(messageClient(response()))
@@ -1085,170 +1174,309 @@ describe("server session", () => {
expect(store.data.message.child).toEqual([live, fetched]) expect(store.data.message.child).toEqual([live, fetched])
}) })
test("does not restore removed optimistic content on refresh", async () => { test("echoes a prompt without changing durable message order", () => {
const message = userMessage("message")
const part = textPart(message.id, { text: "removed" })
const kept = { ...message, id: "kept" }
const keptPart = { ...part, id: "kept-part", messageID: kept.id }
const store = createServerSession(messageClient(response([{ info: kept, parts: [] }])))
store.optimistic.add({ sessionID: "child", message, parts: [part] })
store.optimistic.add({ sessionID: "child", message: kept, parts: [keptPart] })
store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
store.apply({
type: "message.part.removed",
properties: { sessionID: "child", messageID: kept.id, partID: keptPart.id },
})
await store.sync("child", { force: true })
expect(store.data.message.child).toEqual([kept])
expect(store.data.part[message.id]).toBeUndefined()
expect(store.data.part[kept.id]).toBeUndefined()
})
test("replaces confirmed optimistic content with the initial page", async () => {
const optimistic = userMessage("message")
const fetched = { ...optimistic, time: { created: 2 } }
const store = createServerSession(messageClient(response([{ info: fetched, parts: [] }])))
store.optimistic.add({ sessionID: "child", message: optimistic, parts: [] })
await store.sync("child")
expect(store.data.message.child).toEqual([fetched])
})
test("replaces a confirmed optimistic part with fetched content", async () => {
const pending = deferredResponse()
const message = userMessage("message")
const optimistic = textPart(message.id, { text: "optimistic" })
const fetched = { ...optimistic, text: "fetched" }
const store = createServerSession(messageClient(pending.promise))
const loading = store.sync("child")
store.optimistic.add({ sessionID: "child", message, parts: [optimistic] })
pending.resolve(response([{ info: message, parts: [fetched] }]))
await loading
expect(store.data.part[message.id]).toEqual([fetched])
})
test("rolls back only unconfirmed optimistic parts", async () => {
const pending = deferredResponse()
const message = userMessage("message")
const confirmed = textPart(message.id, { id: "confirmed", text: "confirmed" })
const pendingPart = textPart(message.id, { id: "pending", text: "pending" })
const store = createServerSession(messageClient(pending.promise))
const loading = store.sync("child")
store.optimistic.add({ sessionID: "child", message, parts: [confirmed, pendingPart] })
pending.resolve(response([{ info: message, parts: [confirmed] }]))
await loading
store.optimistic.remove({ sessionID: "child", messageID: message.id })
expect(store.data.message.child).toEqual([message])
expect(store.data.part[message.id]).toEqual([confirmed])
})
test("updates confirmed optimistic parts from later pages", async () => {
const message = userMessage("message")
const confirmed = textPart(message.id, { id: "confirmed", text: "first" })
const updated = { ...confirmed, text: "updated" }
const pendingPart = textPart(message.id, { id: "pending", text: "pending" })
const store = createServerSession(
messageClient(response([{ info: message, parts: [confirmed] }]), response([{ info: message, parts: [updated] }])),
)
store.optimistic.add({ sessionID: "child", message, parts: [confirmed, pendingPart] })
await store.sync("child")
await store.sync("child", { force: true })
store.optimistic.remove({ sessionID: "child", messageID: message.id })
expect(store.data.part[message.id]).toEqual([updated])
})
test("does not restore a confirmed optimistic part after its removal event", async () => {
const message = userMessage("message")
const confirmed = textPart(message.id, { id: "confirmed", text: "confirmed" })
const pendingPart = textPart(message.id, { id: "pending", text: "pending" })
const store = createServerSession(
messageClient(response([{ info: message, parts: [confirmed] }]), response([{ info: message, parts: [] }])),
)
store.optimistic.add({ sessionID: "child", message, parts: [confirmed, pendingPart] })
await store.sync("child")
store.apply({
type: "message.part.removed",
properties: { sessionID: "child", messageID: message.id, partID: confirmed.id },
})
await store.sync("child", { force: true })
expect(store.data.part[message.id]).toEqual([pendingPart])
})
test("clears delta buffers when removing optimistic content", () => {
const message = userMessage("message")
const part = textPart(message.id, { text: "optimistic" })
const store = setup({ child: session("child") }).store const store = setup({ child: session("child") }).store
store.optimistic.add({ sessionID: "child", message, parts: [part] })
store.apply({ store.inbox.echo({
type: "message.part.delta", ...promptEcho("msg_prompt"),
properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: " delta" }, text: "hello\nThe user made the following comment regarding line 4 of src/foo.ts: check this",
files: [{ uri: "file:///repo/src/foo.ts", mime: "text/plain", name: "foo.ts" }],
agents: [{ name: "explore" }],
comments: [
{
path: "src/foo.ts",
selection: { startLine: 4, startChar: 1, endLine: 4, endChar: 5 },
comment: "check this",
preview: "const value = 1",
origin: "review",
},
],
}) })
store.optimistic.remove({ sessionID: "child", messageID: message.id }) expect(store.data.pending.child).toMatchObject([{ id: "msg_prompt", type: "user", delivery: "steer" }])
expect(store.data.input.child).toEqual(["msg_prompt"])
expect(store.data.session_message.child).toBeUndefined()
expect(store.data.message.child?.map((message) => message.id)).toEqual(["msg_prompt"])
expect(store.data.part.msg_prompt).toMatchObject([
{ id: "msg_prompt:text:0", type: "text", text: "hello" },
{ id: "msg_prompt:file:0", type: "file", filename: "foo.ts" },
{ id: "msg_prompt:agent:0", type: "agent", name: "explore" },
{
id: "msg_prompt:comment:0",
type: "text",
synthetic: true,
metadata: {
opencodeComment: {
path: "src/foo.ts",
selection: { startLine: 4, startChar: 1, endLine: 4, endChar: 5 },
comment: "check this",
preview: "const value = 1",
origin: "review",
},
},
},
])
expect(store.data.part[message.id]).toBeUndefined() store.applyV2({
expect(store.data.part_text_accum_delta[part.id]).toBeUndefined() id: "evt_prompt",
created: 2,
type: "session.inbox.enqueued",
durable: { aggregateID: "child", seq: 1, version: 1 },
data: {
sessionID: "child",
inboxID: "msg_prompt",
item: {
type: "user",
delivery: "steer",
payload: {
text: "hello\nThe user made the following comment regarding line 4 of src/foo.ts: check this",
},
},
},
} as OpenCodeEvent)
expect(store.data.part.msg_prompt).toMatchObject([
{ id: "msg_prompt:text:0", type: "text", text: "hello" },
{ id: "msg_prompt:comment:0", type: "text", synthetic: true },
])
}) })
test("removes projected messages when rolling back optimistic content", () => { test("preserves a local echo while message history omits pending input", async () => {
const message = userMessage("message") const store = createServerSession(messageClient(response()))
const store = setup({ child: session("child") }).store store.inbox.echo(promptEcho("msg_prompt"))
store.optimistic.add({ sessionID: "child", message, parts: [] }) store.inbox.confirm({
id: "msg_prompt",
sessionID: "child",
timeCreated: 1,
type: "user",
delivery: "steer",
payload: { text: "hello" },
})
store.optimistic.remove({ sessionID: "child", messageID: message.id }) await store.sync("child")
expect(store.data.message.child?.map((message) => message.id)).toEqual(["msg_prompt"])
expect(store.data.part.msg_prompt).toMatchObject([{ type: "text", text: "hello" }])
})
test("preserves local comment presentation through message refresh", async () => {
const note = "The user made the following comment regarding line 4 of src/foo.ts: check this"
const message = userMessage("msg_prompt")
const store = createServerSession(
messageClient(response([{ info: message, parts: [textPart(message.id, { text: note })] }])),
)
store.inbox.echo({
...promptEcho(message.id),
text: `hello\n${note}`,
comments: [
{
path: "src/foo.ts",
selection: { startLine: 4, startChar: 1, endLine: 4, endChar: 5 },
comment: "check this",
origin: "review",
},
],
})
await store.sync("child")
expect(store.data.part.msg_prompt).toMatchObject([
{ id: "msg_prompt:text:0", type: "text", text: "hello" },
{ id: "msg_prompt:comment:0", type: "text", synthetic: true },
])
})
test("retires an admitted echo absent from authoritative reconnect state", async () => {
const store = createServerSession(messageClient(response()))
store.inbox.echo(promptEcho("msg_prompt"))
store.inbox.confirm({
id: "msg_prompt",
sessionID: "child",
timeCreated: 1,
type: "user",
delivery: "steer",
payload: { text: "hello" },
})
await Promise.all([store.sync("child"), store.hydrateTransient("child", async () => ({ pending: [], forms: [] }))])
store.inbox.reconcile("child")
expect(store.data.pending.child).toEqual([])
expect(store.data.message.child).toEqual([])
expect(store.data.part.msg_prompt).toBeUndefined()
})
test("retires a stale enqueued message when inbox hydration finishes after history", async () => {
const store = createServerSession(messageClient(response()))
store.applyV2({
id: "evt_prompt",
created: 1,
type: "session.inbox.enqueued",
durable: { aggregateID: "child", seq: 1, version: 1 },
data: {
sessionID: "child",
inboxID: "msg_prompt",
item: { type: "user", delivery: "steer", payload: { text: "hello" } },
},
} as OpenCodeEvent)
await store.sync("child")
await store.hydrateTransient("child", async () => ({ pending: [], forms: [] }))
store.inbox.reconcile("child")
expect(store.data.pending.child).toEqual([])
expect(store.data.session_message.child).toEqual([]) expect(store.data.session_message.child).toEqual([])
expect(store.data.message.child).toEqual([])
expect(store.data.part.msg_prompt).toBeUndefined()
}) })
test("does not remove content confirmed by a message event", () => { test("deduplicates the durable admission event against its local echo", () => {
const message = userMessage("message")
const part = textPart(message.id)
const store = setup({ child: session("child") }).store const store = setup({ child: session("child") }).store
store.optimistic.add({ sessionID: "child", message, parts: [part] }) store.inbox.echo(promptEcho("msg_prompt"))
store.apply({ type: "message.updated", properties: { sessionID: "child", info: message } })
store.optimistic.remove({ sessionID: "child", messageID: message.id }) store.applyV2({
id: "evt_prompt",
created: 2,
type: "session.inbox.enqueued",
durable: { aggregateID: "child", seq: 1, version: 1 },
data: {
sessionID: "child",
inboxID: "msg_prompt",
item: { type: "user", delivery: "steer", payload: { text: "hello" } },
},
} as OpenCodeEvent)
expect(store.data.message.child).toEqual([message]) expect(store.data.pending.child).toHaveLength(1)
expect(store.data.part[message.id]).toBeUndefined() expect(store.data.input.child).toEqual(["msg_prompt"])
expect(store.data.session_message.child?.filter((message) => message.id === "msg_prompt")).toHaveLength(1)
expect(store.data.message.child?.filter((message) => message.id === "msg_prompt")).toHaveLength(1)
expect(store.data.part.msg_prompt).toMatchObject([{ type: "text", text: "hello" }])
}) })
test("does not remove parts confirmed by part events", () => { test("uses the prompt response when the admission event was missed", () => {
const message = userMessage("message")
const part = textPart(message.id)
const store = setup({ child: session("child") }).store const store = setup({ child: session("child") }).store
store.optimistic.add({ sessionID: "child", message, parts: [part] }) store.inbox.echo(promptEcho("msg_prompt"))
store.apply({ type: "message.updated", properties: { sessionID: "child", info: message } }) store.inbox.confirm({
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } }) id: "msg_prompt",
sessionID: "child",
timeCreated: 2,
type: "user",
delivery: "steer",
payload: { text: "hello" },
})
store.optimistic.remove({ sessionID: "child", messageID: message.id }) store.applyV2({
id: "evt_delivered",
created: Date.now() + 1,
type: "session.inbox.delivered",
durable: { aggregateID: "child", seq: 2, version: 1 },
data: { sessionID: "child", inboxID: "msg_prompt" },
} as OpenCodeEvent)
expect(store.data.message.child).toEqual([message]) expect(store.data.pending.child).toEqual([])
expect(store.data.part[message.id]).toEqual([part]) expect(store.data.input.child).toEqual([])
expect(store.data.session_message.child).toMatchObject([{ id: "msg_prompt", type: "user", text: "hello" }])
expect(store.data.message.child?.filter((message) => message.id === "msg_prompt")).toHaveLength(1)
expect(store.data.part.msg_prompt).toMatchObject([{ type: "text", text: "hello" }])
}) })
test("treats a part event as confirmation when it precedes the message event", () => { test("keeps a durable admission when the HTTP request later fails", () => {
const message = userMessage("message")
const part = textPart(message.id)
const store = setup({ child: session("child") }).store const store = setup({ child: session("child") }).store
store.optimistic.add({ sessionID: "child", message, parts: [part] }) store.inbox.echo(promptEcho("msg_prompt"))
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } }) store.applyV2({
id: "evt_prompt",
created: 2,
type: "session.inbox.enqueued",
durable: { aggregateID: "child", seq: 1, version: 1 },
data: {
sessionID: "child",
inboxID: "msg_prompt",
item: { type: "user", delivery: "steer", payload: { text: "hello" } },
},
} as OpenCodeEvent)
store.optimistic.remove({ sessionID: "child", messageID: message.id }) expect(store.inbox.clearEcho({ sessionID: "child", messageID: "msg_prompt" })).toBe(false)
expect(store.data.pending.child).toHaveLength(1)
expect(store.data.message.child?.map((message) => message.id)).toEqual(["msg_prompt"])
})
expect(store.data.message.child).toEqual([message]) test("places durable admission after delayed selection events", () => {
expect(store.data.part[message.id]).toEqual([part]) const store = setup({ child: session("child") }).store
store.remember(session("child"))
store.inbox.echo(promptEcho("msg_prompt"))
store.applyV2({
id: "evt_agent",
created: 1,
type: "session.agent.selected",
durable: { aggregateID: "child", seq: 1, version: 1 },
data: { sessionID: "child", agent: "review" },
} as OpenCodeEvent)
store.applyV2({
id: "evt_model",
created: 2,
type: "session.model.selected",
durable: { aggregateID: "child", seq: 2, version: 1 },
data: { sessionID: "child", model: { id: "new-model", providerID: "new-provider" } },
} as OpenCodeEvent)
store.applyV2({
id: "evt_prompt",
created: 3,
type: "session.inbox.enqueued",
durable: { aggregateID: "child", seq: 3, version: 1 },
data: {
sessionID: "child",
inboxID: "msg_prompt",
item: { type: "user", delivery: "steer", payload: { text: "hello" } },
},
} as OpenCodeEvent)
expect(store.data.session_message.child?.map((message) => message.type)).toEqual([
"agent-switched",
"model-switched",
"user",
])
expect(store.data.message.child?.find((message) => message.id === "msg_prompt")).toMatchObject({
agent: "review",
model: { providerID: "new-provider", modelID: "new-model" },
})
})
test("removes an echoed prompt when submission fails", () => {
const store = setup({ child: session("child") }).store
store.inbox.echo(promptEcho("msg_prompt"))
expect(store.inbox.clearEcho({ sessionID: "child", messageID: "msg_prompt" })).toBe(true)
expect(store.data.pending.child).toEqual([])
expect(store.data.input.child).toEqual([])
expect(store.data.session_message.child).toBeUndefined()
expect(store.data.message.child).toEqual([])
expect(store.data.part.msg_prompt).toBeUndefined()
})
test("removes a response-confirmed echo when the server cancels it", () => {
const store = setup({ child: session("child") }).store
store.inbox.echo(promptEcho("msg_prompt"))
store.inbox.confirm({
id: "msg_prompt",
sessionID: "child",
timeCreated: 1,
type: "user",
delivery: "steer",
payload: { text: "hello" },
})
store.applyV2({
id: "evt_cancelled",
created: 2,
type: "session.inbox.cancelled",
durable: { aggregateID: "child", seq: 2, version: 1 },
data: { sessionID: "child", inboxID: "msg_prompt" },
} as OpenCodeEvent)
expect(store.data.pending.child).toEqual([])
expect(store.data.message.child).toEqual([])
expect(store.data.part.msg_prompt).toBeUndefined()
}) })
test("clears stale parts when the initial page has none", async () => { test("clears stale parts when the initial page has none", async () => {
@@ -1469,28 +1697,6 @@ describe("server session", () => {
expect(store.data.part[message.id]).toBeUndefined() expect(store.data.part[message.id]).toBeUndefined()
}) })
test("preserves optimistic re-adds across message retries", async () => {
const failed = Promise.withResolvers<MessageResponse>()
const retried = Promise.withResolvers<MessageResponse>()
const message = userMessage("message")
const stale = textPart(message.id, { id: "stale", text: "stale" })
const optimistic = textPart(message.id, { id: "optimistic", text: "optimistic" })
const client = messageClient(response([{ info: message, parts: [stale] }]), failed.promise, retried.promise)
const store = createServerSession(client, { retry: retryImmediately })
await store.sync("child")
const loading = store.sync("child", { force: true })
store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
store.optimistic.add({ sessionID: "child", message, parts: [optimistic] })
failed.reject(new Error("failed to fetch"))
await client.requested(3)
retried.resolve(response([{ info: message, parts: [stale] }]))
await loading
expect(store.data.message.child).toEqual([message])
expect(store.data.part[message.id]).toEqual([optimistic])
})
test("accepts part omission from a successful retry after an earlier delta", async () => { test("accepts part omission from a successful retry after an earlier delta", async () => {
const failed = Promise.withResolvers<MessageResponse>() const failed = Promise.withResolvers<MessageResponse>()
const retried = Promise.withResolvers<MessageResponse>() const retried = Promise.withResolvers<MessageResponse>()
@@ -1654,33 +1860,6 @@ describe("server session", () => {
expect(store.data.part[message.id]).toBeUndefined() expect(store.data.part[message.id]).toBeUndefined()
}) })
test("does not cache skipped optimistic parts", () => {
const message = userMessage("message")
const part = { id: "part", sessionID: "child", messageID: message.id, type: "step-start" as const }
const store = setup({ child: session("child") }).store
store.optimistic.add({ sessionID: "child", message, parts: [part] })
expect(store.data.part[message.id]).toEqual([])
})
test("clears stale delta buffers when replacing optimistic parts", () => {
const message = userMessage("message")
const stale = textPart(message.id, { id: "stale", text: "stale" })
const optimistic = textPart(message.id, { id: "optimistic", text: "optimistic" })
const store = setup({ child: session("child") }).store
store.optimistic.add({ sessionID: "child", message, parts: [stale] })
store.apply({
type: "message.part.delta",
properties: { sessionID: "child", messageID: message.id, partID: stale.id, field: "text", delta: " delta" },
})
store.optimistic.add({ sessionID: "child", message, parts: [optimistic] })
expect(store.data.part_text_accum_delta[stale.id]).toBeUndefined()
expect(store.data.part_text_accum_delta[optimistic.id]).toBeUndefined()
})
test("preserves removals during history prepend", async () => { test("preserves removals during history prepend", async () => {
const pending = deferredResponse() const pending = deferredResponse()
const latest = userMessage("message-2", { time: { created: 2 } }) const latest = userMessage("message-2", { time: { created: 2 } })
@@ -1906,24 +2085,7 @@ describe("server session", () => {
test("preserves pinned session content under server-wide cache pressure", () => { test("preserves pinned session content under server-wide cache pressure", () => {
const ctx = setup({}) const ctx = setup({})
ctx.store.pin("active") ctx.store.pin("active")
ctx.store.optimistic.add({ ctx.store.inbox.echo({ ...promptEcho("message", "keep"), sessionID: "active" })
sessionID: "active",
message: {
id: "message",
sessionID: "active",
role: "assistant",
time: { created: 1 },
parentID: "parent",
modelID: "model",
providerID: "provider",
mode: "build",
agent: "agent",
path: { cwd: "/repo", root: "/repo" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
},
parts: [],
})
for (let index = 0; index < 50; index++) { for (let index = 0; index < 50; index++) {
ctx.store.remember(session(`session-${index}`)) ctx.store.remember(session(`session-${index}`))
+274 -301
View File
@@ -18,41 +18,22 @@ import { compareMessages, messageKey, normalizeSessionMessages } from "@/utils/s
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache" import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
import { createV2SessionReducer, type V2SessionReduction } from "./server-session-v2-reducer" import { createV2SessionReducer, type V2SessionReduction } from "./server-session-v2-reducer"
import type { ServerApi } from "@/utils/server" import type { ServerApi } from "@/utils/server"
import {
createCommentMetadata,
formatCommentNote,
parseCommentNote,
readCommentMetadata,
type PromptComment,
} from "@/utils/comment-note"
type MessageApi = ServerApi["message"] type MessageApi = ServerApi["message"]
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const initialMessagePageSize = 20 const messagePageSize = 200
const historyMessagePageSize = 200
const sessionInfoLimit = 2_048 const sessionInfoLimit = 2_048
const emptyIDs: ReadonlySet<string> = new Set() const emptyIDs: ReadonlySet<string> = new Set()
function projectMessageSource(message: Message): SessionMessageInfo[] {
if (message.role === "user") {
return [
{ id: `${message.id}:agent`, type: "agent-switched", agent: message.agent, time: message.time },
{
id: `${message.id}:model`,
type: "model-switched",
model: { id: message.model.modelID, providerID: message.model.providerID, variant: message.model.variant },
time: message.time,
},
{ id: message.id, type: "user", text: "", time: message.time },
]
}
return [
{
id: message.id,
type: "assistant",
agent: message.agent ?? message.mode,
model: { id: message.modelID, providerID: message.providerID, variant: message.variant },
content: [],
time: message.time,
},
]
}
function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) { function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) {
const boundary = source.find( const boundary = source.find(
(message) => (message) =>
@@ -64,13 +45,6 @@ function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) {
return boundary?.type === "assistant" return boundary?.type === "assistant"
} }
type OptimisticItem = {
message: Message
parts: Part[]
confirmedParts?: Part[]
confirmedMessage?: boolean
}
type MessagePage = { type MessagePage = {
session: Message[] session: Message[]
part: { id: string; part: Part[] }[] part: { id: string; part: Part[] }[]
@@ -81,6 +55,18 @@ type MessagePage = {
complete: boolean complete: boolean
} }
export type PromptEcho = {
sessionID: string
messageID: string
text: string
displayText: string
agent: string
model: { providerID: string; modelID: string; variant?: string }
files?: { uri: string; mime: string; name?: string; mention?: { start: number; end: number; text: string } }[]
agents?: { name: string; mention?: { start: number; end: number; text: string } }[]
comments: PromptComment[]
}
// Most markers describe the current HTTP attempt; deltaParts persists non-durable stream state across retries. // Most markers describe the current HTTP attempt; deltaParts persists non-durable stream state across retries.
type MessageLoadState = { type MessageLoadState = {
touchedMessages: Set<string> touchedMessages: Set<string>
@@ -90,7 +76,6 @@ type MessageLoadState = {
deltaParts: Map<string, Set<string>> deltaParts: Map<string, Set<string>>
carriedDeltaParts: Map<string, Set<string>> carriedDeltaParts: Map<string, Set<string>>
removedParts: Map<string, Set<string>> removedParts: Map<string, Set<string>>
optimisticParts: Map<string, Set<string>>
orphanParents: Set<string> orphanParents: Set<string>
clearedMessageParts: Set<string> clearedMessageParts: Set<string>
touchedSource: Set<string> touchedSource: Set<string>
@@ -101,34 +86,6 @@ type MessageLoadBaseline = Pick<
"touchedMessages" | "retainedMessages" | "touchedParts" | "clearedMessageParts" "touchedMessages" | "retainedMessages" | "touchedParts" | "clearedMessageParts"
> >
function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
if (items.length === 0) return { ...page, observed: [] as { messageID: string; parts: Part[] }[] }
const session = [...page.session]
const part = new Map(page.part.map((item) => [item.id, item.part]))
const observed: { messageID: string; parts: Part[] }[] = []
for (const item of items) {
const result = Binary.search(session, messageKey(item.message), messageKey)
const found = result.found
if (!found) session.splice(result.index, 0, item.message)
const current = part.get(item.message.id)
const confirmed = found ? item.parts.filter((part) => current?.some((value) => value.id === part.id)) : []
if (found) observed.push({ messageID: item.message.id, parts: confirmed })
part.set(
item.message.id,
merge(
found ? (current ?? []) : merge(item.confirmedParts ?? [], current ?? []),
item.parts.filter((part) => !confirmed.includes(part)),
),
)
}
return {
...page,
session,
part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, parts]) => ({ id, part: parts })),
observed,
}
}
function runInflight(map: Map<string, Promise<void>>, key: string, task: () => Promise<void>) { function runInflight(map: Map<string, Promise<void>>, key: string, task: () => Promise<void>) {
const pending = map.get(key) const pending = map.get(key)
if (pending) return pending if (pending) return pending
@@ -203,6 +160,7 @@ export function createServerSession(
input: {} as Record<string, string[]>, input: {} as Record<string, string[]>,
message: {} as Record<string, Message[]>, message: {} as Record<string, Message[]>,
session_message: {} as Record<string, SessionMessageInfo[]>, session_message: {} as Record<string, SessionMessageInfo[]>,
// Part order is semantic and follows SessionMessageAssistant.content; IDs identify parts only.
part: {} as Record<string, Part[]>, part: {} as Record<string, Part[]>,
part_text_accum_delta: {} as Record<string, string>, part_text_accum_delta: {} as Record<string, string>,
session_working(id: string) { session_working(id: string) {
@@ -212,7 +170,6 @@ export function createServerSession(
const requests = new Map<string, Promise<SessionInfo>>() const requests = new Map<string, Promise<SessionInfo>>()
const inflight = new Map<string, Promise<void>>() const inflight = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>() const inflightTodo = new Map<string, Promise<void>>()
const optimistic = new Map<string, Map<string, OptimisticItem>>()
const v2 = createV2SessionReducer() const v2 = createV2SessionReducer()
const pendingRevision = new Map<string, number>() const pendingRevision = new Map<string, number>()
const formRevision = new Map<string, number>() const formRevision = new Map<string, number>()
@@ -223,7 +180,45 @@ export function createServerSession(
const pendingParts = new Map<string, Map<string, Set<string>>>() const pendingParts = new Map<string, Map<string, Set<string>>>()
const orphanParts = new Map<string, Set<string>>() const orphanParts = new Map<string, Set<string>>()
const removedMessages = new Map<string, Set<string>>() const removedMessages = new Map<string, Set<string>>()
const echoes = new Map<string, Map<string, "sending" | "admitted">>()
const messageSnapshots = new Map<string, Set<string>>()
const settledInputs = new Map<string, Set<string>>()
const deltaBases = new Map<string, { base: string; sessionID: string }>() const deltaBases = new Map<string, { base: string; sessionID: string }>()
const markEcho = (sessionID: string, messageID: string) => {
const messages = echoes.get(sessionID) ?? new Map<string, "sending" | "admitted">()
messages.set(messageID, "sending")
echoes.set(sessionID, messages)
}
const confirmEcho = (sessionID: string, messageID: string) => {
const messages = echoes.get(sessionID)
if (!messages?.has(messageID)) return false
messages.set(messageID, "admitted")
return true
}
const releaseEcho = (sessionID: string, messageID: string) => {
const messages = echoes.get(sessionID)
const state = messages?.get(messageID)
if (!messages || !state) return
messages.delete(messageID)
if (messages.size === 0) echoes.delete(sessionID)
return state
}
const present = (messageID: string, parts: Part[]) => {
const local = data.part[messageID] ?? []
const comments = local.filter(
(part) =>
part.type === "text" &&
part.synthetic &&
(readCommentMetadata(part.metadata) !== undefined || parseCommentNote(part.text) !== undefined),
)
if (!comments.length) return parts
const text = local.find((part) => part.type === "text" && !part.synthetic)
const projected = parts.flatMap((part) => {
if (part.id !== `${messageID}:text:0` || part.type !== "text") return [part]
return text?.type === "text" && text.text ? [{ ...part, text: text.text }] : []
})
return [...projected, ...comments]
}
const deleteMessageParts = ( const deleteMessageParts = (
cache: { part: Record<string, Part[] | undefined>; part_text_accum_delta: Record<string, string | undefined> }, cache: { part: Record<string, Part[] | undefined>; part_text_accum_delta: Record<string, string | undefined> },
messageID: string, messageID: string,
@@ -246,25 +241,12 @@ export function createServerSession(
return created return created
} }
const [meta, setMeta] = createStore({ const [meta, setMeta] = createStore({
limit: {} as Record<string, number | undefined>,
cursor: {} as Record<string, string | undefined>, cursor: {} as Record<string, string | undefined>,
complete: {} as Record<string, boolean | undefined>, complete: {} as Record<string, boolean | undefined>,
loading: {} as Record<string, boolean | undefined>, loading: {} as Record<string, boolean | undefined>,
at: {} as Record<string, number | undefined>, at: {} as Record<string, number | undefined>,
}) })
const indexProjectedMessage = (message: Message) => {
const current = data.session_message[message.sessionID] ?? []
if (current.some((item) => item.id === message.id)) return
const projected = projectMessageSource(message)
const projectedIDs = new Set(projected.map((item) => item.id))
setData(
"session_message",
message.sessionID,
reconcile([...current.filter((item) => !projectedIDs.has(item.id)), ...projected]),
)
}
const remember = (session: SessionInfo) => { const remember = (session: SessionInfo) => {
setData("info", session.id, reconcile(session)) setData("info", session.id, reconcile(session))
infoSeen.delete(session.id) infoSeen.delete(session.id)
@@ -276,7 +258,7 @@ export function createServerSession(
...inflight.keys(), ...inflight.keys(),
...inflightTodo.keys(), ...inflightTodo.keys(),
...messageLoads.keys(), ...messageLoads.keys(),
...optimistic.keys(), ...echoes.keys(),
...Object.entries(data.permission) ...Object.entries(data.permission)
.filter(([, items]) => items.length > 0) .filter(([, items]) => items.length > 0)
.map(([sessionID]) => sessionID), .map(([sessionID]) => sessionID),
@@ -352,65 +334,6 @@ export function createServerSession(
return { session, root } return { session, root }
} }
const clearOptimistic = (sessionID: string, messageID?: string) => {
if (!messageID) {
optimistic.delete(sessionID)
return
}
const items = optimistic.get(sessionID)
if (!items) return
items.delete(messageID)
if (items.size === 0) optimistic.delete(sessionID)
}
const clearOptimisticPart = (sessionID: string, messageID: string, partID: string) => {
const items = optimistic.get(sessionID)
const item = items?.get(messageID)
if (!items || !item) return
const parts = item.parts.filter((part) => part.id !== partID)
const confirmedParts = item.confirmedParts?.filter((part) => part.id !== partID)
if (parts.length === 0) {
clearOptimistic(sessionID, messageID)
return
}
items.set(messageID, { ...item, parts, confirmedParts, confirmedMessage: true })
}
const confirmOptimisticPart = (sessionID: string, messageID: string, part: Part) => {
const items = optimistic.get(sessionID)
const item = items?.get(messageID)
if (!items || !item) return
const parts = item.parts.filter((value) => value.id !== part.id)
if (parts.length === 0) {
clearOptimistic(sessionID, messageID)
return
}
items.set(messageID, {
...item,
parts,
confirmedParts: merge(item.confirmedParts ?? [], [part]),
confirmedMessage: true,
})
}
const confirmOptimistic = (sessionID: string, messageID: string, confirmedParts: Part[]) => {
const items = optimistic.get(sessionID)
const item = items?.get(messageID)
if (!items || !item) return
const confirmed = new Set(confirmedParts.map((part) => part.id))
const parts = item.parts.filter((part) => !confirmed.has(part.id))
if (parts.length === 0) {
clearOptimistic(sessionID, messageID)
return
}
items.set(messageID, {
...item,
parts,
confirmedParts: merge(item.confirmedParts ?? [], confirmedParts),
confirmedMessage: true,
})
}
const trackPartChange = (sessionID: string, messageID: string, partID: string) => { const trackPartChange = (sessionID: string, messageID: string, partID: string) => {
const load = messageLoads.get(sessionID) const load = messageLoads.get(sessionID)
if (!load) return if (!load) return
@@ -448,14 +371,6 @@ export function createServerSession(
const messages = data.message[sessionID] const messages = data.message[sessionID]
if (messages?.some((message) => message.id === messageID)) load.retainedMessages.add(messageID) if (messages?.some((message) => message.id === messageID)) load.retainedMessages.add(messageID)
} }
for (const [messageID, parts] of load.optimisticParts) {
load.removedMessages.delete(messageID)
load.clearedMessageParts.add(messageID)
load.touchedMessages.add(messageID)
const touched = load.touchedParts.get(messageID) ?? new Set<string>()
parts.forEach((partID) => touched.add(partID))
load.touchedParts.set(messageID, touched)
}
baseline?.touchedMessages.forEach((messageID) => load.touchedMessages.add(messageID)) baseline?.touchedMessages.forEach((messageID) => load.touchedMessages.add(messageID))
baseline?.retainedMessages.forEach((messageID) => load.retainedMessages.add(messageID)) baseline?.retainedMessages.forEach((messageID) => load.retainedMessages.add(messageID))
baseline?.clearedMessageParts.forEach((messageID) => load.clearedMessageParts.add(messageID)) baseline?.clearedMessageParts.forEach((messageID) => load.clearedMessageParts.add(messageID))
@@ -486,7 +401,9 @@ export function createServerSession(
sessionIDs.forEach((sessionID) => { sessionIDs.forEach((sessionID) => {
messageHydrationRevision.set(sessionID, (messageHydrationRevision.get(sessionID) ?? 0) + 1) messageHydrationRevision.set(sessionID, (messageHydrationRevision.get(sessionID) ?? 0) + 1)
generations.delete(sessionID) generations.delete(sessionID)
clearOptimistic(sessionID) echoes.delete(sessionID)
messageSnapshots.delete(sessionID)
settledInputs.delete(sessionID)
requests.delete(sessionID) requests.delete(sessionID)
inflight.delete(sessionID) inflight.delete(sessionID)
inflightTodo.delete(sessionID) inflightTodo.delete(sessionID)
@@ -504,7 +421,6 @@ export function createServerSession(
setMeta( setMeta(
produce((draft) => { produce((draft) => {
for (const sessionID of sessionIDs) { for (const sessionID of sessionIDs) {
delete draft.limit[sessionID]
delete draft.cursor[sessionID] delete draft.cursor[sessionID]
delete draft.complete[sessionID] delete draft.complete[sessionID]
delete draft.loading[sessionID] delete draft.loading[sessionID]
@@ -521,7 +437,7 @@ export function createServerSession(
...inflight.keys(), ...inflight.keys(),
...inflightTodo.keys(), ...inflightTodo.keys(),
...messageLoads.keys(), ...messageLoads.keys(),
...optimistic.keys(), ...echoes.keys(),
...Object.entries(data.permission) ...Object.entries(data.permission)
.filter(([, items]) => items.length > 0) .filter(([, items]) => items.length > 0)
.map(([sessionID]) => sessionID), .map(([sessionID]) => sessionID),
@@ -538,11 +454,13 @@ export function createServerSession(
pickSessionCacheEvictions({ seen, keep: sessionID, limit: SESSION_CACHE_LIMIT, preserve: protectedSessions() }), pickSessionCacheEvictions({ seen, keep: sessionID, limit: SESSION_CACHE_LIMIT, preserve: protectedSessions() }),
) )
const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => { const fetchMessages = async (sessionID: string, before?: string, onAttempt?: () => void) => {
const request = (cursor?: string) => const request = (cursor?: string) =>
(options?.retry ?? retry)(() => { (options?.retry ?? retry)(() => {
onAttempt?.() onAttempt?.()
return messageApi.list(cursor ? { sessionID, limit, cursor } : { sessionID, limit, order: "desc" }) return messageApi.list(
cursor ? { sessionID, limit: messagePageSize, cursor } : { sessionID, limit: messagePageSize, order: "desc" },
)
}) })
const first = await request(before) const first = await request(before)
const pages = [first] const pages = [first]
@@ -556,9 +474,7 @@ export function createServerSession(
const normalized = normalizeSessionMessages(sessionID, source) const normalized = normalizeSessionMessages(sessionID, source)
return { return {
session: normalized.messages.sort(compareMessages), session: normalized.messages.sort(compareMessages),
part: [...normalized.parts.entries()] part: [...normalized.parts.entries()].map(([id, part]) => ({ id, part })).sort((a, b) => cmp(a.id, b.id)),
.map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) }))
.sort((a, b) => cmp(a.id, b.id)),
source, source,
sourceMode: before ? ("older" as const) : ("latest" as const), sourceMode: before ? ("older" as const) : ("latest" as const),
projectSource: true, projectSource: true,
@@ -598,9 +514,10 @@ export function createServerSession(
) => { ) => {
for (const item of items) { for (const item of items) {
if (!messageIDs.has(item.id)) continue if (!messageIDs.has(item.id)) continue
const fetched = load?.clearedMessageParts.has(item.id) const fetched = present(
? [] item.id,
: item.part.filter((part) => !SKIP_PARTS.has(part.type)) load?.clearedMessageParts.has(item.id) ? [] : item.part.filter((part) => !SKIP_PARTS.has(part.type)),
)
const fetchedIDs = new Set(fetched.map((part) => part.id)) const fetchedIDs = new Set(fetched.map((part) => part.id))
const pending = pendingParts.get(sessionID)?.get(item.id) const pending = pendingParts.get(sessionID)?.get(item.id)
const touched = new Set([...(load?.touchedParts.get(item.id) ?? []), ...(pending ?? [])]) const touched = new Set([...(load?.touchedParts.get(item.id) ?? []), ...(pending ?? [])])
@@ -651,47 +568,56 @@ export function createServerSession(
preserveUnfetched: boolean | ((message: Message) => boolean), preserveUnfetched: boolean | ((message: Message) => boolean),
cleanupOrphans: boolean, cleanupOrphans: boolean,
) => { ) => {
if (page.sourceMode === "latest")
messageSnapshots.set(sessionID, new Set((page.source ?? []).map((message) => message.id)))
page.source?.forEach((message) => releaseEcho(sessionID, message.id))
const source = page.source const source = page.source
? (() => { ? (() => {
const incoming = new Map(page.source.map((message) => [message.id, message])) const incoming = new Map(page.source.map((message) => [message.id, message]))
const existing = data.session_message[sessionID] ?? [] const existing = data.session_message[sessionID] ?? []
const boundary = Math.min(...page.source.map((message) => message.time.created)) const boundary = Math.min(...page.source.map((message) => message.time.created))
const inbox = new Set(data.input[sessionID] ?? [])
const current = existing.filter( const current = existing.filter(
(message) => (message) =>
!incoming.has(message.id) && !incoming.has(message.id) &&
!inbox.has(message.id) &&
(page.sourceMode === "older" || (page.sourceMode === "older" ||
load?.touchedSource.has(message.id) || load?.touchedSource.has(message.id) ||
(!page.complete && message.time.created < boundary)), (!page.complete && message.time.created < boundary)),
) )
// message.list never returns admitted-but-undelivered inbox entries; keep them after the
// fetched history until a delivered or cancelled event resolves them.
const admitted = existing.filter((message) => !incoming.has(message.id) && inbox.has(message.id))
const combined =
page.sourceMode === "older"
? [...page.source, ...current, ...admitted]
: [...current, ...page.source, ...admitted]
const live = new Map(existing.map((message) => [message.id, message])) const live = new Map(existing.map((message) => [message.id, message]))
return (page.sourceMode === "older" ? [...page.source, ...current] : [...current, ...page.source]).map( return combined.map((message) =>
(message) => (load?.touchedSource.has(message.id) ? (live.get(message.id) ?? message) : message), load?.touchedSource.has(message.id) ? (live.get(message.id) ?? message) : message,
) )
})() })()
: undefined : undefined
const projected = const merged =
page.projectSource && source page.projectSource && source
? (() => { ? (() => {
const normalized = normalizeSessionMessages(sessionID, source) const normalized = normalizeSessionMessages(sessionID, source)
return { return {
...page, ...page,
session: normalized.messages.sort(compareMessages), session: normalized.messages.sort(compareMessages),
part: [...normalized.parts.entries()] part: [...normalized.parts.entries()].map(([id, part]) => ({ id, part })).sort((a, b) => cmp(a.id, b.id)),
.map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) }))
.sort((a, b) => cmp(a.id, b.id)),
} }
})() })()
: page : page
const merged = mergeOptimisticPage(projected, [...(optimistic.get(sessionID)?.values() ?? [])])
merged.observed.forEach((item) => {
if (!load?.clearedMessageParts.has(item.messageID)) confirmOptimistic(sessionID, item.messageID, item.parts)
})
const touchedMessages = new Set([...(load?.touchedMessages ?? []), ...(removedMessages.get(sessionID) ?? [])]) const touchedMessages = new Set([...(load?.touchedMessages ?? []), ...(removedMessages.get(sessionID) ?? [])])
const messages = reconcileFetched(merged.session, data.message[sessionID] ?? [], { const messages = reconcileFetched(merged.session, data.message[sessionID] ?? [], {
touched: touchedMessages, touched: touchedMessages,
retained: load?.retainedMessages, retained: load?.retainedMessages,
removed: load?.removedMessages, removed: load?.removedMessages,
preserveUnfetched, preserveUnfetched: (message) =>
echoes.get(sessionID)?.has(message.id) === true ||
preserveUnfetched === true ||
(typeof preserveUnfetched === "function" && preserveUnfetched(message)),
compare: compareMessages, compare: compareMessages,
}) })
batch(() => { batch(() => {
@@ -705,14 +631,13 @@ export function createServerSession(
} }
orphanParts.delete(sessionID) orphanParts.delete(sessionID)
} }
setMeta("limit", sessionID, messages.length)
setMeta("cursor", sessionID, merged.cursor) setMeta("cursor", sessionID, merged.cursor)
setMeta("complete", sessionID, merged.complete) setMeta("complete", sessionID, merged.complete)
setMeta("at", sessionID, Date.now()) setMeta("at", sessionID, Date.now())
}) })
} }
const loadMessages = async (sessionID: string, limit: number, before?: string, mode?: "replace" | "prepend") => { const loadMessages = async (sessionID: string, before?: string, mode?: "replace" | "prepend") => {
if (meta.loading[sessionID]) return if (meta.loading[sessionID]) return
const active = generation(sessionID) const active = generation(sessionID)
const load: MessageLoadState = { const load: MessageLoadState = {
@@ -723,7 +648,6 @@ export function createServerSession(
deltaParts: new Map(), deltaParts: new Map(),
carriedDeltaParts: new Map(), carriedDeltaParts: new Map(),
removedParts: new Map(), removedParts: new Map(),
optimisticParts: new Map(),
orphanParents: new Set(), orphanParents: new Set(),
clearedMessageParts: new Set(), clearedMessageParts: new Set(),
touchedSource: new Set(), touchedSource: new Set(),
@@ -732,7 +656,7 @@ export function createServerSession(
setMeta("loading", sessionID, true) setMeta("loading", sessionID, true)
let applied = false let applied = false
try { try {
const page = await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load)) const page = await fetchMessages(sessionID, before, () => resetMessageLoad(sessionID, load))
const first = page.session.reduce<Message | undefined>( const first = page.session.reduce<Message | undefined>(
(oldest, message) => (!oldest || compareMessages(message, oldest) < 0 ? message : oldest), (oldest, message) => (!oldest || compareMessages(message, oldest) < 0 ? message : oldest),
undefined, undefined,
@@ -744,11 +668,7 @@ export function createServerSession(
const users = new Set([ const users = new Set([
...page.session.filter((message) => message.role === "user").map((message) => message.id), ...page.session.filter((message) => message.role === "user").map((message) => message.id),
...(data.message[sessionID] ?? []) ...(data.message[sessionID] ?? [])
.filter((message) => { .filter((message) => message.role === "user" && load.touchedMessages.has(message.id))
if (message.role !== "user") return false
const item = optimistic.get(sessionID)?.get(message.id)
return load.touchedMessages.has(message.id) && (!item || item.confirmedMessage === true)
})
.map((message) => message.id), .map((message) => message.id),
]) ])
const parentIDs = [ const parentIDs = [
@@ -815,32 +735,30 @@ export function createServerSession(
} }
} }
const sync = (sessionID: string, options?: { force?: boolean; messageLimit?: number }) => { const sync = (sessionID: string, options?: { force?: boolean }) => {
touch(sessionID) touch(sessionID)
return runInflight(inflight, sessionID, async () => { return runInflight(inflight, sessionID, async () => {
const cached = data.message[sessionID] !== undefined && meta.limit[sessionID] !== undefined const cached = data.message[sessionID] !== undefined && meta.complete[sessionID] !== undefined
const invalid = invalidated.has(sessionID) const invalid = invalidated.has(sessionID)
const revision = invalidationRevision const revision = invalidationRevision
if (cached && data.info[sessionID] && !invalid && !options?.force) return if (cached && data.info[sessionID] && !invalid && !options?.force) return
await Promise.all([ await Promise.all([
resolve(sessionID, invalid ? { ...options, force: true } : options), resolve(sessionID, invalid ? { ...options, force: true } : options),
cached && !invalid && !options?.force cached && !invalid && !options?.force ? Promise.resolve() : loadMessages(sessionID),
? Promise.resolve()
: loadMessages(sessionID, options?.messageLimit ?? meta.limit[sessionID] ?? initialMessagePageSize),
]) ])
if (invalid && invalidationRevision === revision) invalidated.delete(sessionID) if (invalid && invalidationRevision === revision) invalidated.delete(sessionID)
}) })
} }
const prefetch = async (sessionID: string, limit: number) => { const prefetch = async (sessionID: string, messageCount: number) => {
touch(sessionID) touch(sessionID)
await inflight.get(sessionID) await inflight.get(sessionID)
if ( if (
Date.now() - (meta.at[sessionID] ?? 0) <= 15_000 && Date.now() - (meta.at[sessionID] ?? 0) <= 15_000 &&
(meta.complete[sessionID] || (data.message[sessionID]?.length ?? 0) >= limit) (meta.complete[sessionID] || (data.message[sessionID]?.length ?? 0) >= messageCount)
) )
return return
await runInflight(inflight, sessionID, () => loadMessages(sessionID, limit)) await runInflight(inflight, sessionID, () => loadMessages(sessionID))
} }
const eventSessionID = (event: { type: string; properties?: unknown }) => { const eventSessionID = (event: { type: string; properties?: unknown }) => {
@@ -893,12 +811,12 @@ export function createServerSession(
apply({ type: "message.updated", properties: { sessionID: reduction.sessionID, info: message } }) apply({ type: "message.updated", properties: { sessionID: reduction.sessionID, info: message } })
} }
for (const messageID of touched) { for (const messageID of touched) {
const next = normalized.parts.get(messageID) ?? [] const next = present(messageID, normalized.parts.get(messageID) ?? [])
const nextIDs = new Set(next.map((part) => part.id)) const nextIDs = new Set(next.map((part) => part.id))
for (const part of next) { for (const part of next) {
apply({ type: "message.part.updated", properties: { sessionID: reduction.sessionID, part } }) apply({ type: "message.part.updated", properties: { sessionID: reduction.sessionID, part } })
} }
for (const part of data.part[messageID] ?? []) { for (const part of [...(data.part[messageID] ?? [])]) {
if (nextIDs.has(part.id)) continue if (nextIDs.has(part.id)) continue
apply({ apply({
type: "message.part.removed", type: "message.part.removed",
@@ -926,6 +844,67 @@ export function createServerSession(
.catch(() => {}) .catch(() => {})
} }
const removeEcho = (sessionID: string, messageID: string) => {
if (!releaseEcho(sessionID, messageID)) return false
pendingRevision.set(sessionID, (pendingRevision.get(sessionID) ?? 0) + 1)
const load = messageLoads.get(sessionID)
load?.touchedMessages.add(messageID)
load?.removedMessages.add(messageID)
load?.clearedMessageParts.add(messageID)
batch(() => {
setData("pending", sessionID, (items) => items?.filter((item) => item.id !== messageID))
setData("input", sessionID, (items) => items?.filter((id) => id !== messageID))
setData("message", sessionID, (messages) => messages?.filter((message) => message.id !== messageID))
setData(produce((draft) => deleteMessageParts(draft, messageID)))
})
return true
}
const confirmInbox = (item: SessionInboxInfo) => {
if (!confirmEcho(item.sessionID, item.id)) return false
v2.confirm(item)
pendingRevision.set(item.sessionID, (pendingRevision.get(item.sessionID) ?? 0) + 1)
const current = data.pending[item.sessionID] ?? []
const index = current.findIndex((entry) => entry.id === item.id)
if (index < 0) setData("pending", item.sessionID, [...current, item])
if (index >= 0) setData("pending", item.sessionID, index, reconcile(item))
return true
}
const reconcileInbox = (sessionID: string) => {
const pending = new Set((data.pending[sessionID] ?? []).map((item) => item.id))
const fetched = messageSnapshots.get(sessionID) ?? new Set<string>()
const removed = [...(settledInputs.get(sessionID) ?? [])].filter(
(messageID) => !pending.has(messageID) && !fetched.has(messageID),
)
settledInputs.delete(sessionID)
if (removed.length) {
const ids = new Set(removed)
const source = data.session_message[sessionID] ?? []
projectV2({
sessionID,
messages: source.filter((message) => !ids.has(message.id)),
touched: [],
removed: source.filter((message) => ids.has(message.id)).map((message) => message.id),
})
}
const messages = echoes.get(sessionID)
if (!messages) return
const projected = new Set((data.session_message[sessionID] ?? []).map((message) => message.id))
for (const [messageID, state] of messages) {
if (projected.has(messageID)) {
releaseEcho(sessionID, messageID)
continue
}
if (pending.has(messageID)) {
confirmEcho(sessionID, messageID)
continue
}
if (state === "admitted") removeEcho(sessionID, messageID)
}
}
const applyV2 = (event: OpenCodeEvent) => { const applyV2 = (event: OpenCodeEvent) => {
if (event.type === "form.created") { if (event.type === "form.created") {
formRevision.set(event.data.form.sessionID, (formRevision.get(event.data.form.sessionID) ?? 0) + 1) formRevision.set(event.data.form.sessionID, (formRevision.get(event.data.form.sessionID) ?? 0) + 1)
@@ -949,6 +928,9 @@ export function createServerSession(
} }
if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return
const sessionID = event.data.sessionID const sessionID = event.data.sessionID
if (event.type === "session.inbox.enqueued" || event.type === "session.inbox.delivered")
releaseEcho(sessionID, event.data.inboxID)
if (event.type === "session.inbox.cancelled") removeEcho(sessionID, event.data.inboxID)
if ( if (
event.type === "session.inbox.enqueued" || event.type === "session.inbox.enqueued" ||
event.type === "session.inbox.delivery.changed" || event.type === "session.inbox.delivery.changed" ||
@@ -960,11 +942,10 @@ export function createServerSession(
pendingRevision.set(sessionID, (pendingRevision.get(sessionID) ?? 0) + 1) pendingRevision.set(sessionID, (pendingRevision.get(sessionID) ?? 0) + 1)
if (event.type === "session.inbox.enqueued") { if (event.type === "session.inbox.enqueued") {
const current = data.pending[sessionID] ?? [] const current = data.pending[sessionID] ?? []
if (!current.some((item) => item.id === event.data.inboxID)) const item = { id: event.data.inboxID, sessionID, timeCreated: event.created, ...event.data.item }
setData("pending", sessionID, [ const index = current.findIndex((entry) => entry.id === event.data.inboxID)
...current, if (index < 0) setData("pending", sessionID, [...current, item])
{ id: event.data.inboxID, sessionID, timeCreated: event.created, ...event.data.item }, if (index >= 0) setData("pending", sessionID, index, reconcile(item))
])
if (event.data.item.type !== "compaction" && !data.input[sessionID]?.includes(event.data.inboxID)) if (event.data.item.type !== "compaction" && !data.input[sessionID]?.includes(event.data.inboxID))
setData("input", sessionID, [...(data.input[sessionID] ?? []), event.data.inboxID]) setData("input", sessionID, [...(data.input[sessionID] ?? []), event.data.inboxID])
} }
@@ -1101,16 +1082,9 @@ export function createServerSession(
} }
case "message.updated": { case "message.updated": {
const info = (event.properties as { info: Message }).info const info = (event.properties as { info: Message }).info
indexProjectedMessage(info)
const load = messageLoads.get(info.sessionID) const load = messageLoads.get(info.sessionID)
load?.touchedMessages.add(info.id) load?.touchedMessages.add(info.id)
load?.removedMessages.delete(info.id) load?.removedMessages.delete(info.id)
const items = optimistic.get(info.sessionID)
const item = items?.get(info.id)
if (items && item) {
if (item.parts.length === 0) clearOptimistic(info.sessionID, info.id)
if (item.parts.length > 0) items.set(info.id, { ...item, confirmedMessage: true })
}
const orphans = orphanParts.get(info.sessionID) const orphans = orphanParts.get(info.sessionID)
orphans?.delete(info.id) orphans?.delete(info.id)
if (orphans?.size === 0) orphanParts.delete(info.sessionID) if (orphans?.size === 0) orphanParts.delete(info.sessionID)
@@ -1123,13 +1097,18 @@ export function createServerSession(
return return
} }
const result = Binary.search(messages, messageKey(info), messageKey) const result = Binary.search(messages, messageKey(info), messageKey)
if (result.found) setData("message", info.sessionID, result.index, reconcile(info)) if (result.found) {
if (!result.found) setData("message", info.sessionID, result.index, reconcile(info))
setData("message", info.sessionID, (value = []) => { return
const next = value.slice() }
next.splice(result.index, 0, info) // Delivery rewrites time.created, changing the sort key; reposition instead of duplicating.
return next setData("message", info.sessionID, (value = []) => {
}) const next = value.slice()
const moved = next.findIndex((message) => message.id === info.id)
if (moved >= 0) next.splice(moved, 1)
next.splice(moved >= 0 && moved < result.index ? result.index - 1 : result.index, 0, info)
return next
})
return return
} }
case "message.removed": { case "message.removed": {
@@ -1144,13 +1123,11 @@ export function createServerSession(
load?.deltaParts.delete(props.messageID) load?.deltaParts.delete(props.messageID)
load?.carriedDeltaParts.delete(props.messageID) load?.carriedDeltaParts.delete(props.messageID)
load?.removedParts.delete(props.messageID) load?.removedParts.delete(props.messageID)
load?.optimisticParts.delete(props.messageID)
pendingParts.get(props.sessionID)?.delete(props.messageID) pendingParts.get(props.sessionID)?.delete(props.messageID)
if (pendingParts.get(props.sessionID)?.size === 0) pendingParts.delete(props.sessionID) if (pendingParts.get(props.sessionID)?.size === 0) pendingParts.delete(props.sessionID)
const removedMessagesForSession = removedMessages.get(props.sessionID) ?? new Set<string>() const removedMessagesForSession = removedMessages.get(props.sessionID) ?? new Set<string>()
removedMessagesForSession.add(props.messageID) removedMessagesForSession.add(props.messageID)
removedMessages.set(props.sessionID, removedMessagesForSession) removedMessages.set(props.sessionID, removedMessagesForSession)
clearOptimistic(props.sessionID, props.messageID)
setData( setData(
produce((draft) => { produce((draft) => {
const messages = draft.message[props.sessionID] const messages = draft.message[props.sessionID]
@@ -1196,12 +1173,8 @@ export function createServerSession(
pending?.delete(part.id) pending?.delete(part.id)
if (pending?.size === 0) pendingParts.get(part.sessionID)?.delete(part.messageID) if (pending?.size === 0) pendingParts.get(part.sessionID)?.delete(part.messageID)
if (pendingParts.get(part.sessionID)?.size === 0) pendingParts.delete(part.sessionID) if (pendingParts.get(part.sessionID)?.size === 0) pendingParts.delete(part.sessionID)
const optimistic = load?.optimisticParts.get(part.messageID)
optimistic?.delete(part.id)
if (optimistic?.size === 0) load?.optimisticParts.delete(part.messageID)
deltaBases.delete(part.id) deltaBases.delete(part.id)
trackPartChange(part.sessionID, part.messageID, part.id) trackPartChange(part.sessionID, part.messageID, part.id)
confirmOptimisticPart(part.sessionID, part.messageID, part)
setData( setData(
"part_text_accum_delta", "part_text_accum_delta",
produce((draft) => void delete draft[part.id]), produce((draft) => void delete draft[part.id]),
@@ -1211,14 +1184,9 @@ export function createServerSession(
setData("part", part.messageID, [part]) setData("part", part.messageID, [part])
return return
} }
const result = Binary.search(parts, part.id, (item) => item.id) const index = parts.findIndex((item) => item.id === part.id)
if (result.found) setData("part", part.messageID, result.index, reconcile(part)) if (index >= 0) setData("part", part.messageID, index, reconcile(part))
if (!result.found) if (index < 0) setData("part", part.messageID, (value = []) => [...value, part])
setData("part", part.messageID, (value = []) => {
const next = value.slice()
next.splice(result.index, 0, part)
return next
})
return return
} }
case "message.part.removed": { case "message.part.removed": {
@@ -1240,20 +1208,16 @@ export function createServerSession(
const parts = load.removedParts.get(props.messageID) ?? new Set<string>() const parts = load.removedParts.get(props.messageID) ?? new Set<string>()
parts.add(props.partID) parts.add(props.partID)
load.removedParts.set(props.messageID, parts) load.removedParts.set(props.messageID, parts)
const optimistic = load.optimisticParts.get(props.messageID)
optimistic?.delete(props.partID)
if (optimistic?.size === 0) load.optimisticParts.delete(props.messageID)
} }
trackPartChange(props.sessionID, props.messageID, props.partID) trackPartChange(props.sessionID, props.messageID, props.partID)
clearOptimisticPart(props.sessionID, props.messageID, props.partID)
setData( setData(
produce((draft) => { produce((draft) => {
delete draft.part_text_accum_delta[props.partID] delete draft.part_text_accum_delta[props.partID]
deltaBases.delete(props.partID) deltaBases.delete(props.partID)
const parts = draft.part[props.messageID] const parts = draft.part[props.messageID]
if (!parts) return if (!parts) return
const result = Binary.search(parts, props.partID, (part) => part.id) const index = parts.findIndex((part) => part.id === props.partID)
if (result.found) parts.splice(result.index, 1) if (index >= 0) parts.splice(index, 1)
if (parts.length === 0) delete draft.part[props.messageID] if (parts.length === 0) delete draft.part[props.messageID]
}), }),
) )
@@ -1269,8 +1233,8 @@ export function createServerSession(
} }
const parts = data.part[props.messageID] const parts = data.part[props.messageID]
if (!parts) return if (!parts) return
const result = Binary.search(parts, props.partID, (part) => part.id) const index = parts.findIndex((part) => part.id === props.partID)
if (!result.found) return if (index < 0) return
trackPartChange(props.sessionID, props.messageID, props.partID) trackPartChange(props.sessionID, props.messageID, props.partID)
const load = messageLoads.get(props.sessionID) const load = messageLoads.get(props.sessionID)
if (load) { if (load) {
@@ -1282,7 +1246,7 @@ export function createServerSession(
if (carried?.size === 0) load.carriedDeltaParts.delete(props.messageID) if (carried?.size === 0) load.carriedDeltaParts.delete(props.messageID)
} }
const field = props.field as keyof (typeof parts)[number] const field = props.field as keyof (typeof parts)[number]
const current = parts[result.index]?.[field] const current = parts[index]?.[field]
if (!deltaBases.has(props.partID) && typeof current === "string") if (!deltaBases.has(props.partID) && typeof current === "string")
deltaBases.set(props.partID, { base: current, sessionID: props.sessionID }) deltaBases.set(props.partID, { base: current, sessionID: props.sessionID })
setData( setData(
@@ -1295,7 +1259,7 @@ export function createServerSession(
props.messageID, props.messageID,
produce((draft) => { produce((draft) => {
if (!draft) return if (!draft) return
const part = draft[result.index] const part = draft[index]
const field = props.field as keyof typeof part const field = props.field as keyof typeof part
;(part[field] as string) = ((part[field] as string | undefined) ?? "") + props.delta ;(part[field] as string) = ((part[field] as string | undefined) ?? "") + props.delta
}), }),
@@ -1354,25 +1318,30 @@ export function createServerSession(
while (true) { while (true) {
const pendingAt = pendingRevision.get(sessionID) ?? 0 const pendingAt = pendingRevision.get(sessionID) ?? 0
const formAt = formRevision.get(sessionID) ?? 0 const formAt = formRevision.get(sessionID) ?? 0
const previous = new Set(data.input[sessionID] ?? [])
const result = await load() const result = await load()
const pendingStable = (pendingRevision.get(sessionID) ?? 0) === pendingAt const pendingStable = (pendingRevision.get(sessionID) ?? 0) === pendingAt
const formStable = (formRevision.get(sessionID) ?? 0) === formAt const formStable = (formRevision.get(sessionID) ?? 0) === formAt
if (pendingStable) { if (pendingStable) {
const current = new Set(result.pending.filter((item) => item.type !== "compaction").map((item) => item.id))
const settled = settledInputs.get(sessionID) ?? new Set<string>()
previous.forEach((messageID) => {
if (!current.has(messageID)) settled.add(messageID)
})
if (settled.size) settledInputs.set(sessionID, settled)
result.pending.forEach(v2.confirm)
setData("pending", sessionID, reconcile(result.pending)) setData("pending", sessionID, reconcile(result.pending))
setData( setData("input", sessionID, reconcile([...current]))
"input",
sessionID,
reconcile(result.pending.filter((item) => item.type !== "compaction").map((item) => item.id)),
)
} }
if (formStable) setData("form", sessionID, reconcile(result.forms)) if (formStable) setData("form", sessionID, reconcile(result.forms))
if (pendingStable && formStable) return if (pendingStable && formStable) return
} }
}, },
refreshPinned(hydrateTransient: (sessionID: string) => Promise<void>) { refreshPinned(hydrateTransient: (sessionID: string) => Promise<void>) {
const sessions = [...pinned.keys()]
return Promise.all( return Promise.all(
[...pinned.keys()].flatMap((sessionID) => [sync(sessionID, { force: true }), hydrateTransient(sessionID)]), sessions.flatMap((sessionID) => [sync(sessionID, { force: true }), hydrateTransient(sessionID)]),
).then(() => undefined) ).then(() => sessions.forEach(reconcileInbox))
}, },
invalidate() { invalidate() {
invalidationRevision += 1 invalidationRevision += 1
@@ -1381,77 +1350,81 @@ export function createServerSession(
setMeta("at", {}) setMeta("at", {})
}, },
prefetch, prefetch,
shouldPrefetch(sessionID: string, limit: number) { shouldPrefetch(sessionID: string, messageCount: number) {
if (data.message[sessionID] === undefined) return true if (data.message[sessionID] === undefined) return true
if (Date.now() - (meta.at[sessionID] ?? 0) > 15_000) return true if (Date.now() - (meta.at[sessionID] ?? 0) > 15_000) return true
if (meta.complete[sessionID]) return false if (meta.complete[sessionID]) return false
return (meta.limit[sessionID] ?? 0) <= limit return (data.message[sessionID]?.length ?? 0) <= messageCount
}, },
fresh(sessionID: string, ttl: number) { fresh(sessionID: string, ttl: number) {
return Date.now() - (meta.at[sessionID] ?? 0) <= ttl return Date.now() - (meta.at[sessionID] ?? 0) <= ttl
}, },
optimistic: { inbox: {
add(input: { sessionID: string; message: Message; parts: Part[] }) { echo(input: PromptEcho) {
const parts = input.parts const created = Date.now()
.filter((part) => !!part?.id && !SKIP_PARTS.has(part.type)) const files = input.files?.map((file) => ({
.sort((a, b) => cmp(a.id, b.id)) data: "",
const load = messageLoads.get(input.sessionID) mime: file.mime,
if (load?.clearedMessageParts.has(input.message.id)) { source: { type: "uri" as const, uri: file.uri },
const touched = load.touchedParts.get(input.message.id) ?? new Set<string>() name: file.name,
parts.forEach((part) => touched.add(part.id)) mention: file.mention,
load.touchedParts.set(input.message.id, touched) }))
const item: SessionInboxInfo = {
id: input.messageID,
sessionID: input.sessionID,
timeCreated: created,
type: "user",
delivery: "steer",
payload: { text: input.text, files, agents: input.agents },
} }
if (load) { const projected = normalizeSessionMessages(input.sessionID, [
load.removedMessages.delete(input.message.id) { id: `${input.messageID}:agent`, type: "agent-switched", agent: input.agent, time: { created } },
load.optimisticParts.set(input.message.id, new Set(parts.map((part) => part.id))) {
} id: `${input.messageID}:model`,
const items = optimistic.get(input.sessionID) type: "model-switched",
const removedMessagesForSession = removedMessages.get(input.sessionID) model: {
removedMessagesForSession?.delete(input.message.id) id: input.model.modelID,
if (removedMessagesForSession?.size === 0) removedMessages.delete(input.sessionID) providerID: input.model.providerID,
if (items) items.set(input.message.id, { ...input, parts, confirmedParts: [] }) variant: input.model.variant,
if (!items) },
optimistic.set(input.sessionID, new Map([[input.message.id, { ...input, parts, confirmedParts: [] }]])) time: { created },
indexProjectedMessage(input.message) },
setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]).sort(compareMessages)) {
setData( id: input.messageID,
"part_text_accum_delta", type: "user",
produce((draft) => { text: input.displayText,
for (const part of [...(data.part[input.message.id] ?? []), ...parts]) { files,
delete draft[part.id] agents: input.agents,
deltaBases.delete(part.id) time: { created },
} },
}), ])
) const message = projected.messages[0]!
setData("part", input.message.id, parts) const comments: Part[] = input.comments.map((comment, index) => ({
id: `${input.messageID}:comment:${index}`,
sessionID: input.sessionID,
messageID: input.messageID,
type: "text",
text: formatCommentNote(comment),
synthetic: true,
metadata: createCommentMetadata(comment),
}))
const parts = [...(projected.parts.get(input.messageID) ?? []), ...comments]
removedMessages.get(input.sessionID)?.delete(input.messageID)
markEcho(input.sessionID, input.messageID)
pendingRevision.set(input.sessionID, (pendingRevision.get(input.sessionID) ?? 0) + 1)
batch(() => {
setData("pending", input.sessionID, (items = []) => [...items.filter((entry) => entry.id !== item.id), item])
if (!data.input[input.sessionID]?.includes(input.messageID))
setData("input", input.sessionID, [...(data.input[input.sessionID] ?? []), input.messageID])
setData("message", input.sessionID, (messages = []) => merge(messages, [message]).sort(compareMessages))
setData("part", input.messageID, parts)
})
}, },
remove(input: { sessionID: string; messageID: string }) { confirm: confirmInbox,
const item = optimistic.get(input.sessionID)?.get(input.messageID) reconcile: reconcileInbox,
if (!item) return clearEcho(input: { sessionID: string; messageID: string }) {
messageLoads.get(input.sessionID)?.optimisticParts.delete(input.messageID) if (echoes.get(input.sessionID)?.get(input.messageID) !== "sending") return false
clearOptimistic(input.sessionID, input.messageID) return removeEcho(input.sessionID, input.messageID)
if (item.confirmedMessage) {
const partIDs = new Set(item.parts.map((part) => part.id))
setData(
produce((draft) => {
for (const part of item.parts) {
delete draft.part_text_accum_delta[part.id]
deltaBases.delete(part.id)
}
const parts = draft.part[input.messageID]
if (!parts) return
draft.part[input.messageID] = parts.filter((part) => !partIDs.has(part.id))
if (draft.part[input.messageID]?.length === 0) delete draft.part[input.messageID]
}),
)
return
}
const projectedIDs = new Set(projectMessageSource(item.message).map((message) => message.id))
setData("session_message", input.sessionID, (messages) =>
messages?.filter((message) => !projectedIDs.has(message.id)),
)
setData("message", input.sessionID, (messages) => messages?.filter((message) => message.id !== input.messageID))
setData(produce((draft) => deleteMessageParts(draft, input.messageID)))
}, },
}, },
async todo(sessionID: string, request?: { force?: boolean }) { async todo(sessionID: string, request?: { force?: boolean }) {
@@ -1463,14 +1436,14 @@ export function createServerSession(
history: { history: {
more: (sessionID: string) => more: (sessionID: string) =>
data.message[sessionID] !== undefined && data.message[sessionID] !== undefined &&
meta.limit[sessionID] !== undefined && meta.complete[sessionID] !== undefined &&
!meta.complete[sessionID] && !meta.complete[sessionID] &&
!!meta.cursor[sessionID], !!meta.cursor[sessionID],
loading: (sessionID: string) => meta.loading[sessionID] ?? false, loading: (sessionID: string) => meta.loading[sessionID] ?? false,
async loadMore(sessionID: string, count = historyMessagePageSize) { async loadMore(sessionID: string) {
touch(sessionID) touch(sessionID)
if (meta.loading[sessionID] || meta.complete[sessionID] || !meta.cursor[sessionID]) return if (meta.loading[sessionID] || meta.complete[sessionID] || !meta.cursor[sessionID]) return
await loadMessages(sessionID, count, meta.cursor[sessionID], "prepend") await loadMessages(sessionID, meta.cursor[sessionID], "prepend")
}, },
}, },
evict(sessionID: string) { evict(sessionID: string) {
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
import type { import type {
McpListInput, McpListInput,
McpResourceCatalogInput, McpResourceCatalogInput,
OpenCodeEvent,
SessionApi, SessionApi,
SessionInfo, SessionInfo,
SessionListInput, SessionListInput,
@@ -15,11 +16,13 @@ import {
loadMcpResourcesQuery, loadMcpResourcesQuery,
reconcileActiveSessionStatuses, reconcileActiveSessionStatuses,
seedActiveSessionStatuses, seedActiveSessionStatuses,
sessionListEventDirectories,
shouldRefreshWorkspaceSessions, shouldRefreshWorkspaceSessions,
} from "./server-sync" } from "./server-sync"
import { ServerScope } from "@/utils/server-scope" import { ServerScope } from "@/utils/server-scope"
import { createServerSession } from "./server-session" import { createServerSession } from "./server-session"
import type { ServerApi } from "@/utils/server" import type { ServerApi } from "@/utils/server"
import { adaptServerEvent } from "./server-sdk"
type McpApi = ServerApi["mcp"] type McpApi = ServerApi["mcp"]
@@ -214,6 +217,23 @@ describe("workspace session inventory", () => {
expect(shouldRefreshWorkspaceSessions(event("session.updated", "session.moved"))).toBe(true) expect(shouldRefreshWorkspaceSessions(event("session.updated", "session.moved"))).toBe(true)
expect(shouldRefreshWorkspaceSessions(event("message.updated"))).toBe(false) expect(shouldRefreshWorkspaceSessions(event("message.updated"))).toBe(false)
}) })
test("invalidates both locations when a session moves", () => {
const event = adaptServerEvent({
id: "evt_moved",
created: 1,
type: "session.moved",
durable: { aggregateID: "ses_1", seq: 1, version: 1 },
location: { directory: "/source" },
data: {
sessionID: "ses_1",
location: { directory: "/destination" },
projectID: "project_2",
},
} satisfies Extract<OpenCodeEvent, { type: "session.moved" }>)
expect(sessionListEventDirectories(event)).toEqual(["/source", "/destination"])
})
}) })
describe("canDisposeDirectory", () => { describe("canDisposeDirectory", () => {
+18 -16
View File
@@ -88,6 +88,12 @@ const SESSION_LIST_EVENTS = new Set([
"session.usage.updated", "session.usage.updated",
]) ])
export function sessionListEventDirectories(event: ServerEvent) {
if (!SESSION_LIST_EVENTS.has(event.current?.type ?? event.type)) return []
const destination = event.current?.type === "session.moved" ? event.current.data.location.directory : undefined
return [...new Set([event.current?.location?.directory, destination].filter((item): item is string => !!item))]
}
type McpListApi = { type McpListApi = {
readonly list: (input?: McpListInput) => Promise<McpListOutput> readonly list: (input?: McpListInput) => Promise<McpListOutput>
} }
@@ -231,7 +237,10 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
return { pending, forms } return { pending, forms }
}) })
} }
const hydrateSession = (sessionID: string) => Promise.all([session.sync(sessionID), hydrateSessionState(sessionID)]) const hydrateSession = async (sessionID: string) => {
await Promise.all([session.sync(sessionID), hydrateSessionState(sessionID)])
session.inbox.reconcile(sessionID)
}
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({ const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
queries: [ queries: [
@@ -551,14 +560,11 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
type: "session.updated", type: "session.updated",
properties: { sessionID: info.id, info }, properties: { sessionID: info.id, info },
}) })
const markSessionListChanged = (event: ServerEvent, directory: string, previousDirectory?: string) => { const markSessionListsChanged = (event: ServerEvent) => {
if (SESSION_LIST_EVENTS.has(event.current?.type ?? event.type)) { sessionListEventDirectories(event).forEach((directory) => {
const key = directoryKey(directory) const key = directoryKey(directory)
sessionRevision.set(key, (sessionRevision.get(key) ?? 0) + 1) sessionRevision.set(key, (sessionRevision.get(key) ?? 0) + 1)
} })
if (!previousDirectory || previousDirectory === directory) return
const key = directoryKey(previousDirectory)
sessionRevision.set(key, (sessionRevision.get(key) ?? 0) + 1)
} }
const toDirectoryEvent = (event: ServerEvent) => { const toDirectoryEvent = (event: ServerEvent) => {
if (event.current?.type === "session.created") return if (event.current?.type === "session.created") return
@@ -569,15 +575,10 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
} }
const unsub = serverSDK.event.listen((e) => { const unsub = serverSDK.event.listen((e) => {
const directory = e.name
const key = directoryKey(directory)
const event = e.details const event = e.details
const directory = event.current?.location?.directory
const eventType: string = event.type const eventType: string = event.type
const previousDirectory = markSessionListsChanged(event)
event.current?.type === "session.moved"
? session.get(event.current.data.sessionID)?.location.directory
: undefined
markSessionListChanged(event, directory, previousDirectory)
if (event.current) session.applyV2(event.current) if (event.current) session.applyV2(event.current)
session.apply(event) session.apply(event)
if (event.current?.type === "session.moved") { if (event.current?.type === "session.moved") {
@@ -629,9 +630,9 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
} }
homeSessions.refresh(event.type) homeSessions.refresh(event.type)
catalog.handleEvent({ type: eventType, directory }) catalog.handleEvent({ type: eventType, directory })
connection.handleEvent({ type: eventType, directory }) connection.handleEvent({ type: eventType })
if (directory === "global") { if (!directory) {
applyGlobalEvent({ applyGlobalEvent({
event, event,
project: globalStore.project, project: globalStore.project,
@@ -644,6 +645,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
return return
} }
const key = directoryKey(directory)
if (event.current?.type === "session.forked") if (event.current?.type === "session.forked")
void session void session
.resolve(event.current.data.sessionID, { force: true }) .resolve(event.current.data.sessionID, { force: true })
@@ -42,7 +42,7 @@ test("invalidates global and active catalogs after connection", async () => {
load: async () => {}, load: async () => {},
}) })
catalog.handleEvent({ type: "server.connected", directory: "global" }) catalog.handleEvent({ type: "server.connected" })
await Bun.sleep(0) await Bun.sleep(0)
expect(queryClient.getQueryState(global)?.isInvalidated).toBe(true) expect(queryClient.getQueryState(global)?.isInvalidated).toBe(true)
@@ -4,7 +4,7 @@ import { pathKey, type PathKey } from "@/utils/path-key"
type CatalogEvent = { type CatalogEvent = {
type: string type: string
directory: string directory?: string
} }
export function createCatalogSync(input: { export function createCatalogSync(input: {
@@ -24,7 +24,7 @@ export function createCatalogSync(input: {
event.type === "integration.updated" || event.type === "integration.updated" ||
event.type === "integration.connection.updated" event.type === "integration.connection.updated"
) { ) {
void refresh(event.directory === "global" ? null : pathKey(event.directory)).catch(() => undefined) void refresh(event.directory ? pathKey(event.directory) : null).catch(() => undefined)
} }
} }
@@ -12,10 +12,8 @@ test("invalidates disconnected data and synchronizes after the handshake", () =>
connected: () => calls.push("connected"), connected: () => calls.push("connected"),
}) })
connection.handleEvent({ type: "server.connected", directory: "global" }) connection.handleEvent({ type: "server.connected" })
expect(calls).toContain("connected") expect(calls).toContain("connected")
connection.handleEvent({ type: "server.connected", directory: "/repo" })
expect(calls.filter((call) => call === "connected")).toHaveLength(1)
setStatus("connected") setStatus("connected")
return dispose return dispose
}) })
@@ -12,8 +12,8 @@ export function createConnectionSync(input: {
}) })
let connectedOnce = false let connectedOnce = false
function handleEvent(event: { type: string; directory: string }) { function handleEvent(event: { type: string }) {
if (event.directory !== "global" || event.type !== "server.connected") return if (event.type !== "server.connected") return
input.connected({ reconnect: connectedOnce }) input.connected({ reconnect: connectedOnce })
connectedOnce = true connectedOnce = true
} }
@@ -1,137 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { Message, Part } from "@/types"
import { applyOptimisticAdd, applyOptimisticRemove, mergeOptimisticPage } from "./sync"
type Text = Extract<Part, { type: "text" }>
const userMessage = (id: string, sessionID: string, created = 1): Message => ({
id,
sessionID,
role: "user",
time: { created },
agent: "assistant",
model: { providerID: "openai", modelID: "gpt" },
})
const textPart = (id: string, sessionID: string, messageID: string): Text => ({
id,
sessionID,
messageID,
type: "text",
text: id,
})
describe("sync optimistic reducers", () => {
test("applyOptimisticAdd inserts by creation time", () => {
const sessionID = "ses_1"
const draft = {
message: { [sessionID]: [userMessage("msg_z", sessionID, 1)] },
part: {} as Record<string, Part[] | undefined>,
}
applyOptimisticAdd(draft, {
sessionID,
message: userMessage("msg_a", sessionID, 2),
parts: [textPart("prt_2", sessionID, "msg_a"), textPart("prt_1", sessionID, "msg_a")],
})
expect(draft.message[sessionID]?.map((x) => x.id)).toEqual(["msg_z", "msg_a"])
expect(draft.part.msg_a?.map((x) => x.id)).toEqual(["prt_1", "prt_2"])
})
test("applyOptimisticRemove removes message and part entries", () => {
const sessionID = "ses_1"
const draft = {
message: { [sessionID]: [userMessage("msg_1", sessionID), userMessage("msg_2", sessionID)] },
part: {
msg_1: [textPart("prt_1", sessionID, "msg_1")],
msg_2: [textPart("prt_2", sessionID, "msg_2")],
} as Record<string, Part[] | undefined>,
}
applyOptimisticRemove(draft, { sessionID, messageID: "msg_1" })
expect(draft.message[sessionID]?.map((x) => x.id)).toEqual(["msg_2"])
expect(draft.part.msg_1).toBeUndefined()
expect(draft.part.msg_2).toHaveLength(1)
})
test("mergeOptimisticPage keeps pending messages in fetched timelines", () => {
const sessionID = "ses_1"
const page = mergeOptimisticPage(
{
session: [userMessage("msg_z", sessionID, 1)],
part: [{ id: "msg_z", part: [textPart("prt_1", sessionID, "msg_z")] }],
complete: true,
},
[{ message: userMessage("msg_a", sessionID, 2), parts: [textPart("prt_2", sessionID, "msg_a")] }],
)
expect(page.session.map((x) => x.id)).toEqual(["msg_z", "msg_a"])
expect(page.part.find((x) => x.id === "msg_a")?.part.map((x) => x.id)).toEqual(["prt_2"])
expect(page.confirmed).toEqual([])
expect(page.complete).toBe(true)
})
test("mergeOptimisticPage uses IDs only to break equal-time ties", () => {
const sessionID = "ses_1"
const page = mergeOptimisticPage(
{
session: [userMessage("msg_z", sessionID, 1)],
part: [],
complete: true,
},
[{ message: userMessage("msg_a", sessionID, 1), parts: [] }],
)
expect(page.session.map((message) => message.id)).toEqual(["msg_a", "msg_z"])
})
test("mergeOptimisticPage keeps missing optimistic parts until the server has them", () => {
const sessionID = "ses_1"
const page = mergeOptimisticPage(
{
session: [userMessage("msg_2", sessionID)],
part: [{ id: "msg_2", part: [textPart("prt_2", sessionID, "msg_2")] }],
complete: true,
},
[
{
message: userMessage("msg_2", sessionID),
parts: [textPart("prt_1", sessionID, "msg_2"), textPart("prt_2", sessionID, "msg_2")],
},
],
)
expect(page.part.find((x) => x.id === "msg_2")?.part.map((x) => x.id)).toEqual(["prt_1", "prt_2"])
expect(page.confirmed).toEqual([])
})
test("mergeOptimisticPage confirms echoed messages once all parts arrive", () => {
const sessionID = "ses_1"
const page = mergeOptimisticPage(
{
session: [userMessage("msg_2", sessionID)],
part: [
{
id: "msg_2",
part: [{ ...textPart("prt_1", sessionID, "msg_2"), text: "server" }, textPart("prt_2", sessionID, "msg_2")],
},
],
complete: true,
},
[
{
message: userMessage("msg_2", sessionID),
parts: [textPart("prt_1", sessionID, "msg_2"), textPart("prt_2", sessionID, "msg_2")],
},
],
)
expect(page.confirmed).toEqual(["msg_2"])
expect(page.part.find((x) => x.id === "msg_2")?.part).toMatchObject([
{ id: "prt_1", type: "text", text: "server" },
{ id: "prt_2", type: "text", text: "prt_2" },
])
})
})
-108
View File
@@ -1,114 +1,6 @@
import { Binary } from "@opencode-ai/core/util/binary"
import { createMemo } from "solid-js" import { createMemo } from "solid-js"
import { useServerSync } from "./server-sync" import { useServerSync } from "./server-sync"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import type { Message, Part } from "@/types"
import { messageKey } from "@/utils/session-message"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
function sortParts(parts: Part[]) {
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
}
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
type OptimisticStore = {
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
}
type OptimisticAddInput = {
sessionID: string
message: Message
parts: Part[]
}
type OptimisticRemoveInput = {
sessionID: string
messageID: string
}
type OptimisticItem = {
message: Message
parts: Part[]
}
type MessagePage = {
session: Message[]
part: { id: string; part: Part[] }[]
cursor?: string
complete: boolean
}
const hasParts = (parts: Part[] | undefined, want: Part[]) => {
if (!parts) return want.length === 0
return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found)
}
const mergeParts = (parts: Part[] | undefined, want: Part[]) => {
if (!parts) return sortParts(want)
const next = [...parts]
let changed = false
for (const part of want) {
const result = Binary.search(next, part.id, (item) => item.id)
if (result.found) continue
next.splice(result.index, 0, part)
changed = true
}
if (!changed) return parts
return next
}
export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
if (items.length === 0) return { ...page, confirmed: [] as string[] }
const session = [...page.session]
const part = new Map(page.part.map((item) => [item.id, sortParts(item.part)]))
const confirmed: string[] = []
for (const item of items) {
const result = Binary.search(session, messageKey(item.message), messageKey)
const found = result.found
if (!found) session.splice(result.index, 0, item.message)
const current = part.get(item.message.id)
if (found && hasParts(current, item.parts)) {
confirmed.push(item.message.id)
continue
}
part.set(item.message.id, mergeParts(current, item.parts))
}
return {
cursor: page.cursor,
complete: page.complete,
session,
part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, part]) => ({ id, part })),
confirmed,
}
}
export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) {
const messages = draft.message[input.sessionID]
if (messages) {
const result = Binary.search(messages, messageKey(input.message), messageKey)
messages.splice(result.index, 0, input.message)
} else {
draft.message[input.sessionID] = [input.message]
}
draft.part[input.message.id] = sortParts(input.parts)
}
export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) {
const messages = draft.message[input.sessionID]
if (messages) {
const index = messages.findIndex((message) => message.id === input.messageID)
if (index >= 0) messages.splice(index, 1)
}
delete draft.part[input.messageID]
}
export const useSync = () => { export const useSync = () => {
const serverSync = useServerSync() const serverSync = useServerSync()
+16 -5
View File
@@ -2,6 +2,7 @@ import { useQueryOptions } from "@/context/server-sync"
import { Iterable, pipe } from "effect" import { Iterable, pipe } from "effect"
import { type Accessor } from "solid-js" import { type Accessor } from "solid-js"
import { emptyProviderCatalog } from "./provider-catalog" import { emptyProviderCatalog } from "./provider-catalog"
import { useIntegrations } from "./use-integrations"
import { useQuery } from "@tanstack/solid-query" import { useQuery } from "@tanstack/solid-query"
import { pathKey } from "@/utils/path-key" import { pathKey } from "@/utils/path-key"
@@ -23,6 +24,7 @@ export function useProviders(directory: Accessor<string | undefined>) {
const dir = directory() const dir = directory()
return queryOpts.providers(dir ? pathKey(dir) : null) return queryOpts.providers(dir ? pathKey(dir) : null)
}) })
const integrations = useIntegrations(directory)
const providers = () => (!providersQuery.isSuccess ? emptyProviderCatalog : providersQuery.data) const providers = () => (!providersQuery.isSuccess ? emptyProviderCatalog : providersQuery.data)
@@ -30,13 +32,22 @@ export function useProviders(directory: Accessor<string | undefined>) {
ready: () => providersQuery.isSuccess, ready: () => providersQuery.isSuccess,
all: () => providers().all, all: () => providers().all,
default: () => providers().default, default: () => providers().default,
popular: () => // V2 servers list only available providers, so the connectable catalog
pipe( // comes from the integration list, with the provider catalog as fallback.
popular: () => {
const catalog = integrations
.list()
.filter((integration) => popularProviderSet.has(integration.id))
.map((integration) => ({ id: integration.id, name: integration.name }))
const seen = new Set(catalog.map((integration) => integration.id))
return pipe(
providers().all, providers().all,
Iterable.map(([, p]) => p), Iterable.map(([, p]) => p),
Iterable.filter((p) => popularProviderSet.has(p.id)), Iterable.filter((p) => popularProviderSet.has(p.id) && !seen.has(p.id)),
(v) => Array.from(v), Iterable.map((p) => ({ id: p.id, name: p.name })),
), (v) => [...catalog, ...v],
)
},
connected: () => { connected: () => {
const connected = new Set(providers().connected) const connected = new Set(providers().connected)
return pipe( return pipe(
+1 -1
View File
@@ -4,7 +4,7 @@ export type UpdaterState =
| { status: "disabled" } | { status: "disabled" }
| { status: "idle" } | { status: "idle" }
| { status: "checking" } | { status: "checking" }
| { status: "downloading"; version: string; percent?: number } | { status: "downloading"; version: string }
| { status: "ready"; version: string } | { status: "ready"; version: string }
| { status: "up-to-date" } | { status: "up-to-date" }
| { status: "installing"; version: string } | { status: "installing"; version: string }
+4 -1
View File
@@ -22,7 +22,10 @@ function blobUrl(id: string, blob: Blob) {
} }
async function blobID(blob: Blob) { async function blobID(blob: Blob) {
const id = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", await blob.arrayBuffer()))) const bytes = crypto.subtle
? new Uint8Array(await crypto.subtle.digest("SHA-256", await blob.arrayBuffer()))
: crypto.getRandomValues(new Uint8Array(16))
const id = Array.from(bytes)
.map((byte) => byte.toString(16).padStart(2, "0")) .map((byte) => byte.toString(16).padStart(2, "0"))
.join("") .join("")
return id return id
@@ -192,7 +192,7 @@ export type ProviderInfo = {
id: string id: string
integrationID?: string integrationID?: string
name: string name: string
disabled?: boolean activation: "auto" | "enabled" | "disabled"
package: string package: string
settings?: { [x: string]: any } settings?: { [x: string]: any }
headers?: { [x: string]: string } headers?: { [x: string]: string }
+2 -2
View File
@@ -65,8 +65,8 @@ const layer = Layer.effect(
const integrations = yield* Integration.Service const integrations = yield* Integration.Service
const available = (provider: Provider.Info, integration: Integration.Info | undefined) => { const available = (provider: Provider.Info, integration: Integration.Info | undefined) => {
if (provider.disabled) return false if (provider.activation === "disabled") return false
if (typeof provider.settings?.apiKey === "string") return true if (provider.activation === "enabled") return true
if (integration?.connections.length) return true if (integration?.connections.length) return true
return provider.integrationID === undefined && !integration return provider.integrationID === undefined && !integration
} }
@@ -40,6 +40,7 @@ export const Plugin = define({
for (const [id, item] of configuredProviders(loaded.entries)) { for (const [id, item] of configuredProviders(loaded.entries)) {
const providerID = id const providerID = id
catalog.provider.update(providerID, (provider) => { catalog.provider.update(providerID, (provider) => {
provider.activation = "enabled"
if (item.name !== undefined) provider.name = item.name if (item.name !== undefined) provider.name = item.name
if (item.package !== undefined) provider.package = item.package if (item.package !== undefined) provider.package = item.package
if (item.settings !== undefined) provider.settings = Provider.mergeOverlay(provider.settings, item.settings) if (item.settings !== undefined) provider.settings = Provider.mergeOverlay(provider.settings, item.settings)
+36 -20
View File
@@ -23,6 +23,32 @@ export type Options = typeof Options.Type
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem/Search") {} export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem/Search") {}
const REFRESH_INTERVAL = Duration.toMillis("10 seconds") const REFRESH_INTERVAL = Duration.toMillis("10 seconds")
type Prepared = ReturnType<typeof fuzzysort.prepare>
function emptyIndex() {
return { files: new Map<string, Prepared>(), directories: new Map<string, Prepared>() }
}
function search(index: ReturnType<typeof emptyIndex>, input: FileSystem.FindInput) {
const items =
input.type === "file"
? Array.from(index.files.values())
: input.type === "directory"
? Array.from(index.directories.values())
: [...index.files.values(), ...index.directories.values()]
const result = fuzzysort.go(input.query, items, { limit: input.limit ?? 50 })
// Targets are owned by the current location index. The only global fuzzysort
// state left is its query cache, which must not retain every query forever.
fuzzysort.cleanup()
return result.map((item) => {
const relative = item.target
const type = relative.endsWith(path.sep) ? ("directory" as const) : ("file" as const)
return FileSystem.Entry.make({
path: RelativePath.make(relative),
type,
})
})
}
export const ripgrepLayer = Layer.effect( export const ripgrepLayer = Layer.effect(
Service, Service,
@@ -32,12 +58,13 @@ export const ripgrepLayer = Layer.effect(
const scope = yield* Scope.Scope const scope = yield* Scope.Scope
const clock = yield* Clock.Clock const clock = yield* Clock.Clock
const home = Protected.isHome(location.directory) const home = Protected.isHome(location.directory)
let index = { files: [] as string[], directories: new Set<string>() } let index = emptyIndex()
let initialized = false let initialized = false
let settledAt = Number.NEGATIVE_INFINITY let settledAt = Number.NEGATIVE_INFINITY
let refreshing = false let refreshing = false
const scan = Effect.gen(function* () { const scan = Effect.gen(function* () {
const next = { files: [] as string[], directories: new Set<string>() } const next = emptyIndex()
const previous = index
if (!initialized) index = next if (!initialized) index = next
yield* ripgrep.find({ yield* ripgrep.find({
cwd: location.directory, cwd: location.directory,
@@ -46,11 +73,13 @@ export const ripgrepLayer = Layer.effect(
exclude: home ? [...Protected.names()].map((name) => `${name}/**`) : undefined, exclude: home ? [...Protected.names()].map((name) => `${name}/**`) : undefined,
onEntry: (entry) => onEntry: (entry) =>
Effect.sync(() => { Effect.sync(() => {
next.files.push(entry.path) next.files.set(entry.path, previous.files.get(entry.path) ?? fuzzysort.prepare(entry.path))
const parts = entry.path.split("/") const parts = entry.path.split("/")
parts parts.slice(0, -1).forEach((_, offset) => {
.slice(0, -1) const directory = parts.slice(0, offset + 1).join("/") + path.sep
.forEach((_, offset) => next.directories.add(parts.slice(0, offset + 1).join("/") + path.sep)) if (!next.directories.has(directory))
next.directories.set(directory, previous.directories.get(directory) ?? fuzzysort.prepare(directory))
})
}), }),
}) })
index = next index = next
@@ -74,20 +103,7 @@ export const ripgrepLayer = Layer.effect(
find: (input) => find: (input) =>
Effect.gen(function* () { Effect.gen(function* () {
yield* refresh yield* refresh
const items = return search(index, input)
input.type === "file"
? index.files
: input.type === "directory"
? Array.from(index.directories)
: [...index.files, ...index.directories]
return fuzzysort.go(input.query, items, { limit: input.limit ?? 50 }).map((item) => {
const relative = item.target
const type = relative.endsWith(path.sep) ? ("directory" as const) : ("file" as const)
return FileSystem.Entry.make({
path: RelativePath.make(relative),
type,
})
})
}), }),
}) })
}), }),
+9 -5
View File
@@ -1,4 +1,5 @@
import { Effect, Layer, LayerMap } from "effect" import { Effect, Layer, LayerMap } from "effect"
import path from "path"
import { Agent } from "./agent.js" import { Agent } from "./agent.js"
import { AISDK } from "./aisdk.js" import { AISDK } from "./aisdk.js"
import { Catalog } from "./catalog.js" import { Catalog } from "./catalog.js"
@@ -49,6 +50,7 @@ import { ReadToolFileSystem } from "./tool/read-filesystem.js"
import { Tool } from "./tool.js" import { Tool } from "./tool.js"
import { ToolOutput } from "./tool-output.js" import { ToolOutput } from "./tool-output.js"
import { Vcs } from "./vcs.js" import { Vcs } from "./vcs.js"
import { AbsolutePath } from "./schema.js"
export { LocationServiceMap } from "./location-service-map.js" export { LocationServiceMap } from "./location-service-map.js"
@@ -110,11 +112,13 @@ export type LocationError = LayerNode.Error<typeof locationServices>
export function buildLocationServiceMap( export function buildLocationServiceMap(
replacements: LayerNode.Replacements = [], replacements: LayerNode.Replacements = [],
): Layer.Layer<LocationServiceMap.Service> { ): Layer.Layer<LocationServiceMap.Service> {
// Structural Equal is own-key-set sensitive, so `{ directory }` (schema-decoded // Structural Equal distinguishes optional-key shape and Windows separator style.
// payloads omit optional keys) and `{ directory, workspaceID: undefined }` are // The RcMap caches the raw key before the build callback, so normalize both here.
// different RcMap keys. The RcMap caches by the raw key before the build const canonical = (ref: Location.Ref) =>
// callback runs, so canonicalize at the map boundary to the key-present shape. Location.Ref.make({
const canonical = (ref: Location.Ref) => Location.Ref.make({ directory: ref.directory, workspaceID: ref.workspaceID }) directory: AbsolutePath.make(process.platform === "win32" ? path.normalize(ref.directory) : ref.directory),
workspaceID: ref.workspaceID,
})
return Layer.effect( return Layer.effect(
LocationServiceMap.Service, LocationServiceMap.Service,
Effect.map( Effect.map(
+43 -10
View File
@@ -358,17 +358,21 @@ export const layer = Layer.effect(
const connection = yield* integrations.connection.active( const connection = yield* integrations.connection.active(
provider?.integrationID ?? Integration.ID.make(selected.providerID), provider?.integrationID ?? Integration.ID.make(selected.providerID),
) )
const model = yield* resolveModel( const credential = connection ? yield* integrations.connection.resolve(connection) : undefined
selected, const runtimeInfo = yield* withVariant(selected, variant)
variant, const model = yield* fromCatalogModel(runtimeInfo, credential, {
connection ? yield* integrations.connection.resolve(connection) : undefined, loadPackage: (specifier) => Provider.loadPackage(specifier, npm),
{ loadAISDK: (model) => aisdk.model(model),
loadPackage: (specifier) => Provider.loadPackage(specifier, npm), })
loadAISDK: (model) => aisdk.model(model), const runtime =
}, provider?.activation === "enabled" &&
) credential === undefined &&
!hasConfiguredAuth(runtimeInfo) &&
usesAPIKeyAuth(runtimeInfo.package)
? LanguageModel.update(model, { route: model.route.with({ auth: Auth.none }) })
: model
return { return {
model, model: runtime,
ref: Ref.make({ ref: Ref.make({
id: selected.id, id: selected.id,
providerID: selected.providerID, providerID: selected.providerID,
@@ -399,6 +403,35 @@ export const layer = Layer.effect(
}), }),
) )
function hasConfiguredAuth(model: Info) {
return [model.settings?.apiKey, model.settings?.authToken, model.settings?.accessToken].some(
(value) => typeof value === "string" && value !== "",
)
}
function usesAPIKeyAuth(packageName: string | undefined) {
const name = Provider.packageName(packageName)
return (
name === "@ai-sdk/openai" ||
name === "@ai-sdk/anthropic" ||
name === "@ai-sdk/openai-compatible" ||
name === "@ai-sdk/google" ||
name === "@ai-sdk/xai" ||
name === "@openrouter/ai-sdk-provider" ||
name === "@ai-sdk/azure" ||
name === "@opencode-ai/ai/providers/openai" ||
name?.startsWith("@opencode-ai/ai/providers/openai/") === true ||
name === "@opencode-ai/ai/providers/anthropic" ||
name === "@opencode-ai/ai/providers/anthropic-compatible" ||
name === "@opencode-ai/ai/providers/openai-compatible" ||
name === "@opencode-ai/ai/providers/google" ||
name === "@opencode-ai/ai/providers/xai" ||
name === "@opencode-ai/ai/providers/openrouter" ||
name === "@opencode-ai/ai/providers/azure" ||
name?.startsWith("@opencode-ai/ai/providers/azure/") === true
)
}
export const node = makeLocationNode({ export const node = makeLocationNode({
service: Service, service: Service,
layer, layer,
+1
View File
@@ -86,6 +86,7 @@ function normalize(input: Record<string, SourceProvider>): readonly Snapshot[] {
const info = { const info = {
id: providerID, id: providerID,
name: item.name, name: item.name,
activation: "auto",
package: Provider.aisdk(item.npm), package: Provider.aisdk(item.npm),
...(item.api ? { settings: { baseURL: item.api } } : {}), ...(item.api ? { settings: { baseURL: item.api } } : {}),
} satisfies Provider.Info } satisfies Provider.Info
@@ -10,7 +10,7 @@ export const LLMGatewayPlugin = define({
const configured = new Set((yield* integrations.list()).map((integration) => integration.id)) const configured = new Set((yield* integrations.list()).map((integration) => integration.id))
yield* ctx.catalog.transform((evt) => { yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) { for (const item of evt.provider.list()) {
if (item.provider.disabled) continue if (item.provider.activation === "disabled") continue
if (!Provider.isAISDK(item.provider.package)) continue if (!Provider.isAISDK(item.provider.package)) continue
if (Provider.packageName(item.provider.package) !== "@ai-sdk/openai-compatible") continue if (Provider.packageName(item.provider.package) !== "@ai-sdk/openai-compatible") continue
if (item.provider.settings?.baseURL !== "https://api.llmgateway.io/v1") continue if (item.provider.settings?.baseURL !== "https://api.llmgateway.io/v1") continue
@@ -178,7 +178,10 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
if (!item) return if (!item) return
const hasKey = Boolean(process.env.OPENCODE_API_KEY || connected || item.provider.settings?.apiKey) const hasKey = Boolean(process.env.OPENCODE_API_KEY || connected || item.provider.settings?.apiKey)
catalog.provider.update(item.provider.id, (provider) => { catalog.provider.update(item.provider.id, (provider) => {
if (!hasKey) provider.settings = { ...provider.settings, apiKey: "public" } if (!hasKey) {
provider.activation = "enabled"
provider.settings = { ...provider.settings, apiKey: "public" }
}
}) })
if (hasKey) return if (hasKey) return
for (const model of item.models.values()) { for (const model of item.models.values()) {
+1 -1
View File
@@ -68,7 +68,7 @@ every field, examples, config locations, and links to dedicated feature guides.
For any request to migrate OpenCode configuration, agents, commands, skills, For any request to migrate OpenCode configuration, agents, commands, skills,
plugins, integrations, or other behavior from V1 to V2, read the full plugins, integrations, or other behavior from V1 to V2, read the full
[migration guide](https://opencode.ai/v2/docs/migrate-v1) before acting. In [migration guide](https://opencode.ai/v2/docs/migrate-v1) before acting. In
the repository, its source is `packages/www/content/docs/(Get started)/migrate-v1.mdx`. the repository, its source is `packages/www/content/docs/migrate-v1.mdx`.
V1 config files and `.opencode/` definitions are intended to remain compatible. V1 config files and `.opencode/` definitions are intended to remain compatible.
The only intentional breaking changes are the server API and plugin API. Native The only intentional breaking changes are the server API and plugin API. Native
+5 -7
View File
@@ -789,7 +789,10 @@ const layer = Layer.effect(
return false return false
}), }),
) )
if (recovered) return if (recovered) {
yield* execution.wakeActive(input.sessionID)
return
}
yield* execution.wake(input.sessionID) yield* execution.wake(input.sessionID)
}), }),
compact: Effect.fn("Session.compact")(function* (input) { compact: Effect.fn("Session.compact")(function* (input) {
@@ -873,12 +876,7 @@ const layer = Layer.effect(
), ),
), ),
interrupt: Effect.fn("Session.interrupt")((sessionID, options) => interrupt: Effect.fn("Session.interrupt")((sessionID, options) =>
Effect.uninterruptible( Effect.uninterruptible(execution.interrupt(sessionID, options)),
Effect.gen(function* () {
yield* execution.interrupt(sessionID)
if (options?.continue && (yield* SessionInbox.has(db, sessionID, "any"))) yield* execution.wake(sessionID)
}),
),
), ),
revert: { revert: {
stage: Effect.fn("Session.revert.stage")(function* (input) { stage: Effect.fn("Session.revert.stage")(function* (input) {
+22 -11
View File
@@ -1,7 +1,8 @@
export * as SessionExecution from "./execution.js" export * as SessionExecution from "./execution.js"
import { Cause, Context, Effect, Exit, Layer, Stream } from "effect" import { Cause, Context, Effect, Exit, Layer } from "effect"
import { Bus } from "../bus.js" import { Bus } from "../bus.js"
import { Database } from "../database/database.js"
import { LocationServiceMap } from "../location-service-map.js" import { LocationServiceMap } from "../location-service-map.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node" import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { SessionEvent } from "./event.js" import { SessionEvent } from "./event.js"
@@ -11,6 +12,7 @@ import { SessionSchema } from "./schema.js"
import { SessionStore } from "./store.js" import { SessionStore } from "./store.js"
import { toSessionError } from "./to-session-error.js" import { toSessionError } from "./to-session-error.js"
import { UserInterruptedError } from "./error.js" import { UserInterruptedError } from "./error.js"
import { SessionInbox } from "./inbox.js"
export interface Interface { export interface Interface {
/** Snapshots active execution owned by this process. */ /** Snapshots active execution owned by this process. */
@@ -19,8 +21,10 @@ export interface Interface {
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError> readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
/** Registers newly recorded work. Repeated wakeups may coalesce. */ /** Registers newly recorded work. Repeated wakeups may coalesce. */
readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void> readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void>
/** Wakes only an active execution, preserving its current input eligibility. */
readonly wakeActive: (sessionID: SessionSchema.ID) => Effect.Effect<void>
/** Interrupt active work owned by this process. Idle interruption is a no-op. */ /** Interrupt active work owned by this process. Idle interruption is a no-op. */
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void> readonly interrupt: (sessionID: SessionSchema.ID, options?: { readonly continue?: boolean }) => Effect.Effect<void>
/** Resolves once this process owns no active execution for the Session. Returns immediately when idle and never starts work. */ /** Resolves once this process owns no active execution for the Session. Returns immediately when idle and never starts work. */
readonly awaitIdle: (sessionID: SessionSchema.ID) => Effect.Effect<void> readonly awaitIdle: (sessionID: SessionSchema.ID) => Effect.Effect<void>
} }
@@ -45,6 +49,7 @@ export const layer = Layer.effect(
const store = yield* SessionStore.Service const store = yield* SessionStore.Service
const locations = yield* LocationServiceMap.Service const locations = yield* LocationServiceMap.Service
const bus = yield* Bus.Service const bus = yield* Bus.Service
const db = (yield* Database.Service).db
const reportLifecycle = <A>(sessionID: SessionSchema.ID, effect: Effect.Effect<A>) => const reportLifecycle = <A>(sessionID: SessionSchema.ID, effect: Effect.Effect<A>) =>
effect.pipe( effect.pipe(
Effect.tapCause((cause) => Effect.tapCause((cause) =>
@@ -71,12 +76,13 @@ export const layer = Layer.effect(
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
force: boolean, force: boolean,
continuation?: SessionRunner.Continuation, continuation?: SessionRunner.Continuation,
promotable: SessionInbox.Promotable = "input",
): Effect.Effect<void, SessionRunner.RunError> { ): Effect.Effect<void, SessionRunner.RunError> {
return Effect.gen(function* () { return Effect.gen(function* () {
const session = yield* store.get(sessionID) const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`)) if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
const result = yield* SessionRunner.Service.use((runner) => const result = yield* SessionRunner.Service.use((runner) =>
runner.drain({ sessionID, force, continuation }), runner.drain({ sessionID, force, continuation, promotable }),
).pipe( ).pipe(
Effect.provide(locations.get(session.location)), Effect.provide(locations.get(session.location)),
Effect.tapCause((cause) => Effect.tapCause((cause) =>
@@ -86,7 +92,7 @@ export const layer = Layer.effect(
), ),
) )
if (result.type === "complete") return if (result.type === "complete") return
return yield* drain(sessionID, false, result.continuation) return yield* drain(sessionID, false, result.continuation, promotable)
}) })
} }
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError, InterruptReason>({ const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError, InterruptReason>({
@@ -95,7 +101,7 @@ export const layer = Layer.effect(
sessionID, sessionID,
bus.publish(SessionEvent.Execution.Started, { sessionID }, claimOnCommit(sessionID)), bus.publish(SessionEvent.Execution.Started, { sessionID }, claimOnCommit(sessionID)),
), ),
drain: (sessionID, force) => drain(sessionID, force), drain: (sessionID, force, promotable) => drain(sessionID, force, undefined, promotable),
// One terminal observation per busy period, covering every coalesced drain. // One terminal observation per busy period, covering every coalesced drain.
settled: (sessionID, exit, reason) => settled: (sessionID, exit, reason) =>
reportLifecycle( reportLifecycle(
@@ -127,16 +133,20 @@ export const layer = Layer.effect(
}), }),
), ),
}) })
yield* bus.subscribe(SessionEvent.Moved).pipe(
Stream.runForEach((event) => coordinator.wake(event.data.sessionID)),
Effect.forkScoped,
)
return Service.of({ return Service.of({
active: coordinator.active, active: coordinator.active,
interrupt: (sessionID) => coordinator.interrupt(sessionID, "user"), interrupt: (sessionID, options) =>
Effect.gen(function* () {
yield* coordinator.interrupt(sessionID, "user")
if (!options?.continue) return
// Resume only steering input from the interrupted intent. Queued next-turn work
// stays parked: a steer-scoped drain never promotes queue-delivery rows.
if (yield* SessionInbox.has(db, sessionID, "steer")) yield* coordinator.wake(sessionID, "steer")
}),
resume: coordinator.run, resume: coordinator.run,
wake: coordinator.wake, wake: coordinator.wake,
wakeActive: coordinator.wakeActive,
awaitIdle: coordinator.awaitIdle, awaitIdle: coordinator.awaitIdle,
}) })
}), }),
@@ -145,7 +155,7 @@ export const layer = Layer.effect(
export const node = makeGlobalNode({ export const node = makeGlobalNode({
service: Service, service: Service,
layer, layer,
deps: [SessionStore.node, LocationServiceMap.node, Bus.node], deps: [SessionStore.node, LocationServiceMap.node, Bus.node, Database.node],
}) })
/** Low-level compatibility layer for callers that only need durable Session recording. */ /** Low-level compatibility layer for callers that only need durable Session recording. */
@@ -155,6 +165,7 @@ export const noopLayer = Layer.succeed(
active: Effect.succeed(new Set()), active: Effect.succeed(new Set()),
resume: () => Effect.void, resume: () => Effect.void,
wake: () => Effect.void, wake: () => Effect.void,
wakeActive: () => Effect.void,
interrupt: () => Effect.void, interrupt: () => Effect.void,
awaitIdle: () => Effect.void, awaitIdle: () => Effect.void,
}), }),
+8
View File
@@ -349,6 +349,14 @@ export const nextSteer = Effect.fn("SessionInbox.nextSteer")(function* (
return row ? fromRow(row) : undefined return row ? fromRow(row) : undefined
}) })
export const nextPromotable = Effect.fn("SessionInbox.nextPromotable")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
promotable: Promotable,
) {
return (yield* nextSteer(db, sessionID)) ?? (promotable === "input" ? yield* nextQueued(db, sessionID) : undefined)
})
/** /**
* Which pending rows count: "any" counts every row, while "input" means any * Which pending rows count: "any" counts every row, while "input" means any
* item in either delivery mode. * item in either delivery mode.
+33 -18
View File
@@ -1,6 +1,7 @@
export * as SessionRunCoordinator from "./run-coordinator.js" export * as SessionRunCoordinator from "./run-coordinator.js"
import { Deferred, Effect, Exit, Fiber, FiberSet, Scope } from "effect" import { Deferred, Effect, Exit, Fiber, FiberSet, Scope } from "effect"
import type { Promotable } from "./inbox.js"
/** Serializes execution for each key while allowing different keys to run concurrently. */ /** Serializes execution for each key while allowing different keys to run concurrently. */
export interface Coordinator<Key, E, Reason = never> { export interface Coordinator<Key, E, Reason = never> {
@@ -9,7 +10,9 @@ export interface Coordinator<Key, E, Reason = never> {
/** Starts an execution while idle, or joins the active execution and returns its exit. */ /** Starts an execution while idle, or joins the active execution and returns its exit. */
readonly run: (key: Key) => Effect.Effect<void, E> readonly run: (key: Key) => Effect.Effect<void, E>
/** Rings the doorbell: an idle key starts an execution; an active one drains again before settling. */ /** Rings the doorbell: an idle key starts an execution; an active one drains again before settling. */
readonly wake: (key: Key) => Effect.Effect<void> readonly wake: (key: Key, scope?: Promotable) => Effect.Effect<void>
/** Rings the current execution's doorbell with its existing scope. Idle keys remain idle. */
readonly wakeActive: (key: Key) => Effect.Effect<void>
/** Stops the active execution, clears its doorbell, and waits for cleanup. No-op when idle. */ /** Stops the active execution, clears its doorbell, and waits for cleanup. No-op when idle. */
readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect<void> readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect<void>
/** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */ /** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */
@@ -19,14 +22,16 @@ export interface Coordinator<Key, E, Reason = never> {
/** /**
* One execution is a busy period for one key: one fiber that drains from the first wake * One execution is a busy period for one key: one fiber that drains from the first wake
* until the key would stay idle. `pendingWake` is the doorbell: work recorded during the * until the key would stay idle. `pendingWake` is the doorbell: work recorded during the
* execution rings it, and the execution loop drains again instead of ending. The doorbell * execution rings it with the scope that work needs, and the execution loop drains again
* closes the gap between a drain's last eligibility check and the idle transition, since * instead of ending. The doorbell closes the gap between a drain's last eligibility check
* those cannot be one atomic step. `done` resolves joiners with this execution's exit. * and the idle transition, since those cannot be one atomic step. `done` resolves joiners
* with this execution's exit.
*/ */
type Execution<E, Reason> = { type Execution<E, Reason> = {
readonly done: Deferred.Deferred<void, E> readonly done: Deferred.Deferred<void, E>
owner?: Fiber.Fiber<void> owner?: Fiber.Fiber<void>
pendingWake: boolean scope: Promotable
pendingWake?: Promotable
stopping: boolean stopping: boolean
interruptionReason?: Reason interruptionReason?: Reason
} }
@@ -43,7 +48,7 @@ type Execution<E, Reason> = {
* ``` * ```
*/ */
export const make = <Key, E, Reason = never>(options: { export const make = <Key, E, Reason = never>(options: {
readonly drain: (key: Key, force: boolean) => Effect.Effect<void, E> readonly drain: (key: Key, force: boolean, scope: Promotable) => Effect.Effect<void, E>
/** Runs once when a process-local busy period begins, before its first drain. */ /** Runs once when a process-local busy period begins, before its first drain. */
readonly started?: (key: Key) => Effect.Effect<void> readonly started?: (key: Key) => Effect.Effect<void>
/** /**
@@ -57,21 +62,22 @@ export const make = <Key, E, Reason = never>(options: {
const fork = yield* FiberSet.makeRuntime<never, void, never>() const fork = yield* FiberSet.makeRuntime<never, void, never>()
const loop = (key: Key, execution: Execution<E, Reason>, force: boolean): Effect.Effect<void, E> => const loop = (key: Key, execution: Execution<E, Reason>, force: boolean): Effect.Effect<void, E> =>
Effect.suspend(() => options.drain(key, force)).pipe( Effect.suspend(() => options.drain(key, force, execution.scope)).pipe(
Effect.flatMap(() => Effect.flatMap(() =>
Effect.suspend(() => { Effect.suspend(() => {
if (execution.stopping || !execution.pendingWake) return Effect.void if (execution.stopping || execution.pendingWake === undefined) return Effect.void
execution.pendingWake = false execution.scope = execution.pendingWake
execution.pendingWake = undefined
// Trampoline so drains that complete synchronously cannot grow the stack. // Trampoline so drains that complete synchronously cannot grow the stack.
return Effect.yieldNow.pipe(Effect.andThen(loop(key, execution, false))) return Effect.yieldNow.pipe(Effect.andThen(loop(key, execution, false)))
}), }),
), ),
) )
const start = (key: Key, force: boolean) => { const start = (key: Key, force: boolean, scope: Promotable) => {
const execution: Execution<E, Reason> = { const execution: Execution<E, Reason> = {
done: Deferred.makeUnsafe<void, E>(), done: Deferred.makeUnsafe<void, E>(),
pendingWake: false, scope,
stopping: false, stopping: false,
} }
executions.set(key, execution) executions.set(key, execution)
@@ -98,7 +104,7 @@ export const make = <Key, E, Reason = never>(options: {
// A doorbell that survives the execution loop (rung after the loop decided to end, or // A doorbell that survives the execution loop (rung after the loop decided to end, or
// during failure or interruption cleanup) starts a fresh execution for the remaining work. // during failure or interruption cleanup) starts a fresh execution for the remaining work.
const settle = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>) => { const settle = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>) => {
if (execution.pendingWake) start(key, false) if (execution.pendingWake) start(key, false, execution.pendingWake)
else executions.delete(key) else executions.delete(key)
Deferred.doneUnsafe(execution.done, exit) Deferred.doneUnsafe(execution.done, exit)
} }
@@ -111,17 +117,24 @@ export const make = <Key, E, Reason = never>(options: {
if (execution.stopping) return Deferred.await(execution.done).pipe(Effect.andThen(run(key))) if (execution.stopping) return Deferred.await(execution.done).pipe(Effect.andThen(run(key)))
return Deferred.await(execution.done) return Deferred.await(execution.done)
} }
return Deferred.await(start(key, true).done) return Deferred.await(start(key, true, "input").done)
}) })
const wake = (key: Key) => const wake = (key: Key, scope: Promotable = "input") =>
Effect.sync(() => { Effect.sync(() => {
const execution = executions.get(key) const execution = executions.get(key)
if (execution !== undefined) { if (execution !== undefined) {
execution.pendingWake = true // Coalesced wakes keep the widest scope: "input" subsumes "steer".
execution.pendingWake = execution.pendingWake === "input" ? "input" : scope
return return
} }
start(key, false) start(key, false, scope)
})
const wakeActive = (key: Key) =>
Effect.suspend(() => {
const execution = executions.get(key)
return execution ? wake(key, execution.scope) : Effect.void
}) })
const interrupt = (key: Key, reason?: Reason): Effect.Effect<void> => const interrupt = (key: Key, reason?: Reason): Effect.Effect<void> =>
@@ -129,7 +142,9 @@ export const make = <Key, E, Reason = never>(options: {
const execution = executions.get(key) const execution = executions.get(key)
if (execution?.owner === undefined || execution.stopping) return Effect.void if (execution?.owner === undefined || execution.stopping) return Effect.void
execution.stopping = true execution.stopping = true
execution.pendingWake = false // Wakes recorded so far belong to the interrupted intent; the interrupt claims them.
// Wakes arriving during cleanup are new admissions and restart normally at settle.
execution.pendingWake = undefined
execution.interruptionReason = reason execution.interruptionReason = reason
return Fiber.interrupt(execution.owner) return Fiber.interrupt(execution.owner)
}) })
@@ -143,5 +158,5 @@ export const make = <Key, E, Reason = never>(options: {
return Deferred.await(execution.done).pipe(Effect.exit, Effect.andThen(awaitIdle(key))) return Deferred.await(execution.done).pipe(Effect.exit, Effect.andThen(awaitIdle(key)))
}) })
return { active: Effect.sync(() => new Set(executions.keys())), run, wake, interrupt, awaitIdle } return { active: Effect.sync(() => new Set(executions.keys())), run, wake, wakeActive, interrupt, awaitIdle }
}) })
@@ -3,6 +3,7 @@ export * as SessionRunner from "./index.js"
import type { AIError } from "@opencode-ai/ai" import type { AIError } from "@opencode-ai/ai"
import { Context, Effect } from "effect" import { Context, Effect } from "effect"
import { SessionSchema } from "../schema.js" import { SessionSchema } from "../schema.js"
import type { Promotable } from "../inbox.js"
import type { AgentNotFoundError, MessageDecodeError, StepFailedError, UserInterruptedError } from "../error.js" import type { AgentNotFoundError, MessageDecodeError, StepFailedError, UserInterruptedError } from "../error.js"
import { SessionRunnerModel } from "./model.js" import { SessionRunnerModel } from "./model.js"
import type { Instructions } from "../../instructions/index.js" import type { Instructions } from "../../instructions/index.js"
@@ -29,6 +30,8 @@ export interface Interface {
readonly sessionID: SessionSchema.ID readonly sessionID: SessionSchema.ID
readonly force: boolean readonly force: boolean
readonly continuation?: Continuation readonly continuation?: Continuation
/** "steer" settles the active intent without promoting queued next-turn work. */
readonly promotable?: Promotable
}) => Effect.Effect<DrainResult, RunError> }) => Effect.Effect<DrainResult, RunError>
} }
+16 -14
View File
@@ -128,22 +128,25 @@ const layer = Layer.effect(
readonly sessionID: SessionSchema.ID readonly sessionID: SessionSchema.ID
readonly force: boolean readonly force: boolean
readonly continuation?: Continuation readonly continuation?: Continuation
readonly promotable?: SessionInbox.Promotable
}) { }) {
let force = input.force let force = input.force
let continuation = input.continuation let continuation = input.continuation
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, "any"))) const promotable = input.promotable ?? "input"
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, promotable)))
return { type: "complete" as const } return { type: "complete" as const }
yield* settleStaleToolCalls(input.sessionID) yield* settleStaleToolCalls(input.sessionID)
while (true) { while (true) {
if (yield* runPendingCompaction(input.sessionID)) { if (yield* runPendingCompaction(input.sessionID, promotable)) {
force = false force = false
continue continue
} }
if (yield* runPendingMove(input.sessionID, "input")) return { type: "moved" as const } if (yield* runPendingMove(input.sessionID, promotable)) return { type: "moved" as const }
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, "input"))) if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, promotable)))
return { type: "complete" as const } return { type: "complete" as const }
const result = yield* runSteps(input.sessionID, continuation) const result = yield* runSteps(input.sessionID, continuation, promotable)
if (result.type === "moved") return result if (result.type === "moved") return result
if (promotable === "steer") return { type: "complete" as const }
force = false force = false
continuation = undefined continuation = undefined
} }
@@ -155,14 +158,15 @@ const layer = Layer.effect(
*/ */
const runSteps = Effect.fn("SessionRunner.runSteps")(function* ( const runSteps = Effect.fn("SessionRunner.runSteps")(function* (
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
continuation?: Continuation, continuation: Continuation | undefined,
drainPromotable: SessionInbox.Promotable,
) { ) {
// Fresh work may promote queued input; later steps absorb steers only. // Fresh work may promote queued input; resumed turns and later steps absorb steers only.
let promotable: SessionInbox.Promotable = continuation ? "steer" : "input" let promotable: SessionInbox.Promotable = continuation ? "steer" : drainPromotable
let step = continuation?.step ?? 1 let step = continuation?.step ?? 1
let next = continuation let next = continuation
while (true) { while (true) {
if (yield* runPendingCompaction(sessionID)) continue if (yield* runPendingCompaction(sessionID, "steer")) continue
if (yield* runPendingMove(sessionID, "steer")) return { type: "moved" as const, continuation: next } if (yield* runPendingMove(sessionID, "steer")) return { type: "moved" as const, continuation: next }
const result = yield* runStep(sessionID, promotable, step) const result = yield* runStep(sessionID, promotable, step)
next = result.needsContinuation ? { step: result.step + 1 } : undefined next = result.needsContinuation ? { step: result.step + 1 } : undefined
@@ -515,14 +519,14 @@ const layer = Layer.effect(
/** Executes a previously admitted manual compaction request, if one is pending. */ /** Executes a previously admitted manual compaction request, if one is pending. */
const runPendingCompaction = Effect.fn("SessionRunner.runPendingCompaction")(function* ( const runPendingCompaction = Effect.fn("SessionRunner.runPendingCompaction")(function* (
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
promotable: SessionInbox.Promotable,
) { ) {
return yield* Effect.uninterruptibleMask((restore) => return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () { Effect.gen(function* () {
const pending = yield* SessionInbox.serialized( const pending = yield* SessionInbox.serialized(
sessionID, sessionID,
Effect.gen(function* () { Effect.gen(function* () {
const selected = const selected = yield* SessionInbox.nextPromotable(db, sessionID, promotable)
(yield* SessionInbox.nextSteer(db, sessionID)) ?? (yield* SessionInbox.nextQueued(db, sessionID))
if (selected?.type !== "compaction") return if (selected?.type !== "compaction") return
yield* bus.publishAll([ yield* bus.publishAll([
[SessionEvent.InboxDelivered, { sessionID, inboxID: selected.id }], [SessionEvent.InboxDelivered, { sessionID, inboxID: selected.id }],
@@ -564,9 +568,7 @@ const layer = Layer.effect(
return yield* SessionInbox.serialized( return yield* SessionInbox.serialized(
sessionID, sessionID,
Effect.gen(function* () { Effect.gen(function* () {
const pending = const pending = yield* SessionInbox.nextPromotable(db, sessionID, promotable)
(yield* SessionInbox.nextSteer(db, sessionID)) ??
(promotable === "input" ? yield* SessionInbox.nextQueued(db, sessionID) : undefined)
if (pending?.type !== "move") return false if (pending?.type !== "move") return false
yield* modelTransport.close(sessionID) yield* modelTransport.close(sessionID)
yield* bus.publishAll([ yield* bus.publishAll([
+30 -1
View File
@@ -102,6 +102,35 @@ describe("Catalog", () => {
}).pipe(Effect.provide(localCatalogLayer)) }).pipe(Effect.provide(localCatalogLayer))
}) })
it.effect("makes an explicitly enabled provider available without a connection", () => {
const integrationID = Integration.ID.make("gateway")
const providerID = Provider.ID.make("remote")
const localCatalogLayer = Layer.fresh(
AppNodeBuilder.build(LayerNode.group([Catalog.node, Credential.node, Integration.node]), [
[Location.node, locationLayer],
]),
)
return Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* (yield* Integration.Service).transform((editor) => editor.update(integrationID, () => {}))
yield* catalog.transform((editor) =>
editor.provider.update(providerID, (provider) => {
provider.integrationID = integrationID
provider.settings = { baseURL: "https://gateway.example.com/v1" }
}),
)
expect(yield* catalog.provider.available()).toEqual([])
yield* catalog.transform((editor) =>
editor.provider.update(providerID, (provider) => {
provider.activation = "enabled"
}),
)
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([providerID])
}).pipe(Effect.provide(localCatalogLayer))
})
it.effect("projects environment connections without a catalog plugin", () => it.effect("projects environment connections without a catalog plugin", () =>
Effect.acquireUseRelease( Effect.acquireUseRelease(
Effect.sync(() => { Effect.sync(() => {
@@ -278,7 +307,7 @@ describe("Catalog", () => {
const fallbackModel = Model.ID.make("fallback") const fallbackModel = Model.ID.make("fallback")
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
catalog.provider.update(disabledProvider, (provider) => { catalog.provider.update(disabledProvider, (provider) => {
provider.disabled = true provider.activation = "disabled"
}) })
catalog.model.update(disabledProvider, disabledModel, () => {}) catalog.model.update(disabledProvider, disabledModel, () => {})
catalog.provider.update(enabledProvider, () => {}) catalog.provider.update(enabledProvider, () => {})
+1 -1
View File
@@ -342,7 +342,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
names: ["CUSTOM_API_KEY"], names: ["CUSTOM_API_KEY"],
}) })
expect((yield* integrations.get(Integration.ID.make("custom")))?.name).toBe("Renamed") expect((yield* integrations.get(Integration.ID.make("custom")))?.name).toBe("Renamed")
expect(provider.disabled).toBeUndefined() expect(provider.activation).toBe("enabled")
expect(provider.package).toBe("aisdk:custom-sdk") expect(provider.package).toBe("aisdk:custom-sdk")
expect(provider.settings).toEqual({ baseURL: "https://example.test" }) expect(provider.settings).toEqual({ baseURL: "https://example.test" })
expect(provider.headers).toEqual({ first: "first", shared: "last", last: "last" }) expect(provider.headers).toEqual({ first: "first", shared: "last", last: "last" })
+56 -1
View File
@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, spyOn, test } from "bun:test"
import fuzzysort from "fuzzysort"
import os from "os" import os from "os"
import path from "path" import path from "path"
import { Deferred, Effect, Layer } from "effect" import { Deferred, Effect, Layer } from "effect"
@@ -123,4 +124,58 @@ describe("FileSystemSearch", () => {
}).pipe(Effect.provide(layer), Effect.provide(TestClock.layer()), Effect.scoped), }).pipe(Effect.provide(layer), Effect.provide(TestClock.layer()), Effect.scoped),
) )
}) })
test("reuses location-owned fuzzy targets across index refreshes", async () => {
let scans = 0
const first = Effect.runSync(Deferred.make<void>())
const second = Effect.runSync(Deferred.make<void>())
const prepare = spyOn(fuzzysort, "prepare")
const cleanup = spyOn(fuzzysort, "cleanup")
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-cache")) }),
),
),
],
[
Ripgrep.node,
Layer.succeed(
Ripgrep.Service,
Ripgrep.Service.of({
find: (input) =>
Effect.gen(function* () {
scans++
const entry = FileSystem.Entry.make({ path: RelativePath.make("src/index.ts"), type: "file" })
if (input.onEntry) yield* input.onEntry(entry)
yield* Deferred.succeed(scans === 1 ? first : second, undefined)
return [entry]
}),
glob: () => Effect.succeed([]),
grep: () => Effect.succeed([]),
}),
),
],
])
await Effect.runPromise(
Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
yield* Deferred.await(first)
yield* search.find({ query: "index", type: "file" })
yield* TestClock.adjust("10 seconds")
yield* search.find({ query: "index", type: "file" })
yield* Deferred.await(second)
yield* search.find({ query: "index", type: "file" })
expect(prepare).toHaveBeenCalledTimes(2)
expect(cleanup).toHaveBeenCalledTimes(3)
}).pipe(Effect.provide(layer), Effect.provide(TestClock.layer()), Effect.scoped),
)
prepare.mockRestore()
cleanup.mockRestore()
})
}) })
+7 -3
View File
@@ -510,7 +510,7 @@ describe("LocationServiceMap", () => {
), ),
) )
it.live("normalizes ref key shapes to one cached location graph", () => it.live("normalizes equivalent refs to one cached location graph", () =>
Effect.acquireRelease( Effect.acquireRelease(
Effect.promise(() => tmpdir()), Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
@@ -520,16 +520,20 @@ describe("LocationServiceMap", () => {
Effect.gen(function* () { Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service const locations = yield* LocationServiceMap.Service
const directory = AbsolutePath.make(dir.path) const directory = AbsolutePath.make(dir.path)
const absent = Location.Ref.make({ directory }) const alternate = AbsolutePath.make(directory.replaceAll("\\", "/"))
const absent = Location.Ref.make({ directory: alternate })
const present = Location.Ref.make({ directory, workspaceID: undefined }) const present = Location.Ref.make({ directory, workspaceID: undefined })
// The two shapes are not structurally Equal: own-key sets differ. // The two shapes are not structurally Equal: own-key sets differ.
expect(Object.keys(absent)).toEqual(["directory"]) expect(Object.keys(absent)).toEqual(["directory"])
expect(Object.keys(present)).toEqual(["directory", "workspaceID"]) expect(Object.keys(present)).toEqual(["directory", "workspaceID"])
expect(Equal.equals(absent, present)).toBe(false) expect(Equal.equals(absent, present)).toBe(false)
if (process.platform === "win32") expect(absent.directory).not.toBe(present.directory)
const first = yield* locations.contextEffect(absent) const first = yield* locations.contextEffect(absent)
expect(yield* locations.contextEffect(present)).toBe(first) expect(yield* locations.contextEffect(present)).toBe(first)
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toHaveLength(1) expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([
Location.Ref.make({ directory, workspaceID: undefined }),
])
// Invalidating with the shape opposite to the one that booted must evict. // Invalidating with the shape opposite to the one that booted must evict.
yield* locations.invalidate(present) yield* locations.invalidate(present)
+107 -1
View File
@@ -2,13 +2,16 @@ import { describe, expect } from "bun:test"
import { LLM, LanguageModel } from "@opencode-ai/ai" import { LLM, LanguageModel } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols" import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { compileRequest } from "@opencode-ai/ai/route/client" import { compileRequest } from "@opencode-ai/ai/route/client"
import { Effect } from "effect" import { Effect, Layer } from "effect"
import { Headers } from "effect/unstable/http" import { Headers } from "effect/unstable/http"
import { Credential } from "@opencode-ai/core/credential" import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration" import { Integration } from "@opencode-ai/core/integration"
import { Compatibility, ID, Info, VariantID } from "@opencode-ai/core/model" import { Compatibility, ID, Info, VariantID } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider" import { Provider } from "@opencode-ai/core/provider"
import { ModelResolver } from "@opencode-ai/core/model-resolver" import { ModelResolver } from "@opencode-ai/core/model-resolver"
import { Catalog } from "@opencode-ai/core/catalog"
import { AISDK } from "@opencode-ai/core/aisdk"
import { Npm } from "@opencode-ai/util/npm"
import { it } from "./lib/effect" import { it } from "./lib/effect"
interface ModelOptions { interface ModelOptions {
@@ -269,6 +272,109 @@ describe("ModelResolver", () => {
}), }),
) )
it.effect("uses no native API-key auth for an explicitly enabled provider without credentials", () => {
const selected = model(Provider.aisdk("@ai-sdk/google"), {
providerID: Provider.ID.make("gateway"),
settings: { baseURL: "https://gateway.example.com/v1" },
headers: { "cf-access-token": "access-token" },
})
const provider = Provider.Info.make({
...Provider.Info.empty(selected.providerID),
activation: "enabled",
package: selected.package ?? "",
settings: selected.settings,
headers: selected.headers,
})
const catalog = Layer.mock(Catalog.Service, {
provider: {
get: () => Effect.succeed(provider),
all: () => Effect.die("unused"),
available: () => Effect.die("unused"),
},
model: {
get: () => Effect.succeed(selected),
all: () => Effect.die("unused"),
available: () => Effect.die("unused"),
default: () => Effect.die("unused"),
small: () => Effect.die("unused"),
},
})
const integrations = Layer.mock(Integration.Service, {
connection: {
active: () => Effect.succeed(undefined),
resolve: () => Effect.die("unused"),
key: () => Effect.die("unused"),
update: () => Effect.die("unused"),
remove: () => Effect.die("unused"),
},
oauth: {
connect: () => Effect.die("unused"),
status: () => Effect.die("unused"),
complete: () => Effect.die("unused"),
cancel: () => Effect.die("unused"),
},
command: {
connect: () => Effect.die("unused"),
status: () => Effect.die("unused"),
cancel: () => Effect.die("unused"),
},
})
const npm = Layer.mock(Npm.Service, {
add: () => Effect.die("unused"),
which: () => Effect.die("unused"),
})
const aisdk = Layer.mock(AISDK.Service, {
hook: {
sdk: () => Effect.die("unused"),
language: () => Effect.die("unused"),
},
model: () => Effect.die("unused"),
})
const layer = ModelResolver.layer.pipe(Layer.provide(Layer.mergeAll(catalog, integrations, npm, aisdk)))
return withEnv({ GOOGLE_GENERATIVE_AI_API_KEY: undefined }, () =>
Effect.gen(function* () {
const resolver = yield* ModelResolver.Service
const resolved = yield* resolver.resolveModel(selected)
const headers = yield* resolved.model.route.auth.apply({
request: LLM.request({ model: resolved.model, prompt: "Hello" }),
method: "POST",
url: "https://gateway.example.com/v1",
body: "{}",
headers: Headers.fromInput(resolved.model.route.defaults.headers),
})
expect(headers["cf-access-token"]).toBe("access-token")
expect(headers.authorization).toBeUndefined()
expect(headers["x-goog-api-key"]).toBeUndefined()
}).pipe(Effect.provide(layer)),
)
})
it.effect("keeps native provider environment auth strict when no API key is configured", () =>
withEnv({ GOOGLE_GENERATIVE_AI_API_KEY: undefined }, () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/google"), {
settings: { baseURL: "https://google.example.com/v1" },
}),
)
const exit = yield* Effect.exit(
resolved.route.auth.apply({
request: LLM.request({ model: resolved, prompt: "Hello" }),
method: "POST",
url: "https://google.example.com/v1",
body: "{}",
headers: Headers.empty,
}),
)
expect(exit._tag).toBe("Failure")
}),
),
)
it.effect("uses merged API settings for OpenAI-compatible auth and request defaults", () => it.effect("uses merged API settings for OpenAI-compatible auth and request defaults", () =>
Effect.gen(function* () { Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel( const resolved = yield* ModelResolver.fromCatalogModel(
+2
View File
@@ -47,6 +47,7 @@ const fixtureSnapshot = [
info: { info: {
id: Provider.ID.make("acme"), id: Provider.ID.make("acme"),
name: "Acme", name: "Acme",
activation: "auto",
package: Provider.aisdk("@ai-sdk/openai-compatible"), package: Provider.aisdk("@ai-sdk/openai-compatible"),
}, },
models: [ models: [
@@ -109,6 +110,7 @@ const fixture2Snapshot = [
info: { info: {
id: Provider.ID.make("beta"), id: Provider.ID.make("beta"),
name: "Beta", name: "Beta",
activation: "auto",
package: Provider.aisdk("@ai-sdk/openai-compatible"), package: Provider.aisdk("@ai-sdk/openai-compatible"),
}, },
models: [ models: [
@@ -64,6 +64,7 @@ describe("ModelsDevPlugin", () => {
info: { info: {
id: providerID, id: providerID,
name: "Acme", name: "Acme",
activation: "auto",
package: Provider.aisdk("@ai-sdk/openai-compatible"), package: Provider.aisdk("@ai-sdk/openai-compatible"),
settings: { baseURL: "https://api.acme.test/v1" }, settings: { baseURL: "https://api.acme.test/v1" },
}, },
@@ -239,6 +240,7 @@ describe("ModelsDevPlugin", () => {
info: { info: {
id: providerID, id: providerID,
name: "Acme", name: "Acme",
activation: "auto",
package: Provider.aisdk("@ai-sdk/openai-compatible"), package: Provider.aisdk("@ai-sdk/openai-compatible"),
}, },
environment: [], environment: [],
@@ -330,6 +332,7 @@ describe("ModelsDevPlugin", () => {
info: { info: {
id: providerID, id: providerID,
name: "Acme", name: "Acme",
activation: "auto",
package: Provider.aisdk("@ai-sdk/openai-compatible"), package: Provider.aisdk("@ai-sdk/openai-compatible"),
settings: { baseURL: "https://${ACME_HOST}/${UNDECLARED_HOST}/v1" }, settings: { baseURL: "https://${ACME_HOST}/${UNDECLARED_HOST}/v1" },
}, },
@@ -385,6 +388,7 @@ describe("ModelsDevPlugin", () => {
info: { info: {
id: Provider.ID.make(id), id: Provider.ID.make(id),
name, name,
activation: "auto",
package: Provider.aisdk(packageName), package: Provider.aisdk(packageName),
}, },
environment: id === "azure" ? ["AZURE_RESOURCE_NAME", environment] : [environment], environment: id === "azure" ? ["AZURE_RESOURCE_NAME", environment] : [environment],
@@ -60,14 +60,14 @@ describe("LLMGatewayPlugin", () => {
}) })
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
catalog.provider.update(Provider.ID.make("llmgateway"), (provider) => { catalog.provider.update(Provider.ID.make("llmgateway"), (provider) => {
provider.disabled = true provider.activation = "disabled"
provider.package = Provider.aisdk("@ai-sdk/openai-compatible") provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
provider.settings = { baseURL: "https://api.llmgateway.io/v1" } provider.settings = { baseURL: "https://api.llmgateway.io/v1" }
}) })
}) })
yield* addPlugin() yield* addPlugin()
expect((yield* catalog.provider.get(Provider.ID.make("llmgateway")))?.disabled).toBe(true) expect((yield* catalog.provider.get(Provider.ID.make("llmgateway")))?.activation).toBe("disabled")
expect((yield* catalog.provider.get(Provider.ID.make("llmgateway")))?.headers).toBeUndefined() expect((yield* catalog.provider.get(Provider.ID.make("llmgateway")))?.headers).toBeUndefined()
}), }),
) )
@@ -347,6 +347,8 @@ describe("OpencodePlugin", () => {
}) })
yield* addPlugin() yield* addPlugin()
expect(required(yield* catalog.provider.get(Provider.ID.opencode)).settings?.apiKey).toBe("public") expect(required(yield* catalog.provider.get(Provider.ID.opencode)).settings?.apiKey).toBe("public")
expect(required(yield* catalog.provider.get(Provider.ID.opencode)).activation).toBe("enabled")
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(Provider.ID.opencode)
expect(required(yield* catalog.model.get(Provider.ID.opencode, Model.ID.make("free"))).enabled).toBe(true) expect(required(yield* catalog.model.get(Provider.ID.opencode, Model.ID.make("free"))).enabled).toBe(true)
}), }),
), ),
+110 -1
View File
@@ -14,8 +14,10 @@ import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionRestart } from "@opencode-ai/core/session/execution/restart" import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
import { UserInterruptedError } from "@opencode-ai/core/session/error" import { UserInterruptedError } from "@opencode-ai/core/session/error"
import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionRunner } from "@opencode-ai/core/session/runner/index" import { SessionRunner } from "@opencode-ai/core/session/runner/index"
import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionInboxTable, SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store" import { SessionStore } from "@opencode-ai/core/session/store"
import { Context, Deferred, Effect, Exit, Fiber, Layer, LayerMap, Scope } from "effect" import { Context, Deferred, Effect, Exit, Fiber, Layer, LayerMap, Scope } from "effect"
import { eq } from "drizzle-orm" import { eq } from "drizzle-orm"
@@ -290,6 +292,113 @@ describe("SessionExecution lifecycle", () => {
) )
}) })
describe("SessionExecution interrupt continuation", () => {
it.effect("resumes only steering input after an interrupt with continue", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const sessionID = Session.ID.make("ses_continue_steer")
yield* seedSessions(database, [sessionID])
yield* seedInbox(database, sessionID, ["steer", "queue"])
const draining = yield* Deferred.make<void>()
const drains: Array<{ force: boolean; promotable?: SessionInbox.Promotable }> = []
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const context = yield* buildExecution(scope, (input) =>
Effect.suspend(() => {
drains.push({ force: input.force, promotable: input.promotable })
if (drains.length > 1) return Effect.void
return Deferred.succeed(draining, undefined).pipe(Effect.andThen(Effect.never))
}),
)
const execution = Context.get(context, SessionExecution.Service)
yield* execution.resume(sessionID).pipe(Effect.forkScoped)
yield* Deferred.await(draining)
yield* execution.interrupt(sessionID, { continue: true })
yield* execution.awaitIdle(sessionID)
// The successor drain is steer-scoped: queued next-turn work stays parked.
expect(drains).toEqual([
{ force: true, promotable: "input" },
{ force: false, promotable: "steer" },
])
}),
)
it.effect("stays parked after an interrupt with continue when only queued work remains", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const sessionID = Session.ID.make("ses_continue_parked")
yield* seedSessions(database, [sessionID])
yield* seedInbox(database, sessionID, ["queue"])
const draining = yield* Deferred.make<void>()
const drains: Array<SessionInbox.Promotable | undefined> = []
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const context = yield* buildExecution(scope, (input) =>
Effect.suspend(() => {
drains.push(input.promotable)
return Deferred.succeed(draining, undefined).pipe(Effect.andThen(Effect.never))
}),
)
const execution = Context.get(context, SessionExecution.Service)
yield* execution.resume(sessionID).pipe(Effect.forkScoped)
yield* Deferred.await(draining)
yield* execution.interrupt(sessionID, { continue: true })
yield* execution.awaitIdle(sessionID)
expect(drains).toEqual(["input"])
expect(yield* execution.active).toEqual(new Set())
}),
)
it.effect("an idle interrupt with continue resumes pending steers", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const sessionID = Session.ID.make("ses_continue_idle")
yield* seedSessions(database, [sessionID])
yield* seedInbox(database, sessionID, ["steer"])
const drains: Array<{ force: boolean; promotable?: SessionInbox.Promotable }> = []
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const context = yield* buildExecution(scope, (input) =>
Effect.sync(() => void drains.push({ force: input.force, promotable: input.promotable })),
)
const execution = Context.get(context, SessionExecution.Service)
yield* execution.interrupt(sessionID, { continue: true })
yield* execution.awaitIdle(sessionID)
expect(drains).toEqual([{ force: false, promotable: "steer" }])
}),
)
})
function seedInbox(
database: Database.Service["Service"],
sessionID: Session.ID,
deliveries: ReadonlyArray<SessionInbox.Delivery>,
) {
return database.db
.insert(SessionInboxTable)
.values(
deliveries.map((delivery, index) => ({
id: SessionMessage.ID.create(),
session_id: sessionID,
type: "compaction" as const,
payload: {},
delivery,
enqueued_seq: index + 1,
})),
)
.run()
.pipe(Effect.orDie)
}
function seedSessions( function seedSessions(
database: Database.Service["Service"], database: Database.Service["Service"],
sessionIDs: ReadonlyArray<Session.ID>, sessionIDs: ReadonlyArray<Session.ID>,
+7 -17
View File
@@ -31,6 +31,7 @@ import { testEffect } from "./lib/effect"
const executionCalls: Session.ID[] = [] const executionCalls: Session.ID[] = []
const interruptCalls: Session.ID[] = [] const interruptCalls: Session.ID[] = []
const interruptContinuations: Array<boolean | undefined> = []
const wakeCalls: Session.ID[] = [] const wakeCalls: Session.ID[] = []
const activeSessions = new Set<Session.ID>() const activeSessions = new Set<Session.ID>()
const execution = Layer.succeed( const execution = Layer.succeed(
@@ -41,14 +42,16 @@ const execution = Layer.succeed(
Effect.sync(() => { Effect.sync(() => {
executionCalls.push(sessionID) executionCalls.push(sessionID)
}), }),
interrupt: (sessionID) => interrupt: (sessionID, options) =>
Effect.sync(() => { Effect.sync(() => {
interruptCalls.push(sessionID) interruptCalls.push(sessionID)
interruptContinuations.push(options?.continue)
}), }),
wake: (sessionID) => wake: (sessionID) =>
Effect.sync(() => { Effect.sync(() => {
wakeCalls.push(sessionID) wakeCalls.push(sessionID)
}), }),
wakeActive: () => Effect.void,
awaitIdle: () => Effect.void, awaitIdle: () => Effect.void,
}), }),
) )
@@ -177,31 +180,18 @@ describe("Session.prompt", () => {
}), }),
) )
it.effect("continues after interruption when pending work remains", () => it.effect("forwards interrupt continuation policy", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
yield* session.synthetic({ sessionID, text: "Continue after interrupt", resume: false })
interruptCalls.length = 0
wakeCalls.length = 0
yield* session.interrupt(sessionID, { continue: true })
expect(interruptCalls).toEqual([sessionID])
expect(wakeCalls).toEqual([sessionID])
}),
)
it.effect("does not continue after interruption without pending work", () =>
Effect.gen(function* () { Effect.gen(function* () {
yield* setup yield* setup
const session = yield* Session.Service const session = yield* Session.Service
interruptCalls.length = 0 interruptCalls.length = 0
interruptContinuations.length = 0
wakeCalls.length = 0 wakeCalls.length = 0
yield* session.interrupt(sessionID, { continue: true }) yield* session.interrupt(sessionID, { continue: true })
expect(interruptCalls).toEqual([sessionID]) expect(interruptCalls).toEqual([sessionID])
expect(interruptContinuations).toEqual([true])
expect(wakeCalls).toEqual([]) expect(wakeCalls).toEqual([])
}), }),
) )
@@ -1,5 +1,6 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect" import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator" import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
@@ -269,6 +270,28 @@ describe("SessionRunCoordinator", () => {
), ),
) )
it.effect("a settlement-window wake starts a fresh execution with its own scope", () =>
Effect.scoped(
Effect.gen(function* () {
const settling = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const scopes: SessionInbox.Promotable[] = []
const coordinator = yield* SessionRunCoordinator.make({
drain: (_key, _force, scope) => Effect.sync(() => scopes.push(scope)),
settled: () => Deferred.succeed(settling, undefined).pipe(Effect.andThen(Deferred.await(release))),
})
yield* coordinator.wake("session", "steer")
yield* Deferred.await(settling)
yield* coordinator.wake("session", "input")
yield* Deferred.succeed(release, undefined)
yield* coordinator.awaitIdle("session")
expect(scopes).toEqual(["steer", "input"])
}),
),
)
it.effect("interrupts active execution and clears its pending wake", () => it.effect("interrupts active execution and clears its pending wake", () =>
Effect.scoped( Effect.scoped(
Effect.gen(function* () { Effect.gen(function* () {
@@ -342,6 +365,126 @@ describe("SessionRunCoordinator", () => {
), ),
) )
it.effect("coalesces drain scopes with input taking precedence", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const scopes: SessionInbox.Promotable[] = []
const coordinator = yield* SessionRunCoordinator.make({
drain: (_key, _force, scope) =>
Effect.gen(function* () {
scopes.push(scope)
if (scopes.length !== 1) return
yield* Deferred.succeed(firstStarted, undefined)
yield* Deferred.await(release)
}),
})
yield* coordinator.wake("session", "steer")
yield* Deferred.await(firstStarted)
yield* coordinator.wake("session", "steer")
yield* coordinator.wake("session", "input")
yield* Deferred.succeed(release, undefined)
yield* coordinator.awaitIdle("session")
expect(scopes).toEqual(["steer", "input"])
}),
),
)
it.effect("does not carry a completed input scope into a steer drain", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const scopes: SessionInbox.Promotable[] = []
const coordinator = yield* SessionRunCoordinator.make({
drain: (_key, _force, scope) =>
Effect.gen(function* () {
scopes.push(scope)
if (scopes.length !== 1) return
yield* Deferred.succeed(firstStarted, undefined)
yield* Deferred.await(release)
}),
})
yield* coordinator.wake("session", "input")
yield* Deferred.await(firstStarted)
yield* coordinator.wake("session", "steer")
yield* Deferred.succeed(release, undefined)
yield* coordinator.awaitIdle("session")
expect(scopes).toEqual(["input", "steer"])
}),
),
)
it.effect("an active wake inherits scope without starting idle work", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const scopes: SessionInbox.Promotable[] = []
const coordinator = yield* SessionRunCoordinator.make({
drain: (_key, _force, scope) =>
Effect.gen(function* () {
scopes.push(scope)
if (scopes.length !== 1) return
yield* Deferred.succeed(firstStarted, undefined)
yield* Deferred.await(release)
}),
})
yield* coordinator.wakeActive("session")
yield* coordinator.wake("session", "steer")
yield* Deferred.await(firstStarted)
yield* coordinator.wakeActive("session")
yield* Deferred.succeed(release, undefined)
yield* coordinator.awaitIdle("session")
expect(scopes).toEqual(["steer", "steer"])
}),
),
)
it.effect("a cleanup-era wake starts a successor with its own scope", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const cleanupStarted = yield* Deferred.make<void>()
const cleanupGate = yield* Deferred.make<void>()
const scopes: SessionInbox.Promotable[] = []
const coordinator = yield* SessionRunCoordinator.make({
drain: (_key, _force, scope) =>
Effect.gen(function* () {
scopes.push(scope)
if (scopes.length !== 1) return
yield* Deferred.succeed(firstStarted, undefined)
yield* Effect.never.pipe(
Effect.onInterrupt(() =>
Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))),
),
)
}),
})
yield* coordinator.wake("session", "input")
yield* Deferred.await(firstStarted)
const interrupt = yield* coordinator.interrupt("session").pipe(Effect.forkChild)
yield* Deferred.await(cleanupStarted)
// A new admission during cancellation restarts normally: interruption only
// claims the wakes recorded before it.
yield* coordinator.wake("session", "input")
yield* Deferred.succeed(cleanupGate, undefined)
yield* Fiber.join(interrupt)
yield* coordinator.awaitIdle("session")
expect(scopes).toEqual(["input", "input"])
}),
),
)
it.effect("starts a resume registered during interruption cleanup", () => it.effect("starts a resume registered during interruption cleanup", () =>
Effect.scoped( Effect.scoped(
Effect.gen(function* () { Effect.gen(function* () {
@@ -126,7 +126,8 @@ const execution = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
active: coordinator.active, active: coordinator.active,
resume: coordinator.run, resume: coordinator.run,
wake: coordinator.wake, wake: coordinator.wake,
interrupt: coordinator.interrupt, wakeActive: coordinator.wakeActive,
interrupt: (sessionID) => coordinator.interrupt(sessionID),
awaitIdle: coordinator.awaitIdle, awaitIdle: coordinator.awaitIdle,
}) })
}), }),
+58 -1
View File
@@ -413,7 +413,8 @@ const execution = Layer.effect(
active: coordinator.active, active: coordinator.active,
resume: coordinator.run, resume: coordinator.run,
wake: coordinator.wake, wake: coordinator.wake,
interrupt: coordinator.interrupt, wakeActive: coordinator.wakeActive,
interrupt: (sessionID) => coordinator.interrupt(sessionID),
awaitIdle: coordinator.awaitIdle, awaitIdle: coordinator.awaitIdle,
}) })
}), }),
@@ -1383,6 +1384,44 @@ describe("SessionRunnerLLM", () => {
}), }),
) )
it.effect("keeps queued input parked across a mid-turn move", () =>
Effect.gen(function* () {
const session = yield* setup
const bus = yield* Bus.Service
const { db } = yield* Database.Service
yield* admit(session, "Echo before moving")
yield* TestLLM.push(
TestLLM.tool("call-move", "echo", { text: "moving" }),
TestLLM.text("Done", "text-after-move"),
TestLLM.text("Handled queue", "text-after-queue"),
)
const tools = yield* blockTools()
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* tools.started
yield* session.prompt({ sessionID, text: "Queued for later", delivery: "queue", resume: false })
yield* SessionInbox.admit(db, bus, {
id: SessionMessage.ID.create(),
sessionID,
item: {
type: "move",
payload: {
location: Location.Ref.make({ directory: AbsolutePath.make("/project") }),
projectID: Project.ID.global,
},
delivery: "steer",
},
})
yield* tools.release
yield* Fiber.join(run)
// The resumed turn absorbs steers only; queued input waits for the turn to end.
expect(requests).toHaveLength(3)
expect(userTexts(requests[1])).not.toContain("Queued for later")
expect(userTexts(requests[2])).toContain("Queued for later")
}),
)
it.effect("seeds a fork with the parent's newest instruction values", () => it.effect("seeds a fork with the parent's newest instruction values", () =>
Effect.gen(function* () { Effect.gen(function* () {
const session = yield* setup const session = yield* setup
@@ -3088,6 +3127,24 @@ describe("SessionRunnerLLM", () => {
}), }),
) )
it.effect("stops a steer-scoped drain before queued input", () =>
Effect.gen(function* () {
const session = yield* setup
const { db } = yield* Database.Service
yield* session.prompt({ sessionID, text: "Queue for later", delivery: "queue", resume: false })
yield* session.prompt({ sessionID, text: "Steer now", resume: false })
yield* TestLLM.push(TestLLM.stop())
const runner = yield* SessionRunner.Service
yield* runner.drain({ sessionID, force: false, promotable: "steer" })
expect(requests).toHaveLength(1)
expect(userTexts(requests[0])).toEqual(["Steer now"])
expect(yield* SessionInbox.has(db, sessionID, "steer")).toBe(false)
expect(yield* SessionInbox.has(db, sessionID, "queue")).toBe(true)
}),
)
it.effect("promotes queued input after steering continuation ends", () => it.effect("promotes queued input after steering continuation ends", () =>
Effect.gen(function* () { Effect.gen(function* () {
const session = yield* setup const session = yield* setup
+1
View File
@@ -115,6 +115,7 @@ const executionNode = makeGlobalNode({
active: Effect.succeed(new Set()), active: Effect.succeed(new Set()),
resume: complete, resume: complete,
wake: () => Effect.void, wake: () => Effect.void,
wakeActive: () => Effect.void,
interrupt: () => Effect.void, interrupt: () => Effect.void,
awaitIdle: (id) => complete(id).pipe(Effect.exit, Effect.asVoid), awaitIdle: (id) => complete(id).pipe(Effect.exit, Effect.asVoid),
}) })
+1
View File
@@ -86,6 +86,7 @@ const executionNode = makeGlobalNode({
active: Effect.succeed(new Set()), active: Effect.succeed(new Set()),
resume: complete, resume: complete,
wake: () => Effect.void, wake: () => Effect.void,
wakeActive: () => Effect.void,
interrupt: () => Effect.void, interrupt: () => Effect.void,
awaitIdle: (sessionID) => complete(sessionID).pipe(Effect.exit, Effect.asVoid), awaitIdle: (sessionID) => complete(sessionID).pipe(Effect.exit, Effect.asVoid),
}) })
+3 -4
View File
@@ -24,8 +24,7 @@ import {
isOldLayoutEligible, isOldLayoutEligible,
} from "./onboarding" } from "./onboarding"
import { getDefaultServerUrl, preferAppEnv, setDefaultServerUrl } from "./server" import { getDefaultServerUrl, preferAppEnv, setDefaultServerUrl } from "./server"
import { setupAutoUpdater, showUpdaterDialog } from "./updater" import { registerUpdaterIpc, setupAutoUpdater, showUpdaterDialog } from "./updater"
import { registerUpdaterIpc } from "./updater-ipc"
import { safeWebContentsURL } from "./window-state" import { safeWebContentsURL } from "./window-state"
import { import {
getLastFocusedWindow, getLastFocusedWindow,
@@ -242,7 +241,7 @@ const main = Effect.gen(function* () {
const win = getLastFocusedWindow() const win = getLastFocusedWindow()
if (win) sendMenuCommand(win, id) if (win) sendMenuCommand(win, id)
}, },
checkForUpdates: () => void showUpdaterDialog(updater, true), checkForUpdates: () => void showUpdaterDialog(updater),
relaunch, relaunch,
} }
registerIpcHandlers({ registerIpcHandlers({
@@ -267,7 +266,7 @@ const main = Effect.gen(function* () {
setDisplayBackend: async () => undefined, setDisplayBackend: async () => undefined,
checkAppExists: (appName) => checkAppExists(appName), checkAppExists: (appName) => checkAppExists(appName),
resolveAppPath: async (appName) => resolveAppPath(appName), resolveAppPath: async (appName) => resolveAppPath(appName),
showUpdater: () => showUpdaterDialog(updater, true), showUpdater: () => showUpdaterDialog(updater),
setBackgroundColor: (color) => setBackgroundColor(color), setBackgroundColor: (color) => setBackgroundColor(color),
exportDebugLogs: () => exportDebugLogs(), exportDebugLogs: () => exportDebugLogs(),
recordFatalRendererError: (error) => writeLog("renderer", "fatal renderer error", { ...error }, "error"), recordFatalRendererError: (error) => writeLog("renderer", "fatal renderer error", { ...error }, "error"),
@@ -1,26 +1,35 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { createUpdaterController, type UpdaterPlatform, type UpdaterReadyRecord } from "./updater-controller" import { createUpdaterController, type UpdaterReadyRecord } from "./updater-controller"
function setup(input?: { currentVersion?: string; ready?: UpdaterReadyRecord }) { // Drives the controller the way the app does: start or check, observe the states
// the renderer sees, then install like a button click. `calls` records the platform
// operations in order; installs record the staged version they would apply.
function setup(input?: {
currentVersion?: string
ready?: UpdaterReadyRecord
latest?: () => string
stage?: () => Promise<void>
install?: () => Promise<never>
}) {
const calls: string[] = [] const calls: string[] = []
const platform: UpdaterPlatform = { const states: string[] = []
async checkForUpdate() {
calls.push("check")
return "2.0.0"
},
async stageUpdate() {
calls.push("download")
},
installAndRestart() {
calls.push("install")
return new Promise<never>(() => {})
},
}
let ready = input?.ready let ready = input?.ready
const controller = createUpdaterController({ const controller = createUpdaterController({
enabled: true,
currentVersion: input?.currentVersion ?? "1.0.0", currentVersion: input?.currentVersion ?? "1.0.0",
platform, platform: {
async checkForUpdate() {
calls.push("check")
return input?.latest?.() ?? "2.0.0"
},
async stageUpdate() {
calls.push("download")
await input?.stage?.()
},
installAndRestart() {
calls.push(`install:${ready?.version}`)
return input?.install?.() ?? new Promise<never>(() => {})
},
},
lifecycle: { lifecycle: {
async prepareToRestart() { async prepareToRestart() {
calls.push("prepare") calls.push("prepare")
@@ -36,21 +45,29 @@ function setup(input?: { currentVersion?: string; ready?: UpdaterReadyRecord })
}, },
}, },
}) })
return { controller, calls, getReady: () => ready } controller.subscribe((state) => states.push(state.status))
return { controller, calls, states, getReady: () => ready }
} }
describe("updater controller", () => { describe("updater controller", () => {
test("checks, downloads, persists, and publishes one authoritative ready state", async () => { test("stages an update found at launch and shows it as ready", async () => {
const app = setup() const app = setup()
const states: ReturnType<typeof app.controller.getState>[] = []
app.controller.subscribe((state) => states.push(state))
await app.controller.start() await app.controller.start()
expect(app.calls).toEqual(["check", "download"]) expect(app.states).toEqual(["idle", "checking", "downloading", "ready"])
expect(app.getReady()).toEqual({ version: "2.0.0" })
expect(states.map((state) => state.status)).toEqual(["idle", "checking", "downloading", "ready"])
expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" }) expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
expect(app.getReady()).toEqual({ version: "2.0.0" })
})
test("reports up to date and clears the record once the update is installed", async () => {
const app = setup({ currentVersion: "2.0.0", ready: { version: "2.0.0" } })
await app.controller.start()
expect(app.states).toEqual(["idle", "checking", "up-to-date"])
expect(app.calls).toEqual(["check"])
expect(app.getReady()).toBeUndefined()
}) })
test("revalidates a persisted target through the updater cache on launch", async () => { test("revalidates a persisted target through the updater cache on launch", async () => {
@@ -62,16 +79,7 @@ describe("updater controller", () => {
expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" }) expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
}) })
test("clears a target already installed before checking", async () => { test("concurrent checks share one platform check", async () => {
const app = setup({ currentVersion: "2.0.0", ready: { version: "2.0.0" } })
await app.controller.start()
expect(app.getReady()).toBeUndefined()
expect(app.calls).toEqual(["check"])
})
test("coalesces concurrent checks", async () => {
const app = setup() const app = setup()
await Promise.all([app.controller.check(), app.controller.check(), app.controller.check()]) await Promise.all([app.controller.check(), app.controller.check(), app.controller.check()])
@@ -79,81 +87,142 @@ describe("updater controller", () => {
expect(app.calls).toEqual(["check", "download"]) expect(app.calls).toEqual(["check", "download"])
}) })
test("starts installing synchronously and coalesces restart requests", async () => { test("clicking install twice checks once and installs the staged version once", async () => {
const app = setup() const app = setup()
await app.controller.start() await app.controller.start()
const first = app.controller.install() void app.controller.install()
const second = app.controller.install() void app.controller.install()
expect(first).toBe(second) await new Promise((resolve) => setTimeout(resolve, 0))
await Promise.resolve() expect(app.calls).toEqual(["check", "download", "check", "prepare", "install:2.0.0"])
expect(app.calls).toEqual(["check", "download", "prepare", "install"])
expect(app.controller.getState()).toEqual({ status: "installing", version: "2.0.0" }) expect(app.controller.getState()).toEqual({ status: "installing", version: "2.0.0" })
}) })
test("does not check for updates while installation is in progress", async () => { test("ignores checks while an installation is in progress", async () => {
const app = setup() const app = setup()
await app.controller.start() await app.controller.start()
void app.controller.install() void app.controller.install()
await app.controller.check() await app.controller.check()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(app.calls).toEqual(["check", "download", "prepare", "install"]) expect(app.calls).toEqual(["check", "download", "check", "prepare", "install:2.0.0"])
})
test("clicking install downloads and installs a newer release", async () => {
let latest = "2.0.0"
const app = setup({ latest: () => latest })
await app.controller.start()
latest = "3.0.0"
void app.controller.install()
expect(app.controller.getState()).toEqual({ status: "installing", version: "2.0.0" })
await new Promise((resolve) => setTimeout(resolve, 0))
expect(app.calls).toEqual(["check", "download", "check", "download", "prepare", "install:3.0.0"])
expect(app.controller.getState()).toEqual({ status: "installing", version: "3.0.0" })
})
test("clicking install uses the staged release when the final check fails", async () => {
let offline = false
const app = setup({
latest: () => {
if (offline) throw new Error("offline")
return "2.0.0"
},
})
await app.controller.start()
offline = true
void app.controller.install()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(app.calls).toEqual(["check", "download", "check", "prepare", "install:2.0.0"])
expect(app.controller.getState()).toEqual({ status: "installing", version: "2.0.0" }) expect(app.controller.getState()).toEqual({ status: "installing", version: "2.0.0" })
}) })
test("returns to ready when installation fails", async () => { test("later checks stay silent while ready and pick up newer versions", async () => {
const app = setup() let latest = "2.0.0"
const app = setup({ latest: () => latest })
await app.controller.start() await app.controller.start()
const error = new Error("install failed")
const failed = createUpdaterController({
enabled: true,
currentVersion: "1.0.0",
platform: {
checkForUpdate: async () => "2.0.0",
stageUpdate: async () => {},
installAndRestart: () => Promise.reject(error),
},
lifecycle: { prepareToRestart: async () => {} },
persistence: { get: () => undefined, set() {}, clear() {} },
})
await failed.start()
await expect(failed.install()).rejects.toThrow("install failed") await app.controller.check()
expect(failed.getState()).toEqual({ status: "ready", version: "2.0.0" }) // Nothing new was published: the install button never hid.
expect(app.states).toEqual(["idle", "checking", "downloading", "ready"])
latest = "3.0.0"
await app.controller.check()
expect(app.states).toEqual(["idle", "checking", "downloading", "ready", "ready"])
expect(app.controller.getState()).toEqual({ status: "ready", version: "3.0.0" })
expect(app.getReady()).toEqual({ version: "3.0.0" })
}) })
test("allows a state subscriber to retry after installation fails", async () => { test("keeps the staged update installable when a silent re-check fails", async () => {
let attempts = 0 let offline = false
let sawInstalling = false const app = setup({
let retry: Promise<void> | undefined latest: () => {
const failed = createUpdaterController({ if (offline) throw new Error("offline")
enabled: true, return "2.0.0"
currentVersion: "1.0.0",
platform: {
checkForUpdate: async () => "2.0.0",
stageUpdate: async () => {},
installAndRestart() {
attempts++
if (attempts === 1) return Promise.reject(new Error("install failed"))
return new Promise<never>(() => {})
},
}, },
lifecycle: { prepareToRestart: async () => {} },
persistence: { get: () => undefined, set() {}, clear() {} },
}) })
failed.subscribe((state) => { await app.controller.start()
if (state.status === "installing") sawInstalling = true
if (!sawInstalling || state.status !== "ready" || retry) return offline = true
retry = failed.install() await app.controller.check()
expect(app.states).toEqual(["idle", "checking", "downloading", "ready"])
expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
expect(app.getReady()).toEqual({ version: "2.0.0" })
})
test("install during a silent refresh waits for the download, then installs the newer version", async () => {
let latest = "2.0.0"
let slowStage = false
let releaseStage = () => {}
const app = setup({
latest: () => latest,
stage: () => {
if (!slowStage) return Promise.resolve()
return new Promise<void>((resolve) => {
releaseStage = resolve
})
},
}) })
await failed.start() await app.controller.start()
await expect(failed.install()).rejects.toThrow("install failed") latest = "3.0.0"
slowStage = true
const refresh = app.controller.check()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(retry).toBeDefined() void app.controller.install()
expect(app.controller.getState()).toEqual({ status: "installing", version: "2.0.0" })
releaseStage()
await refresh
expect(app.controller.getState()).toEqual({ status: "installing", version: "3.0.0" })
await new Promise((resolve) => setTimeout(resolve, 0))
expect(app.calls).toEqual(["check", "download", "check", "download", "prepare", "install:3.0.0"])
})
test("returns to ready after a failed installation and allows a retry", async () => {
let attempts = 0
const app = setup({
install() {
attempts++
if (attempts === 1) return Promise.reject(new Error("install failed"))
return new Promise<never>(() => {})
},
})
await app.controller.start()
await expect(app.controller.install()).rejects.toThrow("install failed")
expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
void app.controller.install()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(attempts).toBe(2) expect(attempts).toBe(2)
expect(failed.getState()).toEqual({ status: "installing", version: "2.0.0" }) expect(app.controller.getState()).toEqual({ status: "installing", version: "2.0.0" })
}) })
}) })
+47 -32
View File
@@ -21,14 +21,13 @@ type UpdaterPersistence = {
} }
export function createUpdaterController(input: { export function createUpdaterController(input: {
enabled: boolean
currentVersion: string currentVersion: string
platform?: UpdaterPlatform platform?: UpdaterPlatform
lifecycle: UpdaterLifecycle lifecycle: UpdaterLifecycle
persistence: UpdaterPersistence persistence: UpdaterPersistence
log?: (message: string, data?: object) => void log?: (message: string, data?: object) => void
}) { }) {
let state: UpdaterState = input.enabled ? { status: "idle" } : { status: "disabled" } let state: UpdaterState = input.platform ? { status: "idle" } : { status: "disabled" }
let pending: Promise<UpdaterState> | undefined let pending: Promise<UpdaterState> | undefined
let installing: Promise<void> | undefined let installing: Promise<void> | undefined
const listeners = new Set<(state: UpdaterState) => void>() const listeners = new Set<(state: UpdaterState) => void>()
@@ -41,13 +40,21 @@ export function createUpdaterController(input: {
} }
const check = () => { const check = () => {
if (!input.enabled) return Promise.resolve(state)
const platform = input.platform const platform = input.platform
if (!platform) return Promise.resolve(state) if (!platform) return Promise.resolve(state)
if (state.status === "ready" || state.status === "installing") return Promise.resolve(state) if (state.status === "installing") return Promise.resolve(state)
if (pending) return pending if (pending) return pending
pending = (async () => { pending = (state.status === "ready" ? refreshStaged(platform, state.version) : findAndStage(platform)).finally(
() => {
pending = undefined
},
)
return pending
}
const findAndStage = (platform: UpdaterPlatform) =>
(async () => {
transition({ status: "checking" }) transition({ status: "checking" })
const version = await platform.checkForUpdate() const version = await platform.checkForUpdate()
if (!version || version === input.currentVersion) { if (!version || version === input.currentVersion) {
@@ -59,41 +66,49 @@ export function createUpdaterController(input: {
await platform.stageUpdate() await platform.stageUpdate()
await input.persistence.set({ version }) await input.persistence.set({ version })
return transition({ status: "ready", version }) return transition({ status: "ready", version })
})() })().catch((error) =>
.catch((error) => transition({ status: "error", message: error instanceof Error ? error.message : String(error) }),
transition({ status: "error", message: error instanceof Error ? error.message : String(error) }), )
)
.finally(() => { // A staged update stays visible and installable throughout: the refresh makes no
pending = undefined // transitions until a newer version is staged, and a failure keeps the current one.
const refreshStaged = (platform: UpdaterPlatform, staged: string) =>
(async () => {
const version = await platform.checkForUpdate()
if (!version || version === staged || version === input.currentVersion) return state
await platform.stageUpdate()
await input.persistence.set({ version })
// An install may have started while this stage was in flight; keep its status
// and show the newer version instead of flickering back to ready.
return transition({ status: installing ? "installing" : "ready", version })
})().catch((error) => {
input.log?.("updater refresh failed, keeping staged update", {
staged,
message: error instanceof Error ? error.message : String(error),
}) })
return pending return state
} })
const install = () => { const install = () => {
if (installing) return installing if (installing) return installing
if (state.status !== "ready") return Promise.reject(new Error("Update is not ready to install")) const platform = input.platform
if (!platform || state.status !== "ready") return Promise.reject(new Error("Update is not ready to install"))
const version = startInstalling(state.version) const staged = state.version
installing = restartWithUpdate(version) transition({ status: "installing", version: staged })
return installing installing = (async () => {
} // Installation is the commit point: refresh once more so one restart lands
// on the newest release, or keep the known-good staged update if checking fails.
const startInstalling = (version: string) => { await (pending ?? refreshStaged(platform, staged))
transition({ status: "installing", version }) await input.lifecycle.prepareToRestart()
return version await platform.installAndRestart()
} })().catch((error) => {
const restartWithUpdate = (version: string) =>
prepareAndRestart().catch((error) => {
installing = undefined installing = undefined
transition({ status: "ready", version }) if (state.status === "installing") transition({ status: "ready", version: state.version })
throw error throw error
}) })
return installing
const prepareAndRestart = async () => {
if (!input.platform) throw new Error("Updater is disabled")
await input.lifecycle.prepareToRestart()
await input.platform.installAndRestart()
} }
return { return {
-23
View File
@@ -1,23 +0,0 @@
import { app, ipcMain } from "electron"
import type { UpdaterController } from "./updater-controller"
import { createUpdaterSubscriptions } from "./updater-subscriptions"
export function registerUpdaterIpc(controller: UpdaterController) {
const subscriptions = createUpdaterSubscriptions()
app.once("will-quit", subscriptions.clear)
ipcMain.handle("updater-subscribe", (event) => {
const id = event.sender.id
subscriptions.set(
id,
controller.subscribe((state) => {
if (event.sender.isDestroyed()) return subscriptions.delete(id)
event.sender.send("updater-state", state)
}),
)
event.sender.once("destroyed", () => subscriptions.delete(id))
})
ipcMain.handle("updater-unsubscribe", (event) => subscriptions.delete(event.sender.id))
ipcMain.handle("updater-check", () => controller.check())
ipcMain.handle("updater-install", () => controller.install())
}
@@ -1,16 +0,0 @@
import { describe, expect, test } from "bun:test"
import { createUpdaterSubscriptions } from "./updater-subscriptions"
describe("updater subscriptions", () => {
test("replaces the previous renderer subscription on reload", () => {
const subscriptions = createUpdaterSubscriptions()
const disposed: string[] = []
subscriptions.set(1, () => disposed.push("first"))
subscriptions.set(1, () => disposed.push("second"))
expect(disposed).toEqual(["first"])
subscriptions.delete(1)
expect(disposed).toEqual(["first", "second"])
})
})
@@ -1,20 +0,0 @@
export function createUpdaterSubscriptions() {
const subscriptions = new Map<number, () => void>()
const remove = (id: number) => {
subscriptions.get(id)?.()
subscriptions.delete(id)
}
return {
set(id: number, unsubscribe: () => void) {
remove(id)
subscriptions.set(id, unsubscribe)
},
delete: remove,
clear() {
subscriptions.forEach((unsubscribe) => unsubscribe())
subscriptions.clear()
},
}
}
+28 -6
View File
@@ -1,6 +1,6 @@
import { app, dialog } from "electron" import { app, dialog, ipcMain } from "electron"
import { UPDATER_ENABLED } from "./constants" import { UPDATER_ENABLED } from "./constants"
import { createUpdaterController, type UpdaterReadyRecord } from "./updater-controller" import { createUpdaterController, type UpdaterController, type UpdaterReadyRecord } from "./updater-controller"
import { getLogger } from "./logging" import { getLogger } from "./logging"
import { getStore } from "./store" import { getStore } from "./store"
import { nativeT } from "./native-translations" import { nativeT } from "./native-translations"
@@ -12,7 +12,6 @@ export function setupAutoUpdater(prepareToRestart: () => Promise<void>) {
const logger = getLogger() const logger = getLogger()
const store = getStore("opencode.updater") const store = getStore("opencode.updater")
return createUpdaterController({ return createUpdaterController({
enabled: UPDATER_ENABLED,
currentVersion: app.getVersion(), currentVersion: app.getVersion(),
platform: UPDATER_ENABLED ? createUpdaterPlatform(logger) : undefined, platform: UPDATER_ENABLED ? createUpdaterPlatform(logger) : undefined,
lifecycle: { prepareToRestart }, lifecycle: { prepareToRestart },
@@ -29,10 +28,34 @@ export function setupAutoUpdater(prepareToRestart: () => Promise<void>) {
}) })
} }
export async function showUpdaterDialog(controller: ReturnType<typeof setupAutoUpdater>, alertOnFail: boolean) { export function registerUpdaterIpc(controller: UpdaterController) {
const subscriptions = new Map<number, () => void>()
const unsubscribe = (id: number) => {
subscriptions.get(id)?.()
subscriptions.delete(id)
}
app.once("will-quit", () => subscriptions.forEach((dispose) => dispose()))
ipcMain.handle("updater-subscribe", (event) => {
const id = event.sender.id
subscriptions.get(id)?.() // a reloaded renderer replaces its previous subscription
subscriptions.set(
id,
controller.subscribe((state) => {
if (event.sender.isDestroyed()) return unsubscribe(id)
event.sender.send("updater-state", state)
}),
)
event.sender.once("destroyed", () => unsubscribe(id))
})
ipcMain.handle("updater-unsubscribe", (event) => unsubscribe(event.sender.id))
ipcMain.handle("updater-check", () => controller.check())
ipcMain.handle("updater-install", () => controller.install())
}
export async function showUpdaterDialog(controller: UpdaterController) {
const state = await controller.check() const state = await controller.check()
if (state.status === "error") { if (state.status === "error") {
if (!alertOnFail) return
await dialog.showMessageBox({ await dialog.showMessageBox({
type: "error", type: "error",
message: nativeT("desktop.updater.dialog.checkFailed.message"), message: nativeT("desktop.updater.dialog.checkFailed.message"),
@@ -41,7 +64,6 @@ export async function showUpdaterDialog(controller: ReturnType<typeof setupAutoU
return return
} }
if (state.status === "up-to-date") { if (state.status === "up-to-date") {
if (!alertOnFail) return
await dialog.showMessageBox({ await dialog.showMessageBox({
type: "info", type: "info",
message: nativeT("desktop.updater.dialog.upToDate.message"), message: nativeT("desktop.updater.dialog.upToDate.message"),
+5 -4
View File
@@ -3964,7 +3964,7 @@
} }
} }
}, },
"description": "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes if durable inbox work remains after interruption.", "description": "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes pending steering input while queued work remains parked.",
"summary": "Interrupt session execution" "summary": "Interrupt session execution"
} }
}, },
@@ -17728,8 +17728,9 @@
"name": { "name": {
"type": "string" "type": "string"
}, },
"disabled": { "activation": {
"type": "boolean" "type": "string",
"enum": ["auto", "enabled", "disabled"]
}, },
"package": { "package": {
"type": "string" "type": "string"
@@ -17747,7 +17748,7 @@
"type": "object" "type": "object"
} }
}, },
"required": ["id", "name", "package"], "required": ["id", "name", "activation", "package"],
"additionalProperties": false "additionalProperties": false
}, },
"ProviderNotFoundError": { "ProviderNotFoundError": {
+1 -1
View File
@@ -660,7 +660,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
identifier: "v2.session.interrupt", identifier: "v2.session.interrupt",
summary: "Interrupt session execution", summary: "Interrupt session execution",
description: description:
"Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes if durable inbox work remains after interruption.", "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes pending steering input while queued work remains parked.",
}), }),
), ),
) )
+5 -2
View File
@@ -25,6 +25,9 @@ export type ID = typeof ID.Type
export const Package = Schema.String export const Package = Schema.String
export type Package = typeof Package.Type export type Package = typeof Package.Type
export const Activation = Schema.Literals(["auto", "enabled", "disabled"])
export type Activation = typeof Activation.Type
export const Overlays = { export const Overlays = {
settings: Schema.Record(Schema.String, Schema.Any).pipe(optional), settings: Schema.Record(Schema.String, Schema.Any).pipe(optional),
headers: Schema.Record(Schema.String, Schema.String).pipe(optional), headers: Schema.Record(Schema.String, Schema.String).pipe(optional),
@@ -46,13 +49,13 @@ export const Info = Schema.Struct({
id: ID, id: ID,
integrationID: Integration.ID.pipe(optional), integrationID: Integration.ID.pipe(optional),
name: Schema.String, name: Schema.String,
disabled: Schema.Boolean.pipe(optional), activation: Activation,
package: Package, package: Package,
...Overlays, ...Overlays,
}) })
.annotate({ identifier: "Provider.Info" }) .annotate({ identifier: "Provider.Info" })
.pipe( .pipe(
statics(() => ({ statics(() => ({
empty: (id: ID): Info => ({ id, name: id, package: "" }), empty: (id: ID): Info => ({ id, name: id, activation: "auto", package: "" }),
})), })),
) )
@@ -120,10 +120,12 @@ describe("contract hygiene", () => {
test("model defaults and provider overlays preserve public invariants", () => { test("model defaults and provider overlays preserve public invariants", () => {
const id = Model.ID.make("model") const id = Model.ID.make("model")
expect(Model.Info.default(Provider.ID.make("provider"), id)).toMatchObject({ modelID: id, variants: [] }) expect(Model.Info.default(Provider.ID.make("provider"), id)).toMatchObject({ modelID: id, variants: [] })
expect(Provider.Info.empty(Provider.ID.make("provider")).activation).toBe("auto")
expect( expect(
Schema.decodeUnknownSync(Provider.Info)({ Schema.decodeUnknownSync(Provider.Info)({
id: "provider", id: "provider",
name: "Provider", name: "Provider",
activation: "auto",
package: "native", package: "native",
settings: { arbitrary: 1n }, settings: { arbitrary: 1n },
}).settings, }).settings,
@@ -3,7 +3,43 @@ import { patchFiles } from "./apply-patch-file"
import { text } from "./session-diff" import { text } from "./session-diff"
describe("apply patch file", () => { describe("apply patch file", () => {
test("parses patch metadata from the server", () => { test("parses v2 patch metadata", () => {
const file = patchFiles([
{
file: "a.ts",
status: "modified",
patch:
"Index: a.ts\n===================================================================\n--- a.ts\n+++ a.ts\n@@ -1,2 +1,2 @@\n one\n-two\n+three\n",
additions: 1,
deletions: 1,
},
])[0]
expect(file).toBeDefined()
expect(file?.filePath).toBe("a.ts")
expect(file?.relativePath).toBe("a.ts")
expect(file?.type).toBe("update")
expect(file?.view.fileDiff.name).toBe("a.ts")
expect(file?.view.fileDiff.isPartial).toBe(true)
expect(text(file.view, "deletions")).toBe("one\ntwo\n")
expect(text(file.view, "additions")).toBe("one\nthree\n")
})
test("maps all v2 patch statuses", () => {
expect(
patchFiles([
{ file: "added.ts", status: "added", patch: "+one", additions: 1, deletions: 0 },
{ file: "deleted.ts", status: "deleted", patch: "-one", additions: 0, deletions: 1 },
{ file: "modified.ts", status: "modified", patch: "-one\n+two", additions: 1, deletions: 1 },
]).map((file) => ({ file: file.filePath, type: file.type })),
).toEqual([
{ file: "added.ts", type: "add" },
{ file: "deleted.ts", type: "delete" },
{ file: "modified.ts", type: "update" },
])
})
test("parses legacy patch metadata", () => {
const file = patchFiles([ const file = patchFiles([
{ {
filePath: "/tmp/a.ts", filePath: "/tmp/a.ts",
@@ -19,8 +55,8 @@ describe("apply patch file", () => {
expect(file).toBeDefined() expect(file).toBeDefined()
expect(file?.view.fileDiff.name).toBe("a.ts") expect(file?.view.fileDiff.name).toBe("a.ts")
expect(file?.view.fileDiff.isPartial).toBe(false) expect(file?.view.fileDiff.isPartial).toBe(false)
expect(text(file!.view, "deletions")).toBe("one\ntwo\n") expect(text(file.view, "deletions")).toBe("one\ntwo\n")
expect(text(file!.view, "additions")).toBe("one\nthree\n") expect(text(file.view, "additions")).toBe("one\nthree\n")
}) })
test("keeps legacy before and after payloads working", () => { test("keeps legacy before and after payloads working", () => {
@@ -37,7 +73,7 @@ describe("apply patch file", () => {
])[0] ])[0]
expect(file).toBeDefined() expect(file).toBeDefined()
expect(text(file!.view, "deletions")).toBe("one\n") expect(text(file.view, "deletions")).toBe("one\n")
expect(text(file!.view, "additions")).toBe("two\n") expect(text(file.view, "additions")).toBe("two\n")
}) })
}) })
@@ -3,9 +3,11 @@ import { normalize, type ViewDiff } from "./session-diff"
type Kind = "add" | "update" | "delete" | "move" type Kind = "add" | "update" | "delete" | "move"
type Raw = { type Raw = {
file?: string
filePath?: string filePath?: string
relativePath?: string relativePath?: string
type?: Kind type?: Kind
status?: "added" | "deleted" | "modified"
patch?: string patch?: string
diff?: string diff?: string
before?: string before?: string
@@ -27,6 +29,10 @@ export type ApplyPatchFile = {
function kind(value: unknown) { function kind(value: unknown) {
if (value === "add" || value === "update" || value === "delete" || value === "move") return value if (value === "add" || value === "update" || value === "delete" || value === "move") return value
if (value === "added") return "add"
if (value === "deleted") return "delete"
if (value === "modified") return "update"
return undefined
} }
function status(type: Kind): "added" | "deleted" | "modified" { function status(type: Kind): "added" | "deleted" | "modified" {
@@ -36,18 +42,19 @@ function status(type: Kind): "added" | "deleted" | "modified" {
} }
export function patchFile(raw: unknown): ApplyPatchFile | undefined { export function patchFile(raw: unknown): ApplyPatchFile | undefined {
if (!raw || typeof raw !== "object") return if (!raw || typeof raw !== "object") return undefined
const value = raw as Raw const value = raw as Raw
const type = kind(value.type) const type = kind(value.type) ?? kind(value.status)
const filePath = typeof value.filePath === "string" ? value.filePath : undefined const filePath =
typeof value.filePath === "string" ? value.filePath : typeof value.file === "string" ? value.file : undefined
const relativePath = typeof value.relativePath === "string" ? value.relativePath : filePath const relativePath = typeof value.relativePath === "string" ? value.relativePath : filePath
const patch = typeof value.patch === "string" ? value.patch : typeof value.diff === "string" ? value.diff : undefined const patch = typeof value.patch === "string" ? value.patch : typeof value.diff === "string" ? value.diff : undefined
const before = typeof value.before === "string" ? value.before : undefined const before = typeof value.before === "string" ? value.before : undefined
const after = typeof value.after === "string" ? value.after : undefined const after = typeof value.after === "string" ? value.after : undefined
if (!type || !filePath || !relativePath) return if (!type || !filePath || !relativePath) return undefined
if (!patch && before === undefined && after === undefined) return if (!patch && before === undefined && after === undefined) return undefined
const additions = typeof value.additions === "number" ? value.additions : 0 const additions = typeof value.additions === "number" ? value.additions : 0
const deletions = typeof value.deletions === "number" ? value.deletions : 0 const deletions = typeof value.deletions === "number" ? value.deletions : 0
@@ -474,6 +474,17 @@ function webSearchProviderLabel(provider: unknown, i18n: ReturnType<typeof useI1
return i18n.t("ui.tool.websearch") return i18n.t("ui.tool.websearch")
} }
function readToolPath(input: Record<string, unknown>) {
if (typeof input.path === "string") return input.path
if (typeof input.filePath === "string") return input.filePath
}
function skillToolName(input: Record<string, unknown>, metadata?: Record<string, unknown>) {
if (typeof metadata?.name === "string") return metadata.name
if (typeof input.id === "string") return input.id
if (typeof input.name === "string") return input.name
}
export function getToolInfo( export function getToolInfo(
tool: string, tool: string,
input: any = {}, input: any = {},
@@ -481,12 +492,14 @@ export function getToolInfo(
): ToolInfo { ): ToolInfo {
const i18n = useI18n() const i18n = useI18n()
switch (tool) { switch (tool) {
case "read": case "read": {
const path = readToolPath(input)
return { return {
icon: "glasses", icon: "glasses",
title: i18n.t("ui.tool.read"), title: i18n.t("ui.tool.read"),
subtitle: input.filePath ? getFilename(input.filePath) : undefined, subtitle: path ? getFilename(path) : undefined,
} }
}
case "list": case "list":
return { return {
icon: "bullet-list", icon: "bullet-list",
@@ -568,7 +581,7 @@ export function getToolInfo(
case "skill": case "skill":
return { return {
icon: "brain", icon: "brain",
title: input.name || i18n.t("ui.tool.skill"), title: skillToolName(input, metadata) || i18n.t("ui.tool.skill"),
} }
default: default:
return { return {
@@ -847,7 +860,7 @@ function contextToolDetail(part: ToolPart): string | undefined {
function contextToolTrigger(part: ToolPart, i18n: ReturnType<typeof useI18n>) { function contextToolTrigger(part: ToolPart, i18n: ReturnType<typeof useI18n>) {
const input = (part.state.input ?? {}) as Record<string, unknown> const input = (part.state.input ?? {}) as Record<string, unknown>
const path = typeof input.path === "string" ? input.path : "/" const path = typeof input.path === "string" ? input.path : "/"
const filePath = typeof input.filePath === "string" ? input.filePath : undefined const filePath = readToolPath(input)
const pattern = typeof input.pattern === "string" ? input.pattern : undefined const pattern = typeof input.pattern === "string" ? input.pattern : undefined
const include = typeof input.include === "string" ? input.include : undefined const include = typeof input.include === "string" ? input.include : undefined
const offset = typeof input.offset === "number" ? input.offset : undefined const offset = typeof input.offset === "number" ? input.offset : undefined
@@ -1793,7 +1806,7 @@ ToolRegistry.register({
icon="glasses" icon="glasses"
trigger={{ trigger={{
title: i18n.t("ui.tool.read"), title: i18n.t("ui.tool.read"),
subtitle: props.input.filePath ? getFilename(props.input.filePath) : "", subtitle: getFilename(readToolPath(props.input) ?? ""),
args, args,
}} }}
/> />
@@ -2624,7 +2637,7 @@ ToolRegistry.register({
name: "skill", name: "skill",
render(props) { render(props) {
const i18n = useI18n() const i18n = useI18n()
const title = createMemo(() => props.input.name || i18n.t("ui.tool.skill")) const title = createMemo(() => skillToolName(props.input, props.metadata) || i18n.t("ui.tool.skill"))
const running = createMemo(() => props.status === "pending" || props.status === "running") const running = createMemo(() => props.status === "pending" || props.status === "running")
const titleContent = () => <TextShimmer text={title()} active={running()} /> const titleContent = () => <TextShimmer text={title()} active={running()} />
@@ -26,6 +26,21 @@ describe("partDefaultOpen", () => {
).toBe(false) ).toBe(false)
}) })
test("collapses v2 patches containing only deleted files when enabled", () => {
expect(
partDefaultOpen(
tool("patch", {
files: [
{ file: "one.ts", status: "deleted" },
{ file: "two.ts", status: "deleted" },
],
}),
false,
true,
),
).toBe(false)
})
test("keeps mixed patches expanded when enabled", () => { test("keeps mixed patches expanded when enabled", () => {
expect( expect(
partDefaultOpen( partDefaultOpen(
@@ -41,6 +56,21 @@ describe("partDefaultOpen", () => {
).toBe(true) ).toBe(true)
}) })
test("keeps mixed v2 patches expanded when enabled", () => {
expect(
partDefaultOpen(
tool("patch", {
files: [
{ file: "one.ts", status: "deleted" },
{ file: "two.ts", status: "modified" },
],
}),
false,
true,
),
).toBe(true)
})
test("preserves shell defaults", () => { test("preserves shell defaults", () => {
expect(partDefaultOpen(tool("shell", {}), true, false)).toBe(true) expect(partDefaultOpen(tool("shell", {}), true, false)).toBe(true)
}) })
@@ -7,7 +7,12 @@ function deletionOnly(part: ToolPart) {
const files = metadata.files const files = metadata.files
if (Array.isArray(files) && files.length > 0) { if (Array.isArray(files) && files.length > 0) {
return files.every((file) => !!file && typeof file === "object" && "type" in file && file.type === "delete") return files.every(
(file) =>
!!file &&
typeof file === "object" &&
(("type" in file && file.type === "delete") || ("status" in file && file.status === "deleted")),
)
} }
const filediff = metadata.filediff const filediff = metadata.filediff
@@ -16,11 +21,12 @@ function deletionOnly(part: ToolPart) {
return filediff.additions === 0 && typeof filediff.deletions === "number" && filediff.deletions > 0 return filediff.additions === 0 && typeof filediff.deletions === "number" && filediff.deletions > 0
} }
export function partDefaultOpen(part: PartType, shell = false, edit = false) { export function partDefaultOpen(part: PartType, shell = false, edit = false): boolean | undefined {
if (part.type !== "tool") return if (part.type !== "tool") return undefined
if (part.tool === "bash" || part.tool === "shell") return shell if (part.tool === "bash" || part.tool === "shell") return shell
if (part.tool === "edit" || part.tool === "write" || part.tool === "patch" || part.tool === "apply_patch") { if (part.tool === "edit" || part.tool === "write" || part.tool === "patch" || part.tool === "apply_patch") {
if (!edit) return false if (!edit) return false
return !deletionOnly(part) return !deletionOnly(part)
} }
return undefined
} }
@@ -1,6 +1,7 @@
import { createMemo, createResource, createSignal, onMount, Show } from "solid-js" import { createMemo, createResource, createSignal, onMount, Show } from "solid-js"
import path from "path" import path from "path"
import type { SessionInfo } from "@opencode-ai/client" import type { SessionInfo } from "@opencode-ai/client"
import { Project } from "@opencode-ai/schema/project"
import { TextAttributes } from "@opentui/core" import { TextAttributes } from "@opentui/core"
import type { RGBA } from "@opentui/core" import type { RGBA } from "@opentui/core"
import { useDialog } from "../ui/dialog" import { useDialog } from "../ui/dialog"
@@ -54,10 +55,12 @@ export function DialogSessionList() {
const response = await client.api.session.list({ const response = await client.api.session.list({
...(allProjects ...(allProjects
? {} ? {}
: { : current.project.id === Project.ID.global
project: current.project.id, ? { directory: current.directory }
subpath: path.relative(current.project.directory, current.directory).replaceAll("\\", "/"), : {
}), project: current.project.id,
subpath: path.relative(current.project.directory, current.directory).replaceAll("\\", "/"),
}),
...(query ? { search: query } : {}), ...(query ? { search: query } : {}),
limit: 50, limit: 50,
order: "desc", order: "desc",
+86 -139
View File
@@ -57,10 +57,7 @@ import {
type LocalAttachment, type LocalAttachment,
} from "./local-attachment" } from "./local-attachment"
import { useData } from "../../context/data" import { useData } from "../../context/data"
import { usePromptRef } from "../../context/prompt"
import { useLocation } from "../../context/location" import { useLocation } from "../../context/location"
import type { PromptFileAttachment, PromptSkillAttachment, SkillInfo } from "@opencode-ai/client"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Keymap, type KeymapCommand } from "../../context/keymap" import { Keymap, type KeymapCommand } from "../../context/keymap"
import { abbreviateHome } from "../../runtime" import { abbreviateHome } from "../../runtime"
import { Slot } from "../../plugin/render" import { Slot } from "../../plugin/render"
@@ -74,6 +71,7 @@ import { DialogImagePreview } from "../dialog-image-preview"
import { useDirectoryRecents } from "../../prompt/directory-recents" import { useDirectoryRecents } from "../../prompt/directory-recents"
import { directoryRecentValue } from "../../prompt/directory-completion" import { directoryRecentValue } from "../../prompt/directory-completion"
import { useWorkingDirectoryActions } from "../../ui/working-directory-actions" import { useWorkingDirectoryActions } from "../../ui/working-directory-actions"
import { truncateFilePath } from "../../ui/file-path"
export type PromptProps = { export type PromptProps = {
sessionID?: string sessionID?: string
@@ -103,59 +101,6 @@ export type PromptRef = {
const DRAFT_RETENTION_MIN_CHARS = 20 const DRAFT_RETENTION_MIN_CHARS = 20
// Serialize background prompt submissions per session so admission order matches
// the on-screen optimistic order, even across Prompt remounts and route changes.
const submitTails = new Map<string, Promise<void>>()
function enqueueSubmit(sessionID: string, task: () => Promise<void>) {
const tail = (submitTails.get(sessionID) ?? Promise.resolve()).then(task, task)
submitTails.set(sessionID, tail)
void tail.finally(() => {
if (submitTails.get(sessionID) === tail) submitTails.delete(sessionID)
})
return tail
}
// Approximate the server's materialized attachment shape for the local echo. Pasted
// data: URIs carry real content so images preview immediately; file references render
// as labels until the admission echo replaces them with server truth.
function optimisticFiles(files: PromptInfo["files"]): PromptFileAttachment[] | undefined {
if (!files?.length) return undefined
return files.map((file) => {
const match = /^data:([^;,]*);base64,(.*)$/.exec(file.uri)
if (match)
return {
data: match[2] ?? "",
mime: match[1] || "application/octet-stream",
source: { type: "inline" as const },
name: file.name,
description: file.description,
mention: file.mention,
}
return {
data: "",
mime: file.uri.endsWith("/") ? "application/x-directory" : "text/plain",
source: { type: "uri" as const, uri: file.uri },
name: file.name,
description: file.description,
mention: file.mention,
}
})
}
function optimisticSkills(
skills: PromptInfo["skills"],
available: SkillInfo[],
): PromptSkillAttachment[] | undefined {
if (!skills?.length) return undefined
return skills.map((attachment) => ({
id: attachment.id,
name: available.find((skill) => skill.id === attachment.id)?.name ?? attachment.id,
text: "",
mention: attachment.mention,
}))
}
function randomIndex(count: number) { function randomIndex(count: number) {
if (count <= 0) return 0 if (count <= 0) return 0
return Math.floor(Math.random() * count) return Math.floor(Math.random() * count)
@@ -257,7 +202,6 @@ export function Prompt(props: PromptProps) {
const editor = useEditorContext() const editor = useEditorContext()
const route = useRoute() const route = useRoute()
const data = useData() const data = useData()
const activePrompt = usePromptRef()
const directoryRecents = useDirectoryRecents() const directoryRecents = useDirectoryRecents()
const keymapCommands = Keymap.useCommands() const keymapCommands = Keymap.useCommands()
const currentLocation = useLocation() const currentLocation = useLocation()
@@ -1127,20 +1071,6 @@ export function Prompt(props: PromptProps) {
} }
}) })
// Return a failed background submission to its author. When the target tab's
// editor is live and empty, restore directly; otherwise stash a draft for the
// next mount. A non-empty editor is never clobbered—prompt history retains the
// failed prompt either way.
function restorePrompt(sessionID: string, snapshot: PromptInfo) {
const active = route.data
if (active.type === "session" && active.sessionID === sessionID) {
const live = activePrompt.current
if (live && !live.current.text.trim()) live.set(snapshot)
return
}
saveDraft(sessionID, { prompt: snapshot, cursor: snapshot.text.length })
}
let submitting = false let submitting = false
async function submit(delivery: SessionInbox.Delivery = "steer") { async function submit(delivery: SessionInbox.Delivery = "steer") {
// Prevent overlapping invocations (e.g. a double-pressed Enter, or the // Prevent overlapping invocations (e.g. a double-pressed Enter, or the
@@ -1241,6 +1171,7 @@ export function Prompt(props: PromptProps) {
const variant = selection.variant const variant = selection.variant
let sessionID = props.sessionID let sessionID = props.sessionID
let session = sessionID ? data.session.get(sessionID) : undefined
let finishMoveProgress = false let finishMoveProgress = false
if (sessionID == null) { if (sessionID == null) {
const directory = await move.getDirectory() const directory = await move.getDirectory()
@@ -1274,6 +1205,7 @@ export function Prompt(props: PromptProps) {
} }
sessionID = created.id sessionID = created.id
session = created
} }
// Capture mode before it gets reset // Capture mode before it gets reset
@@ -1314,70 +1246,70 @@ export function Prompt(props: PromptProps) {
}) })
} else { } else {
move.startSubmit() move.startSubmit()
// Echo the prompt locally and admit it in the background: the editor clears and if (!session) {
// the message renders immediately, while a per-session queue preserves admission await data.session.sync(sessionID)
// order. Failure rolls the echo back and restores the captured prompt. session = data.session.get(sessionID)
const submitSessionID = sessionID }
const messageID = SessionMessage.ID.create() if (session?.agent !== agent.id) {
const snapshot = structuredClone(unwrap(store.prompt)) await client.api.session.switchAgent({ sessionID, agent: agent.id })
const editorContextText = pendingEditorSelection ? formatEditorContext(pendingEditorSelection) : undefined }
const targetAgent = agent.id if (
const model = { providerID: selection.providerID, id: selection.modelID, variant } session?.model?.providerID !== selection.providerID ||
data.session.optimistic.prompt({ session.model.id !== selection.modelID ||
sessionID: submitSessionID, (session.model.variant ?? "default") !== (variant ?? "default")
messageID, ) {
delivery, const model = { providerID: selection.providerID, id: selection.modelID, variant }
text: inputText, const cancelCommit = local.model.trackSessionCommit(sessionID, model)
files: optimisticFiles(snapshot.files), await client.api.session.switchModel({ sessionID, model }).catch((error) => {
agents: snapshot.agents?.length ? snapshot.agents : undefined, cancelCommit()
skills: optimisticSkills(snapshot.skills, data.location.skill.list(currentLocation.ref) ?? []), throw error
}) })
// Mark the editor context sent with the echo so a rapid follow-up submit }
// does not re-attach the same selection while admission is in flight. if (session?.revert) {
if (editorContextText) editor.markSelectionSent() const error = await client.api.session.revert.commit({ sessionID }).then(
void enqueueSubmit(submitSessionID, async () => {
const error = await (async () => {
let session = data.session.get(submitSessionID)
if (!session) {
await data.session.sync(submitSessionID)
session = data.session.get(submitSessionID)
}
if (session?.agent !== targetAgent) {
await client.api.session.switchAgent({ sessionID: submitSessionID, agent: targetAgent })
}
if (
session?.model?.providerID !== model.providerID ||
session.model.id !== model.id ||
(session.model.variant ?? "default") !== (model.variant ?? "default")
) {
const cancelCommit = local.model.trackSessionCommit(submitSessionID, model)
await client.api.session.switchModel({ sessionID: submitSessionID, model }).catch((error) => {
cancelCommit()
throw error
})
}
if (session?.revert) await client.api.session.revert.commit({ sessionID: submitSessionID })
// Keep editor context hidden while admitting it before the corresponding user prompt.
if (editorContextText)
await client.api.session.synthetic({ sessionID: submitSessionID, text: editorContextText, resume: false })
await client.api.session.prompt({
sessionID: submitSessionID,
id: messageID,
text: inputText,
files: snapshot.files,
agents: snapshot.agents,
skills: snapshot.skills?.length ? snapshot.skills : undefined,
delivery,
})
})().then(
() => undefined, () => undefined,
(error) => error, (error) => error,
) )
if (error === undefined) return if (error) {
data.session.optimistic.rollback(submitSessionID, messageID) toast.show({ title: "Failed to commit revert", message: errorMessage(error), variant: "error" })
restorePrompt(submitSessionID, snapshot) return false
}
}
if (pendingEditorSelection) {
// Keep editor context hidden while admitting it before the corresponding user prompt.
const error = await client.api.session
.synthetic({
sessionID,
text: formatEditorContext(pendingEditorSelection),
resume: false,
})
.then(
() => undefined,
(error) => error,
)
if (error) {
toast.show({ title: "Failed to send editor context", message: errorMessage(error), variant: "error" })
return false
}
}
const error = await client.api.session
.prompt({
sessionID,
text: inputText,
files: store.prompt.files,
agents: store.prompt.agents,
skills: store.prompt.skills?.length ? store.prompt.skills : undefined,
delivery,
})
.then(
() => undefined,
(error) => error,
)
if (error) {
toast.show({ title: "Failed to send prompt", message: errorMessage(error), variant: "error" }) toast.show({ title: "Failed to send prompt", message: errorMessage(error), variant: "error" })
}) return false
}
if (pendingEditorSelection) editor.markSelectionSent()
} }
history.append({ history.append({
...store.prompt, ...store.prompt,
@@ -1388,14 +1320,15 @@ export function Prompt(props: PromptProps) {
setStore("extmarkToPart", new Map()) setStore("extmarkToPart", new Map())
props.onSubmit?.() props.onSubmit?.()
// temporary hack to make sure the message is sent
if (!props.sessionID) { if (!props.sessionID) {
if (pendingEditorSelection) editor.preserveSelectionFromNewSession() if (pendingEditorSelection) editor.preserveSelectionFromNewSession()
// The optimistic echo is already in the data store, so the session route setTimeout(() => {
// renders the prompt immediately; admission continues in the background. route.navigate({
route.navigate({ type: "session",
type: "session", sessionID,
sessionID, })
}) }, 50)
} }
input.clear() input.clear()
if (finishMoveProgress) move.finishSubmit() if (finishMoveProgress) move.finishSubmit()
@@ -1623,6 +1556,12 @@ export function Prompt(props: PromptProps) {
const branch = data.location.vcs.info(location)?.branch.current const branch = data.location.vcs.info(location)?.branch.current
return branch ? `${directory}:${branch}` : directory return branch ? `${directory}:${branch}` : directory
}) })
const [locationWidth, setLocationWidth] = createSignal(dimensions().width)
const locationLabelDisplay = createMemo(() => {
const label = locationLabel()
if (!label) return
return truncateFilePath(label, locationWidth())
})
const locationActions = useWorkingDirectoryActions({ const locationActions = useWorkingDirectoryActions({
directory: () => footerLocation()?.directory, directory: () => footerLocation()?.directory,
onMove: () => void move.open(), onMove: () => void move.open(),
@@ -1908,7 +1847,15 @@ export function Prompt(props: PromptProps) {
<box width="100%" flexDirection="row" justifyContent="space-between" gap={2}> <box width="100%" flexDirection="row" justifyContent="space-between" gap={2}>
<Slot path="prompt.footer" input={footerInput()}> <Slot path="prompt.footer" input={footerInput()}>
<Slot path="prompt.footer.status" input={footerInput()}> <Slot path="prompt.footer.status" input={footerInput()}>
<box flexGrow={1} flexShrink={1} minWidth={0}> <box
flexGrow={1}
flexShrink={1}
minWidth={0}
onSizeChange={function (this: BoxRenderable) {
const width = this.width
queueMicrotask(() => setLocationWidth(width))
}}
>
<Switch> <Switch>
<Match when={status() === "running"}> <Match when={status() === "running"}>
<box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start"> <box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start">
@@ -1945,7 +1892,7 @@ export function Prompt(props: PromptProps) {
</box> </box>
</Match> </Match>
<Match when={true}> <Match when={true}>
<Show when={!props.hint && locationLabel()} fallback={props.hint ?? <text />}> <Show when={!props.hint && locationLabelDisplay()} fallback={props.hint ?? <text />}>
{(location) => ( {(location) => (
<text <text
id="prompt.footer.location" id="prompt.footer.location"
+21 -140
View File
@@ -20,7 +20,6 @@ import type {
ProviderInfo, ProviderInfo,
ReferenceInfo, ReferenceInfo,
SessionMessageInfo, SessionMessageInfo,
SessionMessageUser,
SessionMessageAssistant, SessionMessageAssistant,
SessionMessageAssistantReasoning, SessionMessageAssistantReasoning,
SessionMessageAssistantText, SessionMessageAssistantText,
@@ -41,7 +40,7 @@ import { useClient } from "./client"
import { nonEmptyToolContent } from "../util/tool-display" import { nonEmptyToolContent } from "../util/tool-display"
import type { SessionInbox } from "@opencode-ai/schema/session-inbox" import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
import { Worktree } from "@opencode-ai/schema/worktree" import { Worktree } from "@opencode-ai/schema/worktree"
import { batch, createEffect, createSignal, onCleanup } from "solid-js" import { createEffect, createSignal, onCleanup } from "solid-js"
export type DataSessionStatus = "idle" | "running" export type DataSessionStatus = "idle" | "running"
@@ -162,25 +161,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
const messageIndex = new Map<string, Map<string, number>>() const messageIndex = new Map<string, Map<string, number>>()
const sync = createSync() const sync = createSync()
// Optimistic prompt echoes: user prompts applied locally before server admission,
// keyed sessionID -> messageID. The session.inbox.enqueued echo carrying the same
// ID replaces the local copy with server truth (admission materializes file
// attachments and expands skills). Entries survive wholesale sync replaces until
// the server confirms them or the submitter rolls back.
type OptimisticPrompt = { message: SessionMessageInfo; pending: SessionInboxInfo }
const optimisticPrompts = new Map<string, Map<string, OptimisticPrompt>>()
function confirmOptimistic(sessionID: string, messageID: string) {
const entries = optimisticPrompts.get(sessionID)
if (!entries?.delete(messageID)) return false
if (entries.size === 0) optimisticPrompts.delete(sessionID)
return true
}
function optimisticMessages(sessionID: string) {
return [...(optimisticPrompts.get(sessionID)?.values() ?? [])]
}
function setSessionActive(sessionID: string, status: DataSessionStatus) { function setSessionActive(sessionID: string, status: DataSessionStatus) {
setStore("session", "active", sessionID, status) setStore("session", "active", sessionID, status)
} }
@@ -208,17 +188,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
) )
} }
function removeMessage(sessionID: string, messageID: string) {
if (!messageIndex.get(sessionID)?.has(messageID)) return
message.update(sessionID, (draft, index) => {
const position = index.get(messageID)
if (position === undefined) return
draft.splice(position, 1)
index.delete(messageID)
message.reindex(draft, index, position)
})
}
function removePermission(sessionID: string, requestID: string) { function removePermission(sessionID: string, requestID: string) {
const requests = store.session.permission[sessionID] const requests = store.session.permission[sessionID]
if (!requests?.some((request) => request.id === requestID)) return if (!requests?.some((request) => request.id === requestID)) return
@@ -344,7 +313,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
function removeSession(sessionID: string) { function removeSession(sessionID: string) {
messageIndex.delete(sessionID) messageIndex.delete(sessionID)
optimisticPrompts.delete(sessionID)
sync.invalidate(`session:${sessionID}`) sync.invalidate(`session:${sessionID}`)
sync.invalidate(`session.pending:${sessionID}`) sync.invalidate(`session.pending:${sessionID}`)
sync.invalidate(`session.message:${sessionID}`) sync.invalidate(`session.message:${sessionID}`)
@@ -503,9 +471,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
break break
} }
case "session.inbox.delivered": { case "session.inbox.delivered": {
// Delivery implies the message is projected server-side, so future message
// fetches include it and the optimistic entry no longer needs re-appending.
confirmOptimistic(event.data.sessionID, event.data.inboxID)
const admitted = store.session.input[event.data.sessionID]?.includes(event.data.inboxID) ?? false const admitted = store.session.input[event.data.sessionID]?.includes(event.data.inboxID) ?? false
removePending(event.data.sessionID, event.data.inboxID) removePending(event.data.sessionID, event.data.inboxID)
message.update(event.data.sessionID, (draft, index) => { message.update(event.data.sessionID, (draft, index) => {
@@ -524,31 +489,25 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
updatePending(event.data.sessionID, event.data.inboxID, event.data.delivery) updatePending(event.data.sessionID, event.data.inboxID, event.data.delivery)
break break
case "session.inbox.cancelled": { case "session.inbox.cancelled": {
confirmOptimistic(event.data.sessionID, event.data.inboxID)
removePending(event.data.sessionID, event.data.inboxID) removePending(event.data.sessionID, event.data.inboxID)
removeMessage(event.data.sessionID, event.data.inboxID) if (messageIndex.get(event.data.sessionID)?.has(event.data.inboxID))
message.update(event.data.sessionID, (draft, index) => {
const position = index.get(event.data.inboxID)
if (position === undefined) return
draft.splice(position, 1)
index.delete(event.data.inboxID)
message.reindex(draft, index, position)
})
break break
} }
case "session.inbox.enqueued": { case "session.inbox.enqueued": {
const item = event.data.item const item = event.data.item
// The admission echo is authoritative for an optimistic local copy: it addPending({
// materializes file attachments and expands skills, so replace in place.
const confirmed = confirmOptimistic(event.data.sessionID, event.data.inboxID)
const pendingItem: SessionInboxInfo = {
id: event.data.inboxID, id: event.data.inboxID,
sessionID: event.data.sessionID, sessionID: event.data.sessionID,
timeCreated: event.created, timeCreated: event.created,
...item, ...item,
} })
const pendingList = store.session.pending[event.data.sessionID]
if (confirmed && pendingList?.some((pending) => pending.id === event.data.inboxID))
setStore(
"session",
"pending",
event.data.sessionID,
pendingList.map((pending) => (pending.id === event.data.inboxID ? pendingItem : pending)),
)
else addPending(pendingItem)
if (!store.session.input[event.data.sessionID]?.includes(event.data.inboxID)) if (!store.session.input[event.data.sessionID]?.includes(event.data.inboxID))
setStore("session", "input", event.data.sessionID, [ setStore("session", "input", event.data.sessionID, [
...(store.session.input[event.data.sessionID] ?? []), ...(store.session.input[event.data.sessionID] ?? []),
@@ -556,7 +515,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
]) ])
if (item.type !== "user" && item.type !== "synthetic") break if (item.type !== "user" && item.type !== "synthetic") break
message.update(event.data.sessionID, (draft, index) => { message.update(event.data.sessionID, (draft, index) => {
const next: SessionMessageInfo = message.append(
draft,
index,
item.type === "user" item.type === "user"
? { ? {
id: event.data.inboxID, id: event.data.inboxID,
@@ -569,13 +530,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
type: "synthetic", type: "synthetic",
...item.payload, ...item.payload,
time: { created: event.created }, time: { created: event.created },
} },
const position = index.get(event.data.inboxID) )
if (confirmed && position !== undefined) {
draft[position] = next
return
}
message.append(draft, index, next)
}) })
break break
} }
@@ -869,30 +825,22 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
if (store.session.info[event.data.sessionID]) if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "revert", undefined) setStore("session", "info", event.data.sessionID, "revert", undefined)
break break
case "session.revert.committed": { case "session.revert.committed":
if (store.session.info[event.data.sessionID]) { if (store.session.info[event.data.sessionID]) {
setStore("session", "info", event.data.sessionID, "revert", undefined) setStore("session", "info", event.data.sessionID, "revert", undefined)
} }
// Unconfirmed optimistic prompts postdate the revert boundary but were never
// part of the reverted history: the server admits them after the commit.
const local = optimisticPrompts.get(event.data.sessionID)
setStore( setStore(
"session", "session",
"input", "input",
event.data.sessionID, event.data.sessionID,
(store.session.input[event.data.sessionID] ?? []).filter( (store.session.input[event.data.sessionID] ?? []).filter((id) => id < event.data.to),
(id) => id < event.data.to || local?.has(id) === true,
),
) )
message.update(event.data.sessionID, (draft, index) => { message.update(event.data.sessionID, (draft, index) => {
const position = draft.findIndex((item) => item.id >= event.data.to) const position = draft.findIndex((item) => item.id >= event.data.to)
if (position === -1) return if (position === -1) return
const dropped = draft.splice(position) for (const item of draft.splice(position)) index.delete(item.id)
for (const item of dropped) index.delete(item.id)
for (const item of dropped) if (local?.has(item.id)) message.append(draft, index, item)
}) })
break break
}
case "session.compaction.delta": case "session.compaction.delta":
message.update(event.data.sessionID, (draft) => { message.update(event.data.sessionID, (draft) => {
const current = message.compaction(draft) const current = message.compaction(draft)
@@ -1065,74 +1013,13 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return store.session.input[sessionID]?.includes(inboxID) ?? false return store.session.input[sessionID]?.includes(inboxID) ?? false
}, },
}, },
optimistic: {
// Locally echo a user prompt before server admission. The session.inbox.enqueued
// echo carrying the same message ID replaces the copy with server truth; rollback
// removes the echo when submission fails.
prompt(input: {
sessionID: string
messageID: string
delivery: SessionInbox.Delivery
text: string
files?: SessionMessageUser["files"]
agents?: SessionMessageUser["agents"]
skills?: SessionMessageUser["skills"]
}) {
const created = Date.now()
const pendingItem: SessionInboxInfo = {
id: input.messageID,
sessionID: input.sessionID,
timeCreated: created,
type: "user",
payload: { text: input.text, files: input.files, agents: input.agents, skills: input.skills },
delivery: input.delivery,
}
const messageItem: SessionMessageInfo = {
id: input.messageID,
type: "user",
text: input.text,
files: input.files,
agents: input.agents,
skills: input.skills,
time: { created },
}
const entries = optimisticPrompts.get(input.sessionID) ?? new Map<string, OptimisticPrompt>()
optimisticPrompts.set(input.sessionID, entries)
entries.set(input.messageID, { message: messageItem, pending: pendingItem })
batch(() => {
addPending(pendingItem)
if (!store.session.input[input.sessionID]?.includes(input.messageID))
setStore("session", "input", input.sessionID, [
...(store.session.input[input.sessionID] ?? []),
input.messageID,
])
message.update(input.sessionID, (draft, index) => message.append(draft, index, messageItem))
})
},
rollback(sessionID: string, messageID: string) {
if (!confirmOptimistic(sessionID, messageID)) return
batch(() => {
removePending(sessionID, messageID)
removeMessage(sessionID, messageID)
})
},
},
pending: { pending: {
list(sessionID: string) { list(sessionID: string) {
return store.session.pending[sessionID] ?? [] return store.session.pending[sessionID] ?? []
}, },
sync(sessionID: string) { sync(sessionID: string) {
return sync.run(`session.pending:${sessionID}`, async () => { return sync.run(`session.pending:${sessionID}`, async () => {
const fetched = await client.api.session.inbox.list({ sessionID }) const pending = await client.api.session.inbox.list({ sessionID })
// Keep unconfirmed optimistic prompts pending across the wholesale replace.
// Server presence here is not treated as confirmation: until the enqueued
// echo or a projected message arrives, the ledger still guards message.sync.
const pending = [
...fetched,
...optimisticMessages(sessionID)
.map((entry) => entry.pending)
.filter((entry) => !fetched.some((item) => item.id === entry.id)),
]
setStore("session", "pending", sessionID, reconcile(pending)) setStore("session", "pending", sessionID, reconcile(pending))
setStore( setStore(
"session", "session",
@@ -1182,15 +1069,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
}, },
sync(sessionID: string) { sync(sessionID: string) {
return sync.run(`session.message:${sessionID}`, async () => { return sync.run(`session.message:${sessionID}`, async () => {
const fetched = ( const messages = (
await client.api.message.list({ sessionID, limit: 200, order: "desc" }) await client.api.message.list({ sessionID, limit: 200, order: "desc" })
).data.toReversed() ).data.toReversed()
// A wholesale replace would drop optimistic prompts the server has not
// admitted yet. A fetched ID is server confirmation; the rest re-append.
for (const entry of optimisticMessages(sessionID))
if (fetched.some((item) => item.id === entry.message.id))
confirmOptimistic(sessionID, entry.message.id)
const messages = [...fetched, ...optimisticMessages(sessionID).map((entry) => entry.message)]
messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index]))) messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])))
setStore("session", "message", sessionID, reconcile(messages)) setStore("session", "message", sessionID, reconcile(messages))
}) })
@@ -1,276 +0,0 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client"
import { createEffect, type ParentProps } from "solid-js"
import { ConfigProvider } from "../../../src/config"
import { ClientProvider, useClient } from "../../../src/context/client"
import { DataProvider as DataProviderBase, useData } from "../../../src/context/data"
import { LocationProvider, useLocation } from "../../../src/context/location"
import { createApi, createEventStream, createFetch, directory, json } from "../../fixture/tui-client"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
async function wait(fn: () => boolean, timeout = 2000) {
const start = Date.now()
while (!fn()) {
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
await Bun.sleep(10)
}
}
function emitEvent(events: ReturnType<typeof createEventStream>, event: OpenCodeEvent) {
events.emit({ ...event, location: { directory } })
}
const config = createTuiResolvedConfig()
function DataProvider(props: ParentProps) {
return (
<ConfigProvider config={config}>
<DataProviderBase>
<LocationProvider>
<SyncLocation />
{props.children}
</LocationProvider>
</DataProviderBase>
</ConfigProvider>
)
}
function SyncLocation() {
const data = useData()
const location = useLocation()
createEffect(() => location.set(data.location.default()))
return null
}
function durable(sessionID: string, seq = 0): { aggregateID: string; seq: number; version: 1 } {
return { aggregateID: sessionID, seq, version: 1 }
}
type Harness = {
data: ReturnType<typeof useData>
client: ReturnType<typeof useClient>
}
async function renderData(fetch: ReturnType<typeof createFetch>["fetch"]) {
const harness = {} as Harness
function Probe() {
harness.client = useClient()
harness.data = useData()
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<ClientProvider api={createApi(fetch)}>
<DataProvider>
<Probe />
</DataProvider>
</ClientProvider>
</TestTuiContexts>
))
await wait(() => harness.client.connection.status() === "connected")
return { app, ...harness }
}
test("echoes an optimistic prompt and replaces it with the admission echo", async () => {
const events = createEventStream()
const sessionID = "session-optimistic-echo"
const calls = createFetch(undefined, events)
const { app, data } = await renderData(calls.fetch)
try {
data.session.optimistic.prompt({
sessionID,
messageID: "msg_optimistic",
delivery: "steer",
text: "Hello",
files: [{ data: "", mime: "text/plain", source: { type: "uri", uri: "file:///tmp/a.ts" }, name: "a.ts" }],
})
const echoed = data.session.message.get(sessionID, "msg_optimistic")
expect(echoed?.type === "user" && echoed.text).toBe("Hello")
expect(echoed?.type === "user" && echoed.files?.[0]?.data).toBe("")
expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual(["msg_optimistic"])
expect(data.session.input.has(sessionID, "msg_optimistic")).toBe(true)
// Admission materializes attachments, so the echo must be replaced in place.
emitEvent(events, {
id: "evt_admitted",
created: 9,
type: "session.inbox.enqueued",
durable: durable(sessionID),
data: {
sessionID,
inboxID: "msg_optimistic",
item: {
type: "user",
payload: {
text: "Hello",
files: [{ data: "QUJD", mime: "text/plain", source: { type: "uri", uri: "file:///tmp/a.ts" }, name: "a.ts" }],
},
delivery: "steer",
},
},
})
await wait(() => {
const message = data.session.message.get(sessionID, "msg_optimistic")
return message?.type === "user" && message.files?.[0]?.data === "QUJD"
})
expect(data.session.message.list(sessionID)).toHaveLength(1)
expect(data.session.pending.list(sessionID)).toHaveLength(1)
expect(data.session.pending.list(sessionID)[0]?.timeCreated).toBe(9)
} finally {
app.renderer.destroy()
}
})
test("rolls back a failed optimistic prompt", async () => {
const events = createEventStream()
const sessionID = "session-optimistic-rollback"
const calls = createFetch(undefined, events)
const { app, data } = await renderData(calls.fetch)
try {
data.session.optimistic.prompt({
sessionID,
messageID: "msg_failed",
delivery: "queue",
text: "Will fail",
})
expect(data.session.message.get(sessionID, "msg_failed")).toBeDefined()
data.session.optimistic.rollback(sessionID, "msg_failed")
expect(data.session.message.get(sessionID, "msg_failed")).toBeUndefined()
expect(data.session.pending.list(sessionID)).toHaveLength(0)
expect(data.session.input.has(sessionID, "msg_failed")).toBe(false)
// Rollback of an unknown or already-settled echo is a no-op.
data.session.optimistic.rollback(sessionID, "msg_failed")
expect(data.session.message.list(sessionID)).toHaveLength(0)
} finally {
app.renderer.destroy()
}
})
test("optimistic prompts survive sync replaces until the server confirms them", async () => {
const events = createEventStream()
const sessionID = "session-optimistic-sync"
let serverMessages: SessionMessageInfo[] = []
const calls = createFetch((url) => {
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: serverMessages, cursor: {} })
if (url.pathname === `/api/session/${sessionID}/inbox`) return json({ data: [] })
}, events)
const { app, data } = await renderData(calls.fetch)
try {
data.session.optimistic.prompt({
sessionID,
messageID: "msg_pending",
delivery: "steer",
text: "Survive the sync",
})
// A wholesale replace from an empty server page keeps the unconfirmed echo.
await data.session.message.sync(sessionID)
expect(data.session.message.get(sessionID, "msg_pending")).toBeDefined()
await data.session.pending.sync(sessionID)
expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual(["msg_pending"])
expect(data.session.input.has(sessionID, "msg_pending")).toBe(true)
// A fetched page containing the ID is server confirmation: the projected copy
// wins and later rollback attempts become no-ops.
serverMessages = [{ id: "msg_pending", type: "user", text: "Survive the sync", time: { created: 5 } }]
data.session.message.invalidate(sessionID)
await data.session.message.sync(sessionID)
const confirmed = data.session.message.get(sessionID, "msg_pending")
expect(confirmed?.type === "user" && confirmed.time.created).toBe(5)
expect(data.session.message.list(sessionID)).toHaveLength(1)
data.session.optimistic.rollback(sessionID, "msg_pending")
expect(data.session.message.get(sessionID, "msg_pending")).toBeDefined()
} finally {
app.renderer.destroy()
}
})
test("cancellation clears the optimistic echo for good", async () => {
const events = createEventStream()
const sessionID = "session-optimistic-cancel"
const calls = createFetch((url) => {
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
}, events)
const { app, data } = await renderData(calls.fetch)
try {
data.session.optimistic.prompt({
sessionID,
messageID: "msg_cancelled",
delivery: "queue",
text: "Cancel me",
})
emitEvent(events, {
id: "evt_cancelled",
created: 2,
type: "session.inbox.cancelled",
durable: durable(sessionID),
data: { sessionID, inboxID: "msg_cancelled" },
})
await wait(() => data.session.message.get(sessionID, "msg_cancelled") === undefined)
expect(data.session.pending.list(sessionID)).toHaveLength(0)
// The ledger entry is gone too: a sync replace must not resurrect the echo.
await data.session.message.sync(sessionID)
expect(data.session.message.get(sessionID, "msg_cancelled")).toBeUndefined()
} finally {
app.renderer.destroy()
}
})
test("revert commit preserves unconfirmed optimistic prompts", async () => {
const events = createEventStream()
const sessionID = "session-optimistic-revert"
const calls = createFetch(undefined, events)
const { app, data } = await renderData(calls.fetch)
try {
for (const [seq, id] of [
[0, "msg_1"],
[1, "msg_2"],
] as const) {
emitEvent(events, {
id: `evt_seed_${id}`,
created: seq + 1,
type: "session.inbox.enqueued",
durable: durable(sessionID, seq),
data: { sessionID, inboxID: id, item: { type: "user", payload: { text: id }, delivery: "steer" } },
})
}
await wait(() => data.session.message.list(sessionID).length === 2)
data.session.optimistic.prompt({
sessionID,
messageID: "msg_9",
delivery: "steer",
text: "After the revert boundary",
})
emitEvent(events, {
id: "evt_revert",
created: 4,
type: "session.revert.committed",
durable: durable(sessionID, 2),
data: { sessionID, to: "msg_2" },
})
await wait(() => data.session.message.get(sessionID, "msg_2") === undefined)
expect(data.session.message.get(sessionID, "msg_1")).toBeDefined()
expect(data.session.message.get(sessionID, "msg_9")).toBeDefined()
expect(data.session.input.has(sessionID, "msg_9")).toBe(true)
} finally {
app.renderer.destroy()
}
})
@@ -5,6 +5,7 @@ export function catalogProvider(id: string, name: string): ProviderListOutput["d
return { return {
id, id,
name, name,
activation: "auto",
package: "", package: "",
} }
} }

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