Compare commits

..
Author SHA1 Message Date
Kit Langton f543a66be7 feat(tui): optimistic session creation
Seed new sessions locally with a client-generated session ID and create
them in the background, so submitting from the home screen navigates
into the session and renders the prompt echo immediately. The seed
marks initial reads complete until the session.created echo loads
server truth. Creation failure closes the optimistic tab, returns to
the home screen with the prompt restored, and drops the seed. Worktree-
backed creation and command, skill, and shell submissions keep awaiting
creation.
2026-08-15 14:38:28 -04:00
Kit Langton d4bd41c4ba feat(tui): optimistic prompt submission
Echo user prompts into the local data store with a client-generated
message ID before server admission. The editor clears and the message
renders immediately; admission runs in a per-session background queue
that preserves steer order. The session.inbox.enqueued echo replaces
the local copy with server truth, wholesale sync replaces re-append
unconfirmed echoes, and failures roll back the echo, restore the
captured prompt, and toast.
2026-08-15 14:32:38 -04:00
85 changed files with 2745 additions and 2140 deletions
+1 -1
View File
@@ -86,7 +86,7 @@ async function writeProtocolStream(session: CDPSession, handle: string, file: st
try {
while (true) {
const chunk = await session.send("IO.read", { handle })
await (chunk.base64Encoded ? output.write(Buffer.from(chunk.data, "base64")) : output.write(chunk.data))
await output.write(chunk.base64Encoded ? Buffer.from(chunk.data, "base64") : chunk.data)
if (chunk.eof) break
}
} finally {
@@ -125,20 +125,17 @@ export async function installTimelineStreamProbe(
const scrollTo = Element.prototype.scrollTo
const scrollTop = Object.getOwnPropertyDescriptor(Element.prototype, "scrollTop")!
if (profileVisual) {
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) {
Element.prototype.scrollTo = function (...args) {
state.scroll.calls += 1
const top = typeof first === "object" ? first?.top : second
const top = typeof args[0] === "object" ? args[0]?.top : args[1]
if (typeof top === "number") {
const target = Math.min(top, this.scrollHeight - this.clientHeight)
if (Math.abs(this.scrollTop - target) < 1) state.scroll.callNoops += 1
}
if (state.scroll.lastCallFrame === state.scroll.frame) state.scroll.sameFrameCalls += 1
state.scroll.lastCallFrame = state.scroll.frame
Reflect.apply(scrollTo, this, typeof first === "number" ? [first, second] : [first])
return scrollTo.apply(this, args)
}
Element.prototype.scrollTo = measuredScrollTo
Object.defineProperty(Element.prototype, "scrollTop", {
configurable: true,
get: scrollTop.get,
@@ -267,19 +267,18 @@ const childMessages = Array.from({ length: 4 }, (_, index) => [
userMessage(childID, index + 2000, 120),
assistantMessage(childID, index + 2000, id("msg_user", index + 2000), [textPart(index + 2000, 0, 240)]),
]).flat()
const messages: Record<string, Message[]> = {
[sourceID]: sourceMessages,
[targetID]: targetMessages,
[childID]: childMessages,
}
function renderable(part: MessagePart) {
if (part.type === "tool" && part.tool === "todowrite") return false
if (part.type === "text") return !!part.text?.trim()
if (part.type === "reasoning") return !!part.text?.trim()
if (part.type === "text") return !!part.text.trim()
if (part.type === "reasoning") return !!part.text.trim()
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 = {
directory,
project: {
@@ -334,7 +333,7 @@ export const fixture = {
sourceID,
targetID,
childID,
messages,
messages: { [sourceID]: sourceMessages, [targetID]: targetMessages, [childID]: childMessages },
expected: {
sourceTitle: "Uncommitted changes inquiry",
targetTitle: "Example Game: sample jump movement & sample physics analysis",
@@ -346,12 +345,16 @@ export const fixture = {
.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) => message.parts.filter(renderable).map((part) => part.id)),
targetPartIDs: targetMessages.flatMap((message) =>
orderedParts(message)
.filter(renderable)
.map((part) => part.id),
),
},
}
export function pageMessages(sessionID: string, limit: number, before?: string) {
const messages = fixture.messages[sessionID] ?? []
const messages = fixture.messages[sessionID as keyof typeof fixture.messages] ?? []
const end = before
? Math.max(
0,
@@ -361,6 +364,6 @@ export function pageMessages(sessionID: string, limit: number, before?: string)
const start = Math.max(0, end - limit)
return {
items: messages.slice(start, end),
cursor: start > 0 ? messages[start].info.id : undefined,
cursor: start > 0 ? messages[start]!.info.id : undefined,
}
}
@@ -220,8 +220,7 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
}
if (url.pathname === "/api/project/current")
return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory })
if (url.pathname === "/api/session")
return json(route, { data: sessions.map((session) => currentSession(session)), cursor: {} })
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
const currentSessionInfo = sessions.find((session) => url.pathname === `/api/session/${session.id}`)
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
@@ -82,7 +82,6 @@ test("moves busy through retry and recovery to final idle content", async ({ pag
file: "src/retry.ts",
additions: 1,
deletions: 1,
status: "modified",
patch: "@@ -1 +1 @@\n-export const retry = false\n+export const retry = true",
},
],
@@ -1,27 +1,28 @@
import { expect, test } from "@playwright/test"
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
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 assistant = (completed: boolean, tool = false, childID?: string): SessionMessageAssistant => ({
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: tool
? [
{
type: "tool",
id: "call_subagent",
name: "subagent",
state: { status: "running", input: {}, metadata: childID ? { sessionID: childID } : {} },
time: { created: 2 },
},
]
: [{ type: "text", text: "Working" }],
time: { created: 2, ...(completed ? { completed: 3 } : {}) },
})
const assistant = (completed: boolean, tool = false, childID?: string) =>
({
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: tool
? [
{
type: "tool",
id: "call_subagent",
name: "subagent",
state: { status: "running", input: {}, metadata: childID ? { sessionID: childID } : {} },
time: { created: 2 },
},
]
: [{ type: "text", text: "Working" }],
time: { created: 2, ...(completed ? { completed: 3 } : {}) },
}) satisfies SessionMessageInfo
test("renders current protocol notices in CLI order", async ({ page }) => {
const ownerWarnings: string[] = []
@@ -280,7 +280,6 @@ function summaryDiff(index: number) {
file: `src/diff-${index}.ts`,
additions: 1,
deletions: 1,
status: "modified" as const,
patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`,
}
}
@@ -23,10 +23,11 @@ test("groups singleton and separated context operations at correct boundaries",
]
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
await expect(page.locator('[data-timeline-part-ids="prt_boundary_01_read"]')).toBeVisible()
await expect(page.locator('[data-timeline-part-ids="prt_boundary_03_glob,prt_boundary_04_grep"]')).toBeVisible()
await expect(
page.locator('[data-timeline-part-ids="prt_boundary_01_read,prt_boundary_03_glob,prt_boundary_04_grep"]'),
).toBeVisible()
await expect(page.locator('[data-timeline-part-ids="prt_boundary_06_list"]')).toBeVisible()
await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(5)
await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(4)
})
test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => {
@@ -131,7 +131,6 @@ test("allows paint rounding for every framed row but not fixed turn gaps", async
file: "src/summary.ts",
additions: 1,
deletions: 1,
status: "modified",
patch: "@@ -1 +1 @@\n-export const value = 1\n+export const value = 2",
},
],
@@ -83,40 +83,6 @@ test("labels all web search provider variants", async ({ page }) => {
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() {
return { questions: [{ header: "Stability", question: "Keep it stable?", options: [] }] }
}
@@ -21,7 +21,7 @@ type EventPayload = {
payload: Record<string, unknown>
}
test.use({ viewport: { width: 1440, height: 900 } })
test.use({ viewport: { width: 1440, height: 900 }, reducedMotion: "no-preference" })
test("animates todo opening without replaying it across session tabs", async ({ page }) => {
test.setTimeout(90_000)
@@ -57,6 +57,7 @@ test("animates todo opening without replaying it across session tabs", async ({
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
},
sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)],
sessionStatus: { [sourceID]: { type: "busy" } },
pageMessages: () => ({ items: [] }),
events: () => events.splice(0, 1),
eventRetry: 16,
@@ -90,8 +90,7 @@ async function mockServer(page: Page) {
if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname))
return new Promise(() => {})
if (url.pathname === "/api/event") return sse(route)
if (url.pathname === "/api/session")
return json(route, { data: sessions.map((session) => currentSession(session)), cursor: {} })
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`)
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
@@ -227,24 +227,25 @@ const sourceMessages = Array.from({ length: 12 }, (_, index) => [
userMessage(sourceID, index + 1000, 120),
assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [textPart(index + 1000, 0, 240)]),
]).flat()
const messages: Record<string, Message[]> = { [sourceID]: sourceMessages, [targetID]: targetMessages }
function renderable(part: MessagePart) {
if (part.type === "tool" && part.tool === "todowrite") return false
if (part.type === "text") return !!part.text?.trim()
if (part.type === "reasoning") return !!part.text?.trim()
if (part.type === "text") return !!part.text.trim()
if (part.type === "reasoning") return !!part.text.trim()
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
}
function currentPartIDs(message: Message) {
const ordinals = { text: 0, reasoning: 0 }
return message.parts.flatMap((part) => {
if (!renderable(part)) return []
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 === "tool") return [typeof part.callID === "string" ? part.callID : part.id]
return []
})
return message.parts
.flatMap((part) => {
if (!renderable(part)) return []
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 === "tool") return [typeof part.callID === "string" ? part.callID : part.id]
return []
})
.sort()
}
export const fixture = {
@@ -291,7 +292,7 @@ export const fixture = {
],
sourceID,
targetID,
messages,
messages: { [sourceID]: sourceMessages, [targetID]: targetMessages },
expected: {
sourceTitle: "Uncommitted changes inquiry",
targetTitle: "Example Game: sample jump movement & sample physics analysis",
@@ -305,7 +306,7 @@ export const fixture = {
}
export function pageMessages(sessionID: string, limit: number, before?: string) {
const messages = fixture.messages[sessionID] ?? []
const messages = fixture.messages[sessionID as keyof typeof fixture.messages] ?? []
const end = before
? Math.max(
0,
@@ -315,6 +316,6 @@ export function pageMessages(sessionID: string, limit: number, before?: string)
const start = Math.max(0, end - limit)
return {
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,
directory: fixture.directory,
project: fixture.project,
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID] ?? [] }),
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }),
})
await configureSmokePage(page, fixture.directory)
await page.addInitScript(
@@ -188,11 +188,7 @@ test.describe("smoke: session timeline", () => {
const bottom = root
.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
?.getBoundingClientRect()
samples.push({
ids: visible,
last: visible.includes(last),
bottomError: bottom ? bottom.bottom - view.bottom : undefined,
})
samples.push({ ids: visible, last: visible.includes(last), bottomError: bottom?.bottom - view.bottom })
if (
!firstPaint &&
visible.includes(last) &&
@@ -267,7 +263,7 @@ test.describe("smoke: session timeline", () => {
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID] ?? [] }),
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }),
})
await configureSmokePage(page, fixture.directory)
await page.addInitScript(
+13 -3
View File
@@ -1,11 +1,21 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"composite": false,
"emitDeclarationOnly": false,
"noEmit": true,
"rootDir": "..",
"types": ["node", "bun"]
},
"include": ["./**/*.ts", "./**/*.tsx", "../src/types.ts"]
"include": [
"./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"
]
}
@@ -1,321 +0,0 @@
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("/..")
})
})
@@ -1,115 +0,0 @@
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,
}
}
@@ -0,0 +1,396 @@
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("/..")
}
})
})
@@ -0,0 +1,216 @@
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,17 +11,15 @@ type SessionCreateInput = {
model?: { id: string; providerID: string; variant?: string }
location?: { directory: string }
}
const admitted: Array<{
const optimistic: Array<{
directory?: string
sessionID: string
messageID: string
text: string
displayText: string
agent: string
model: { providerID: string; modelID: string; variant?: string }
comments: unknown[]
sessionID?: string
message: {
agent: string
model: { providerID: string; modelID: string }
variant?: string
}
}> = []
const confirmed: unknown[] = []
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
const sentShellDirectories: string[] = []
@@ -37,11 +35,9 @@ const switchedModels: Array<{
const sessionRequestOrder: string[] = []
const updatedDrafts: Array<{ draftID: string; worktree?: string }> = []
const syncedServers: string[] = []
const admittedServers: string[] = []
const optimisticServers: string[] = []
const promptCaptures: Array<{ scope?: unknown; target?: unknown }> = []
let serverSessionSyncs = 0
let restoredPrompts = 0
let clearEchoCalls = 0
let params: { id?: string } = {}
let search: { draftId?: string } = {}
@@ -51,8 +47,6 @@ let createSessionGate: Promise<void> | undefined
let createWorktreeGate: Promise<void> | undefined
let worktreeFailure: Error | undefined
let locationFailure: Error | undefined
let promptFailure: Error | undefined
let clearEchoResult = true
let worktreeCreates = 0
let activeSDK = "server-a"
let activeServerSync = "server-a"
@@ -80,7 +74,7 @@ const prompt = {
set: () => undefined,
},
reset: () => undefined,
set: () => restoredPrompts++,
set: () => undefined,
context: {
add: () => undefined,
remove: () => undefined,
@@ -122,16 +116,7 @@ const clientFor = (directory: string) => {
sessionRequestOrder.push("prompt")
sentPrompts.push(sessionDirectories[(input as { sessionID: string }).sessionID] ?? directory)
promptInputs.push(input)
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 },
}
return { data: undefined }
},
switchAgent: async (input: { sessionID: string; agent: string }) => {
sessionRequestOrder.push("agent")
@@ -250,27 +235,16 @@ beforeAll(async () => {
return {
data: { command: commands, project: "project" },
session: {
inbox: {
echo: (value: {
optimistic: {
add: (value: {
directory?: string
sessionID: string
messageID: string
text: string
displayText: string
agent: string
model: { providerID: string; modelID: string; variant?: string }
comments: unknown[]
sessionID?: string
message: { agent: string; model: { providerID: string; modelID: string; variant?: string } }
}) => {
admittedServers.push(server)
admitted.push(value)
},
confirm: (value: unknown) => {
confirmed.push(value)
},
clearEcho: () => {
clearEchoCalls++
return clearEchoResult
optimisticServers.push(server)
optimistic.push(value)
},
remove: () => undefined,
},
},
set: () => undefined,
@@ -330,8 +304,7 @@ beforeAll(async () => {
beforeEach(() => {
createdSessions.length = 0
admitted.length = 0
confirmed.length = 0
optimistic.length = 0
promotedDrafts.length = 0
updatedDrafts.length = 0
sentCommands.length = 0
@@ -341,10 +314,8 @@ beforeEach(() => {
switchedModels.length = 0
sessionRequestOrder.length = 0
syncedServers.length = 0
admittedServers.length = 0
optimisticServers.length = 0
promptCaptures.length = 0
restoredPrompts = 0
clearEchoCalls = 0
params = {}
search = {}
sentShell.length = 0
@@ -362,8 +333,6 @@ beforeEach(() => {
createWorktreeGate = undefined
worktreeFailure = undefined
locationFailure = undefined
promptFailure = undefined
clearEchoResult = true
worktreeCreates = 0
for (const key of Object.keys(draftServers)) delete draftServers[key]
for (const key of Object.keys(sessionDirectories)) delete sessionDirectories[key]
@@ -452,7 +421,7 @@ describe("prompt submit worktree selection", () => {
expect(updatedDrafts).toEqual([{ draftID: "draft-1", worktree: undefined }])
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server-a", sessionId: "session-1" }])
expect(syncedServers.every((server) => server === "server-a")).toBe(true)
expect(admittedServers).toEqual(["server-a"])
expect(optimisticServers).toEqual(["server-a"])
expect(promptCaptures.at(-1)?.target).toEqual({ server: "project-server-a", scope: ServerScope.local })
expect(submitted).toBe(0)
})
@@ -472,15 +441,13 @@ describe("prompt submit worktree selection", () => {
await submit.handleSubmit(event)
await Bun.sleep(0)
expect(admitted).toHaveLength(1)
expect(admitted[0]).toMatchObject({
sessionID: "session-1",
text: "ls",
agent: "agent",
model: { providerID: "provider", modelID: "model", variant: "high" },
expect(optimistic).toHaveLength(1)
expect(optimistic[0]).toMatchObject({
message: {
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(switchedAgents).toEqual([{ sessionID: "session-1", agent: "agent" }])
expect(switchedModels).toEqual([
@@ -499,22 +466,6 @@ describe("prompt submit worktree selection", () => {
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 () => {
params = { id: "session-1" }
variant = "high"
@@ -1,9 +1,10 @@
import type { Message } from "@/types"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { showToast } from "@/utils/toast"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { Binary } from "@opencode-ai/core/util/binary"
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { startTransition, type Accessor } from "solid-js"
import { batch, startTransition, type Accessor } from "solid-js"
import { useTabs } from "@/context/tabs"
import { useServerSync, type ServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
@@ -14,7 +15,7 @@ import { useSDK, type DirectorySDK } from "@/context/sdk"
import { useSync, type DirectorySync } from "@/context/sync"
import { Identifier } from "@/utils/id"
import { getDirectory } from "@opencode-ai/core/util/path"
import { buildPromptRequest } from "./build-prompt-request"
import { buildRequestParts } from "./build-request-parts"
import { setCursorPosition } from "./editor-dom"
import { formatServerError } from "@/utils/server-errors"
import { ScopedKey } from "@/utils/server-scope"
@@ -99,22 +100,43 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
dataUrl: await blobDataUrl(attachment.blob, attachment.mime),
})),
)
const request = buildPromptRequest({
const { requestParts, optimisticParts } = buildRequestParts({
prompt: input.draft.prompt,
context: input.draft.context,
images: encodedImages,
text,
sessionID: input.draft.sessionID,
messageID,
sessionDirectory: input.draft.sessionDirectory,
})
setBusy()
input.sync.session.inbox.echo({
directory: input.draft.sessionDirectory,
const message: Message = {
id: messageID,
sessionID: input.draft.sessionID,
messageID,
role: "user",
time: { created: Date.now() },
agent: input.draft.agent,
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 {
@@ -137,23 +159,40 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
})
}
const admitted = await input.api.prompt({
await input.api.prompt({
sessionID: input.draft.sessionID,
id: messageID,
text: request.text,
files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
agents: request.agents,
text: requestParts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
files: requestParts.flatMap((part) => {
if (part.type !== "file") return []
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
} catch (err) {
const failed = input.sync.session.inbox.clearEcho({
directory: input.draft.sessionDirectory,
sessionID: input.draft.sessionID,
messageID,
batch(() => {
setIdle()
remove()
})
if (!failed) return true
setIdle()
throw err
}
}
@@ -499,6 +538,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
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)
clearInput()
@@ -518,6 +565,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
title: language.t("prompt.toast.promptSendFailed.title"),
description: errorMessage(err),
})
removeOptimisticMessage()
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
})
} finally {
@@ -119,11 +119,9 @@ export function createProviderConnectionController(options: {
const finish = async () => {
cancelPolling()
const directory = options.directory()
const key = directory ? pathKey(directory) : null
await Promise.all([
queryClient.refetchQueries(serverSync.queryOptions.providers(key)).catch(() => undefined),
queryClient.refetchQueries(serverSync.queryOptions.integrations(key)).catch(() => undefined),
])
await queryClient
.refetchQueries(serverSync.queryOptions.providers(directory ? pathKey(directory) : null))
.catch(() => undefined)
if (polling.disposed) return
options.onComplete()
}
@@ -4,7 +4,6 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { showToast } from "@/utils/toast"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { useIntegrations } from "@/hooks/use-integrations"
import { createMemo, type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
@@ -41,9 +40,7 @@ export const SettingsProvidersV2: Component<{
const serverSdk = useServerSDK()
const serverSync = useServerSync()
const providers = useProviders(() => props.directory)
const integrations = useIntegrations(() => props.directory)
const providerConnect = useProviderConnectController({ onBack: props.onBack })
const integration = (providerID: string) => integrations.list().find((item) => item.id === providerID)
const connect = (provider?: string) => {
providerConnect.select(provider)
@@ -76,14 +73,7 @@ export const SettingsProvidersV2: Component<{
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 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
const value = item.source
if (value === "env" || value === "api" || value === "config" || value === "custom") return value
@@ -102,11 +92,7 @@ export const SettingsProvidersV2: Component<{
return language.t("settings.providers.tag.other")
}
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 canDisconnect = (item: ProviderItem) => source(item) !== "env" && !isConfigCustom(item.id)
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
@@ -68,8 +68,7 @@
display: none;
}
[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"] {
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-link"] {
--tab-title-fade-offset: 4px;
-webkit-mask-image: linear-gradient(
to right,
@@ -87,8 +86,7 @@
);
}
[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"] {
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]):dir(rtl) [data-slot="tab-link"] {
-webkit-mask-image: linear-gradient(
to left,
black 0,
@@ -105,7 +103,8 @@
);
}
[data-titlebar-tab]:is(:hover, [data-active="true"]):not([data-editing="true"]) [data-slot="tab-link"] {
[data-titlebar-tab][data-title-overflow="true"]:is(:hover, [data-active="true"]):not([data-editing="true"])
[data-slot="tab-link"] {
--tab-title-fade-offset: 24px;
}
+28 -10
View File
@@ -1,10 +1,10 @@
import { Binary } from "@opencode-ai/core/util/binary"
import type { SessionInboxInfo, SessionInfo } from "@opencode-ai/client/promise"
import type { Message, Part } from "@/types"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { createMemo } from "solid-js"
import { produce, reconcile, type SetStoreFunction } from "solid-js/store"
import type { createServerSdkContext } from "./server-sdk"
import type { createServerSyncContextInner } from "./server-sync"
import type { PromptEcho } from "./server-session"
import type { State } from "./global-sync/types"
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
@@ -82,17 +82,35 @@ export const createDirSyncContext = (
const session = serverSync.session.get(sessionID)
if (session?.location.directory === directory) return session
},
inbox: {
echo(input: PromptEcho & { directory?: string }) {
serverSync.session.inbox.echo(input)
optimistic: {
add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
serverSync.session.optimistic.add(input)
},
confirm(input: SessionInboxInfo) {
return serverSync.session.inbox.confirm(input)
},
clearEcho(input: { directory?: string; sessionID: string; messageID: string }) {
return serverSync.session.inbox.clearEcho(input)
remove(input: { directory?: string; sessionID: string; messageID: string }) {
serverSync.session.optimistic.remove(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 }) {
await serverSync.session.sync(sessionID, options)
index(sessionID)
+1 -1
View File
@@ -143,7 +143,7 @@ describe("encodeFilePath", () => {
})
test("should handle mixed separator path (Windows + Unix)", () => {
// This is what happens in build-prompt-request.ts when concatenating paths
// This is what happens in build-request-parts.ts when concatenating paths
const mixedPath = "D:\\dev\\projects\\opencode/README.bs.md"
const result = encodeFilePath(mixedPath)
const fileUrl = `file://${result}`
+1 -2
View File
@@ -287,8 +287,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; sync: Ser
)
return
const directory = event.current?.location?.directory
if (!directory) return
const directory = e.name
const time = Date.now()
if (event.type === "session.execution.failed") {
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 event = e.details
if (event?.type !== "permission.asked") return
void respondPending(event.properties, event.current?.location?.directory)
void respondPending(event.properties, e.name)
}
const unsubscribe = input.sdk.event.listen((event) => {
+21 -12
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import { adaptServerEvent, coalesceServerEvents, resumeStreamAfterPageShow } from "./server-sdk"
import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
describe("resumeStreamAfterPageShow", () => {
test("restarts a stream only after a back-forward cache restore", () => {
@@ -45,21 +45,23 @@ describe("adaptServerEvent", () => {
})
describe("current event buffering", () => {
const delta = (id: string, value: string, ordinal = 0) =>
adaptServerEvent({
const delta = (id: string, value: string, ordinal = 0) => ({
directory: "/repo",
payload: adaptServerEvent({
id,
created: 1,
type: "session.text.delta",
location: { directory: "/repo" },
data: { sessionID: "ses", assistantMessageID: "msg", ordinal, delta: value },
} as OpenCodeEvent)
} as OpenCodeEvent),
})
test("merges adjacent text deltas for the same message and ordinal", () => {
const result = coalesceServerEvents([delta("evt_1", "hello "), delta("evt_2", "world")])
expect(result).toHaveLength(1)
expect(result[0]?.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
expect(result[0]?.properties).toMatchObject({ delta: "hello world" })
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
expect(result[0]?.payload.properties).toMatchObject({ delta: "hello world" })
})
test("coalesces current tool input deltas by tool ID", () => {
@@ -72,19 +74,26 @@ describe("current event buffering", () => {
data: { sessionID: "ses", assistantMessageID: "msg", id, delta },
} as OpenCodeEvent)
const result = coalesceServerEvents([
current("evt_1", "call_1", "{"),
current("evt_2", "call_1", "}"),
current("evt_3", "call_2", "[]"),
{ directory: "/repo", payload: current("evt_1", "call_1", "{") },
{ directory: "/repo", payload: current("evt_2", "call_1", "}") },
{ directory: "/repo", payload: current("evt_3", "call_2", "[]") },
])
expect(result).toHaveLength(2)
expect(result[0]?.current).toMatchObject({ id: "evt_2", data: { id: "call_1", delta: "{}" } })
expect(result[1]?.current).toMatchObject({ id: "evt_3", data: { id: "call_2", delta: "[]" } })
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { id: "call_1", delta: "{}" } })
expect(result[1]?.payload.current).toMatchObject({ id: "evt_3", data: { id: "call_2", delta: "[]" } })
})
test("preserves boundaries between distinct delta streams", () => {
const events = [delta("evt_1", "a"), delta("evt_2", "b", 1), delta("evt_3", "c")]
expect(coalesceServerEvents(events).map((event) => event.current?.id)).toEqual(["evt_1", "evt_2", "evt_3"])
expect(coalesceServerEvents(events).map((event) => event.payload.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"])
})
})
+29 -31
View File
@@ -12,7 +12,7 @@ import { ServerScope } from "@/utils/server-scope"
import { useServer } from "./server"
export type ServerEvent = Event & { id?: string; current?: OpenCodeEvent }
type ServerEventMap = { [Type in ServerEvent["type"]]: Extract<ServerEvent, { type: Type }> }
type QueuedServerEvent = { directory: string; payload: ServerEvent }
type CurrentDelta = Extract<
OpenCodeEvent,
{ type: "session.text.delta" | "session.reasoning.delta" | "session.tool.input.delta" | "session.compaction.delta" }
@@ -22,17 +22,22 @@ export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
}
export function coalesceServerEvents(events: ServerEvent[]) {
const output: ServerEvent[] = []
export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServerEvent) {
queue.push(event)
return true
}
export function coalesceServerEvents(events: QueuedServerEvent[]) {
const output: QueuedServerEvent[] = []
events.forEach((event) => {
const current = currentDelta(event.current)
const current = currentDelta(event.payload.current)
if (current) {
const previous = output[output.length - 1]
const prior = currentDelta(previous?.current)
const prior = currentDelta(previous?.payload.current)
if (
previous &&
prior &&
prior.location?.directory === current.location?.directory &&
previous.directory === event.directory &&
currentDeltaKey(prior) === currentDeltaKey(current)
) {
const fragment = currentDeltaFragment(prior) + currentDeltaFragment(current)
@@ -41,10 +46,13 @@ export function coalesceServerEvents(events: ServerEvent[]) {
? { ...current.data, text: fragment }
: { ...current.data, delta: fragment }
output[output.length - 1] = {
...event,
properties: data,
current: { ...current, data } as CurrentDelta,
} as ServerEvent
directory: event.directory,
payload: {
...event.payload,
properties: data,
current: { ...current, data } as CurrentDelta,
} as ServerEvent,
}
return
}
output.push(event)
@@ -81,8 +89,7 @@ export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: ()
start()
}
type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<ServerEventMap>>
type ServerLocationEventEmitter = ReturnType<typeof createGlobalEmitter<{ [directory: string]: ServerEvent }>>
type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<{ [key: string]: ServerEvent }>>
export type ServerConnectionStatus = "connecting" | "connected" | "reconnecting"
type ServerSDKBase = {
server: ServerConnection.Any
@@ -97,9 +104,6 @@ type ServerSDKBase = {
event: {
on: ServerEventEmitter["on"]
listen: ServerEventEmitter["listen"]
location: {
on: ServerLocationEventEmitter["on"]
}
}
}
@@ -119,16 +123,18 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
})()
const eventApi = createApiForServer({ server: server.http, fetch: eventFetch })
const emitter = createGlobalEmitter<ServerEventMap>()
const locations = createGlobalEmitter<{ [directory: string]: ServerEvent }>()
const emitter = createGlobalEmitter<{
[key: string]: ServerEvent
}>()
type Queued = QueuedServerEvent
const FLUSH_FRAME_MS = 16
const STREAM_YIELD_MS = 8
const CONNECT_TIMEOUT_MS = 2_000
const RECONNECT_DELAY_MS = 1_000
let queue: ServerEvent[] = []
let buffer: ServerEvent[] = []
let queue: Queued[] = []
let buffer: Queued[] = []
let timer: ReturnType<typeof setTimeout> | undefined
let last = 0
@@ -146,11 +152,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
last = Date.now()
const output = coalesceServerEvents(events)
batch(() => {
output.forEach((event) => {
emitter.emit(event.type, event)
const directory = event.current?.location?.directory
if (directory) locations.emit(directory, event)
})
output.forEach((event) => emitter.emit(event.directory, event.payload))
})
buffer.length = 0
@@ -163,8 +165,8 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
}
function publish(event: OpenCodeEvent) {
queue.push(adaptServerEvent(event))
schedule()
const directory = event.location?.directory ?? "global"
if (enqueueServerEvent(queue, { directory, payload: adaptServerEvent(event) })) schedule()
}
function wait(delay: number, signal: AbortSignal) {
@@ -311,7 +313,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
queue = []
buffer = []
emitter.clear()
locations.clear()
})
const api = createApiForServer({ server: server.http, fetch: platform.fetch })
@@ -329,9 +330,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
event: {
on: emitter.on.bind(emitter),
listen: emitter.listen.bind(emitter),
location: {
on: locations.on.bind(locations),
},
},
}
}
@@ -367,7 +365,7 @@ export type DirectorySDK = {
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): DirectorySDK {
const emitter = createGlobalEmitter<SDKEventMap>()
const unsub = serverSDK.event.location.on(directory, (event) => {
const unsub = serverSDK.event.on(directory, (event) => {
emitter.emit(event.type, event)
})
onCleanup(unsub)
@@ -6,32 +6,6 @@ const event = (input: object) => input as OpenCodeEvent
const base = { created: 1, location: { directory: "/repo" }, durable: { aggregateID: "ses_1", seq: 1, version: 1 } }
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", () => {
const reducer = createV2SessionReducer()
let messages: SessionMessageInfo[] = []
@@ -1,10 +1,4 @@
import type {
OpenCodeEvent,
SessionInboxInfo,
SessionInboxItem,
SessionInfo,
SessionMessageInfo,
} from "@opencode-ai/client/promise"
import type { OpenCodeEvent, SessionInboxItem, SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
type Assistant = Extract<SessionMessageInfo, { type: "assistant" }>
type Compaction = Extract<SessionMessageInfo, { type: "compaction" }>
@@ -35,14 +29,12 @@ export function createV2SessionReducer() {
})
const append = (message: SessionMessageInfo) =>
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) {
case "session.inbox.enqueued":
pending.set(key(sessionID, event.data.inboxID), event.data.item)
if (event.data.item.type === "user")
return replace({
return append({
id: event.data.inboxID,
type: "user",
metadata: event.data.item.payload.metadata,
@@ -52,7 +44,7 @@ export function createV2SessionReducer() {
time: { created: event.created },
})
if (event.data.item.type !== "synthetic") return result([...source])
return replace({
return append({
id: event.data.inboxID,
type: "synthetic",
metadata: event.data.item.payload.metadata,
@@ -488,9 +480,6 @@ export function createV2SessionReducer() {
return {
reduce,
confirm(item: SessionInboxInfo) {
pending.set(key(item.sessionID, item.id), item)
},
clear(sessionID: string) {
for (const id of pending.keys()) {
if (id.startsWith(`${sessionID}:`)) pending.delete(id)
+244 -362
View File
@@ -185,16 +185,6 @@ const textPart = (messageID: string, input: Partial<TextPart> = {}): TextPart =>
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 => ({
data,
response: { headers: new Headers(cursor ? { "x-next-cursor": cursor } : undefined) },
@@ -309,26 +299,6 @@ function setup(sessions: Record<string, SessionInfo>) {
}
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", () => {
const ctx = setup({ child: session("child") })
ctx.store.remember(session("child"))
@@ -370,38 +340,14 @@ describe("server session", () => {
location: { directory: "/repo" },
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({
id: "msg_2_assistant",
type: "assistant",
content: [
{ type: "text", text: "world" },
{ type: "tool", id: "call_z" },
{ type: "tool", id: "call_a" },
],
content: [{ type: "text", text: "world" }],
})
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?.map((part) => part.id)).toEqual([
"msg_2_assistant:text:0",
"call_z",
"call_a",
])
expect(ctx.store.data.part.msg_2_assistant).toMatchObject([{ type: "text", text: "world" }])
})
test("projects V2 pending inputs and forms", () => {
@@ -660,45 +606,6 @@ describe("server session", () => {
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 () => {
const user = { id: "msg_1_user", type: "user", text: "hello", time: { created: 1 } } as const
const assistant = (id: string, created: number) => ({
@@ -803,17 +710,19 @@ describe("server session", () => {
expect(store.data.part[parent.id]).toBeUndefined()
})
test("does not let an admitted user suppress initial root backfill", async () => {
test("does not let an optimistic user suppress initial root backfill", async () => {
const user = userMessage("message-1")
const part = textPart(user.id)
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
const client = rootMessageClient(
[response(assistants.map((info) => ({ info, parts: [] })))],
[singleResponse(user)],
)
const store = createServerSession(client)
store.inbox.echo(promptEcho(user.id, "text"))
store.optimistic.add({ sessionID: "child", message: user, parts: [part] })
await store.sync("child")
store.optimistic.remove({ sessionID: "child", messageID: user.id })
expect(client.requests).toHaveLength(1)
expect(client.rootRequests).toHaveLength(1)
@@ -874,6 +783,28 @@ describe("server session", () => {
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 () => {
const pending = deferredResponse()
const user = userMessage("message-1")
@@ -1109,6 +1040,30 @@ describe("server session", () => {
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 () => {
const stale = userMessage("stale")
const store = createServerSession(messageClient(response()))
@@ -1130,309 +1085,170 @@ describe("server session", () => {
expect(store.data.message.child).toEqual([live, fetched])
})
test("echoes a prompt without changing durable message order", () => {
const store = setup({ child: session("child") }).store
test("does not restore removed optimistic content on refresh", async () => {
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.inbox.echo({
...promptEcho("msg_prompt"),
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.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.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",
},
},
},
])
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\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 },
])
expect(store.data.message.child).toEqual([kept])
expect(store.data.part[message.id]).toBeUndefined()
expect(store.data.part[kept.id]).toBeUndefined()
})
test("preserves a local echo while message history omits pending input", 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" },
})
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?.map((message) => message.id)).toEqual(["msg_prompt"])
expect(store.data.part.msg_prompt).toMatchObject([{ type: "text", text: "hello" }])
expect(store.data.message.child).toEqual([fetched])
})
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")
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: [textPart(message.id, { text: note })] }])),
messageClient(response([{ info: message, parts: [confirmed] }]), response([{ info: message, parts: [updated] }])),
)
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",
},
],
})
store.optimistic.add({ sessionID: "child", message, parts: [confirmed, pendingPart] })
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 },
])
await store.sync("child", { force: true })
store.optimistic.remove({ sessionID: "child", messageID: message.id })
expect(store.data.part[message.id]).toEqual([updated])
})
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" },
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 Promise.all([store.sync("child"), store.hydrateTransient("child", async () => ({ pending: [], forms: [] }))])
store.inbox.reconcile("child")
await store.sync("child", { force: true })
expect(store.data.pending.child).toEqual([])
expect(store.data.message.child).toEqual([])
expect(store.data.part.msg_prompt).toBeUndefined()
expect(store.data.part[message.id]).toEqual([pendingPart])
})
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)
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
store.optimistic.add({ sessionID: "child", message, parts: [part] })
store.apply({
type: "message.part.delta",
properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: " delta" },
})
await store.sync("child")
await store.hydrateTransient("child", async () => ({ pending: [], forms: [] }))
store.inbox.reconcile("child")
store.optimistic.remove({ sessionID: "child", messageID: message.id })
expect(store.data.part[message.id]).toBeUndefined()
expect(store.data.part_text_accum_delta[part.id]).toBeUndefined()
})
test("removes projected messages when rolling back optimistic content", () => {
const message = userMessage("message")
const store = setup({ child: session("child") }).store
store.optimistic.add({ sessionID: "child", message, parts: [] })
store.optimistic.remove({ sessionID: "child", messageID: message.id })
expect(store.data.pending.child).toEqual([])
expect(store.data.session_message.child).toEqual([])
expect(store.data.message.child).toEqual([])
expect(store.data.part.msg_prompt).toBeUndefined()
})
test("deduplicates the durable admission event against its local echo", () => {
test("does not remove content confirmed by a message event", () => {
const message = userMessage("message")
const part = textPart(message.id)
const store = setup({ child: session("child") }).store
store.inbox.echo(promptEcho("msg_prompt"))
store.optimistic.add({ sessionID: "child", message, parts: [part] })
store.apply({ type: "message.updated", properties: { sessionID: "child", info: message } })
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.data.pending.child).toHaveLength(1)
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" }])
expect(store.data.message.child).toEqual([message])
expect(store.data.part[message.id]).toBeUndefined()
})
test("uses the prompt response when the admission event was missed", () => {
test("does not remove parts confirmed by part events", () => {
const message = userMessage("message")
const part = textPart(message.id)
const store = setup({ child: session("child") }).store
store.inbox.echo(promptEcho("msg_prompt"))
store.inbox.confirm({
id: "msg_prompt",
sessionID: "child",
timeCreated: 2,
type: "user",
delivery: "steer",
payload: { text: "hello" },
})
store.optimistic.add({ sessionID: "child", message, parts: [part] })
store.apply({ type: "message.updated", properties: { sessionID: "child", info: message } })
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } })
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)
store.optimistic.remove({ sessionID: "child", messageID: message.id })
expect(store.data.pending.child).toEqual([])
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" }])
expect(store.data.message.child).toEqual([message])
expect(store.data.part[message.id]).toEqual([part])
})
test("keeps a durable admission when the HTTP request later fails", () => {
test("treats a part event as confirmation when it precedes the message event", () => {
const message = userMessage("message")
const part = textPart(message.id)
const store = setup({ child: session("child") }).store
store.inbox.echo(promptEcho("msg_prompt"))
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.add({ sessionID: "child", message, parts: [part] })
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } })
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"])
})
store.optimistic.remove({ sessionID: "child", messageID: message.id })
test("places durable admission after delayed selection events", () => {
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()
expect(store.data.message.child).toEqual([message])
expect(store.data.part[message.id]).toEqual([part])
})
test("clears stale parts when the initial page has none", async () => {
@@ -1653,6 +1469,28 @@ describe("server session", () => {
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 () => {
const failed = Promise.withResolvers<MessageResponse>()
const retried = Promise.withResolvers<MessageResponse>()
@@ -1816,6 +1654,33 @@ describe("server session", () => {
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 () => {
const pending = deferredResponse()
const latest = userMessage("message-2", { time: { created: 2 } })
@@ -2041,7 +1906,24 @@ describe("server session", () => {
test("preserves pinned session content under server-wide cache pressure", () => {
const ctx = setup({})
ctx.store.pin("active")
ctx.store.inbox.echo({ ...promptEcho("message", "keep"), sessionID: "active" })
ctx.store.optimistic.add({
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++) {
ctx.store.remember(session(`session-${index}`))
+279 -256
View File
@@ -18,13 +18,6 @@ import { compareMessages, messageKey, normalizeSessionMessages } from "@/utils/s
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
import { createV2SessionReducer, type V2SessionReduction } from "./server-session-v2-reducer"
import type { ServerApi } from "@/utils/server"
import {
createCommentMetadata,
formatCommentNote,
parseCommentNote,
readCommentMetadata,
type PromptComment,
} from "@/utils/comment-note"
type MessageApi = ServerApi["message"]
@@ -35,6 +28,31 @@ const historyMessagePageSize = 200
const sessionInfoLimit = 2_048
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[]) {
const boundary = source.find(
(message) =>
@@ -46,6 +64,13 @@ function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) {
return boundary?.type === "assistant"
}
type OptimisticItem = {
message: Message
parts: Part[]
confirmedParts?: Part[]
confirmedMessage?: boolean
}
type MessagePage = {
session: Message[]
part: { id: string; part: Part[] }[]
@@ -56,18 +81,6 @@ type MessagePage = {
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.
type MessageLoadState = {
touchedMessages: Set<string>
@@ -77,6 +90,7 @@ type MessageLoadState = {
deltaParts: Map<string, Set<string>>
carriedDeltaParts: Map<string, Set<string>>
removedParts: Map<string, Set<string>>
optimisticParts: Map<string, Set<string>>
orphanParents: Set<string>
clearedMessageParts: Set<string>
touchedSource: Set<string>
@@ -87,6 +101,34 @@ type MessageLoadBaseline = Pick<
"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>) {
const pending = map.get(key)
if (pending) return pending
@@ -161,7 +203,6 @@ export function createServerSession(
input: {} as Record<string, string[]>,
message: {} as Record<string, Message[]>,
session_message: {} as Record<string, SessionMessageInfo[]>,
// Part order is semantic and follows SessionMessageAssistant.content; IDs identify parts only.
part: {} as Record<string, Part[]>,
part_text_accum_delta: {} as Record<string, string>,
session_working(id: string) {
@@ -171,6 +212,7 @@ export function createServerSession(
const requests = new Map<string, Promise<SessionInfo>>()
const inflight = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>()
const optimistic = new Map<string, Map<string, OptimisticItem>>()
const v2 = createV2SessionReducer()
const pendingRevision = new Map<string, number>()
const formRevision = new Map<string, number>()
@@ -181,45 +223,7 @@ export function createServerSession(
const pendingParts = new Map<string, Map<string, Set<string>>>()
const orphanParts = 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 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 = (
cache: { part: Record<string, Part[] | undefined>; part_text_accum_delta: Record<string, string | undefined> },
messageID: string,
@@ -249,6 +253,18 @@ export function createServerSession(
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) => {
setData("info", session.id, reconcile(session))
infoSeen.delete(session.id)
@@ -260,7 +276,7 @@ export function createServerSession(
...inflight.keys(),
...inflightTodo.keys(),
...messageLoads.keys(),
...echoes.keys(),
...optimistic.keys(),
...Object.entries(data.permission)
.filter(([, items]) => items.length > 0)
.map(([sessionID]) => sessionID),
@@ -336,6 +352,65 @@ export function createServerSession(
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 load = messageLoads.get(sessionID)
if (!load) return
@@ -373,6 +448,14 @@ export function createServerSession(
const messages = data.message[sessionID]
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?.retainedMessages.forEach((messageID) => load.retainedMessages.add(messageID))
baseline?.clearedMessageParts.forEach((messageID) => load.clearedMessageParts.add(messageID))
@@ -403,9 +486,7 @@ export function createServerSession(
sessionIDs.forEach((sessionID) => {
messageHydrationRevision.set(sessionID, (messageHydrationRevision.get(sessionID) ?? 0) + 1)
generations.delete(sessionID)
echoes.delete(sessionID)
messageSnapshots.delete(sessionID)
settledInputs.delete(sessionID)
clearOptimistic(sessionID)
requests.delete(sessionID)
inflight.delete(sessionID)
inflightTodo.delete(sessionID)
@@ -440,7 +521,7 @@ export function createServerSession(
...inflight.keys(),
...inflightTodo.keys(),
...messageLoads.keys(),
...echoes.keys(),
...optimistic.keys(),
...Object.entries(data.permission)
.filter(([, items]) => items.length > 0)
.map(([sessionID]) => sessionID),
@@ -475,7 +556,9 @@ export function createServerSession(
const normalized = normalizeSessionMessages(sessionID, source)
return {
session: normalized.messages.sort(compareMessages),
part: [...normalized.parts.entries()].map(([id, part]) => ({ id, part })).sort((a, b) => cmp(a.id, b.id)),
part: [...normalized.parts.entries()]
.map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) }))
.sort((a, b) => cmp(a.id, b.id)),
source,
sourceMode: before ? ("older" as const) : ("latest" as const),
projectSource: true,
@@ -515,10 +598,9 @@ export function createServerSession(
) => {
for (const item of items) {
if (!messageIDs.has(item.id)) continue
const fetched = present(
item.id,
load?.clearedMessageParts.has(item.id) ? [] : item.part.filter((part) => !SKIP_PARTS.has(part.type)),
)
const fetched = load?.clearedMessageParts.has(item.id)
? []
: item.part.filter((part) => !SKIP_PARTS.has(part.type))
const fetchedIDs = new Set(fetched.map((part) => part.id))
const pending = pendingParts.get(sessionID)?.get(item.id)
const touched = new Set([...(load?.touchedParts.get(item.id) ?? []), ...(pending ?? [])])
@@ -569,56 +651,47 @@ export function createServerSession(
preserveUnfetched: boolean | ((message: Message) => 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 incoming = new Map(page.source.map((message) => [message.id, message]))
const existing = data.session_message[sessionID] ?? []
const boundary = Math.min(...page.source.map((message) => message.time.created))
const inbox = new Set(data.input[sessionID] ?? [])
const current = existing.filter(
(message) =>
!incoming.has(message.id) &&
!inbox.has(message.id) &&
(page.sourceMode === "older" ||
load?.touchedSource.has(message.id) ||
(!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]))
return combined.map((message) =>
load?.touchedSource.has(message.id) ? (live.get(message.id) ?? message) : message,
return (page.sourceMode === "older" ? [...page.source, ...current] : [...current, ...page.source]).map(
(message) => (load?.touchedSource.has(message.id) ? (live.get(message.id) ?? message) : message),
)
})()
: undefined
const merged =
const projected =
page.projectSource && source
? (() => {
const normalized = normalizeSessionMessages(sessionID, source)
return {
...page,
session: normalized.messages.sort(compareMessages),
part: [...normalized.parts.entries()].map(([id, part]) => ({ id, part })).sort((a, b) => cmp(a.id, b.id)),
part: [...normalized.parts.entries()]
.map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) }))
.sort((a, b) => cmp(a.id, b.id)),
}
})()
: 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 messages = reconcileFetched(merged.session, data.message[sessionID] ?? [], {
touched: touchedMessages,
retained: load?.retainedMessages,
removed: load?.removedMessages,
preserveUnfetched: (message) =>
echoes.get(sessionID)?.has(message.id) === true ||
preserveUnfetched === true ||
(typeof preserveUnfetched === "function" && preserveUnfetched(message)),
preserveUnfetched,
compare: compareMessages,
})
batch(() => {
@@ -650,6 +723,7 @@ export function createServerSession(
deltaParts: new Map(),
carriedDeltaParts: new Map(),
removedParts: new Map(),
optimisticParts: new Map(),
orphanParents: new Set(),
clearedMessageParts: new Set(),
touchedSource: new Set(),
@@ -670,7 +744,11 @@ export function createServerSession(
const users = new Set([
...page.session.filter((message) => message.role === "user").map((message) => message.id),
...(data.message[sessionID] ?? [])
.filter((message) => message.role === "user" && load.touchedMessages.has(message.id))
.filter((message) => {
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),
])
const parentIDs = [
@@ -815,12 +893,12 @@ export function createServerSession(
apply({ type: "message.updated", properties: { sessionID: reduction.sessionID, info: message } })
}
for (const messageID of touched) {
const next = present(messageID, normalized.parts.get(messageID) ?? [])
const next = normalized.parts.get(messageID) ?? []
const nextIDs = new Set(next.map((part) => part.id))
for (const part of next) {
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
apply({
type: "message.part.removed",
@@ -848,67 +926,6 @@ export function createServerSession(
.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) => {
if (event.type === "form.created") {
formRevision.set(event.data.form.sessionID, (formRevision.get(event.data.form.sessionID) ?? 0) + 1)
@@ -932,9 +949,6 @@ export function createServerSession(
}
if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return
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 (
event.type === "session.inbox.enqueued" ||
event.type === "session.inbox.delivery.changed" ||
@@ -946,10 +960,11 @@ export function createServerSession(
pendingRevision.set(sessionID, (pendingRevision.get(sessionID) ?? 0) + 1)
if (event.type === "session.inbox.enqueued") {
const current = data.pending[sessionID] ?? []
const item = { id: event.data.inboxID, sessionID, timeCreated: event.created, ...event.data.item }
const index = current.findIndex((entry) => entry.id === event.data.inboxID)
if (index < 0) setData("pending", sessionID, [...current, item])
if (index >= 0) setData("pending", sessionID, index, reconcile(item))
if (!current.some((item) => item.id === event.data.inboxID))
setData("pending", sessionID, [
...current,
{ id: event.data.inboxID, sessionID, timeCreated: event.created, ...event.data.item },
])
if (event.data.item.type !== "compaction" && !data.input[sessionID]?.includes(event.data.inboxID))
setData("input", sessionID, [...(data.input[sessionID] ?? []), event.data.inboxID])
}
@@ -1086,9 +1101,16 @@ export function createServerSession(
}
case "message.updated": {
const info = (event.properties as { info: Message }).info
indexProjectedMessage(info)
const load = messageLoads.get(info.sessionID)
load?.touchedMessages.add(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)
orphans?.delete(info.id)
if (orphans?.size === 0) orphanParts.delete(info.sessionID)
@@ -1101,18 +1123,13 @@ export function createServerSession(
return
}
const result = Binary.search(messages, messageKey(info), messageKey)
if (result.found) {
setData("message", info.sessionID, result.index, reconcile(info))
return
}
// Delivery rewrites time.created, changing the sort key; reposition instead of duplicating.
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
})
if (result.found) setData("message", info.sessionID, result.index, reconcile(info))
if (!result.found)
setData("message", info.sessionID, (value = []) => {
const next = value.slice()
next.splice(result.index, 0, info)
return next
})
return
}
case "message.removed": {
@@ -1127,11 +1144,13 @@ export function createServerSession(
load?.deltaParts.delete(props.messageID)
load?.carriedDeltaParts.delete(props.messageID)
load?.removedParts.delete(props.messageID)
load?.optimisticParts.delete(props.messageID)
pendingParts.get(props.sessionID)?.delete(props.messageID)
if (pendingParts.get(props.sessionID)?.size === 0) pendingParts.delete(props.sessionID)
const removedMessagesForSession = removedMessages.get(props.sessionID) ?? new Set<string>()
removedMessagesForSession.add(props.messageID)
removedMessages.set(props.sessionID, removedMessagesForSession)
clearOptimistic(props.sessionID, props.messageID)
setData(
produce((draft) => {
const messages = draft.message[props.sessionID]
@@ -1177,8 +1196,12 @@ export function createServerSession(
pending?.delete(part.id)
if (pending?.size === 0) pendingParts.get(part.sessionID)?.delete(part.messageID)
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)
trackPartChange(part.sessionID, part.messageID, part.id)
confirmOptimisticPart(part.sessionID, part.messageID, part)
setData(
"part_text_accum_delta",
produce((draft) => void delete draft[part.id]),
@@ -1188,9 +1211,14 @@ export function createServerSession(
setData("part", part.messageID, [part])
return
}
const index = parts.findIndex((item) => item.id === part.id)
if (index >= 0) setData("part", part.messageID, index, reconcile(part))
if (index < 0) setData("part", part.messageID, (value = []) => [...value, part])
const result = Binary.search(parts, part.id, (item) => item.id)
if (result.found) setData("part", part.messageID, result.index, reconcile(part))
if (!result.found)
setData("part", part.messageID, (value = []) => {
const next = value.slice()
next.splice(result.index, 0, part)
return next
})
return
}
case "message.part.removed": {
@@ -1212,16 +1240,20 @@ export function createServerSession(
const parts = load.removedParts.get(props.messageID) ?? new Set<string>()
parts.add(props.partID)
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)
clearOptimisticPart(props.sessionID, props.messageID, props.partID)
setData(
produce((draft) => {
delete draft.part_text_accum_delta[props.partID]
deltaBases.delete(props.partID)
const parts = draft.part[props.messageID]
if (!parts) return
const index = parts.findIndex((part) => part.id === props.partID)
if (index >= 0) parts.splice(index, 1)
const result = Binary.search(parts, props.partID, (part) => part.id)
if (result.found) parts.splice(result.index, 1)
if (parts.length === 0) delete draft.part[props.messageID]
}),
)
@@ -1237,8 +1269,8 @@ export function createServerSession(
}
const parts = data.part[props.messageID]
if (!parts) return
const index = parts.findIndex((part) => part.id === props.partID)
if (index < 0) return
const result = Binary.search(parts, props.partID, (part) => part.id)
if (!result.found) return
trackPartChange(props.sessionID, props.messageID, props.partID)
const load = messageLoads.get(props.sessionID)
if (load) {
@@ -1250,7 +1282,7 @@ export function createServerSession(
if (carried?.size === 0) load.carriedDeltaParts.delete(props.messageID)
}
const field = props.field as keyof (typeof parts)[number]
const current = parts[index]?.[field]
const current = parts[result.index]?.[field]
if (!deltaBases.has(props.partID) && typeof current === "string")
deltaBases.set(props.partID, { base: current, sessionID: props.sessionID })
setData(
@@ -1263,7 +1295,7 @@ export function createServerSession(
props.messageID,
produce((draft) => {
if (!draft) return
const part = draft[index]
const part = draft[result.index]
const field = props.field as keyof typeof part
;(part[field] as string) = ((part[field] as string | undefined) ?? "") + props.delta
}),
@@ -1322,30 +1354,25 @@ export function createServerSession(
while (true) {
const pendingAt = pendingRevision.get(sessionID) ?? 0
const formAt = formRevision.get(sessionID) ?? 0
const previous = new Set(data.input[sessionID] ?? [])
const result = await load()
const pendingStable = (pendingRevision.get(sessionID) ?? 0) === pendingAt
const formStable = (formRevision.get(sessionID) ?? 0) === formAt
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("input", sessionID, reconcile([...current]))
setData(
"input",
sessionID,
reconcile(result.pending.filter((item) => item.type !== "compaction").map((item) => item.id)),
)
}
if (formStable) setData("form", sessionID, reconcile(result.forms))
if (pendingStable && formStable) return
}
},
refreshPinned(hydrateTransient: (sessionID: string) => Promise<void>) {
const sessions = [...pinned.keys()]
return Promise.all(
sessions.flatMap((sessionID) => [sync(sessionID, { force: true }), hydrateTransient(sessionID)]),
).then(() => sessions.forEach(reconcileInbox))
[...pinned.keys()].flatMap((sessionID) => [sync(sessionID, { force: true }), hydrateTransient(sessionID)]),
).then(() => undefined)
},
invalidate() {
invalidationRevision += 1
@@ -1363,72 +1390,68 @@ export function createServerSession(
fresh(sessionID: string, ttl: number) {
return Date.now() - (meta.at[sessionID] ?? 0) <= ttl
},
inbox: {
echo(input: PromptEcho) {
const created = Date.now()
const files = input.files?.map((file) => ({
data: "",
mime: file.mime,
source: { type: "uri" as const, uri: file.uri },
name: file.name,
mention: file.mention,
}))
const item: SessionInboxInfo = {
id: input.messageID,
sessionID: input.sessionID,
timeCreated: created,
type: "user",
delivery: "steer",
payload: { text: input.text, files, agents: input.agents },
optimistic: {
add(input: { sessionID: string; message: Message; parts: Part[] }) {
const parts = input.parts
.filter((part) => !!part?.id && !SKIP_PARTS.has(part.type))
.sort((a, b) => cmp(a.id, b.id))
const load = messageLoads.get(input.sessionID)
if (load?.clearedMessageParts.has(input.message.id)) {
const touched = load.touchedParts.get(input.message.id) ?? new Set<string>()
parts.forEach((part) => touched.add(part.id))
load.touchedParts.set(input.message.id, touched)
}
const projected = normalizeSessionMessages(input.sessionID, [
{ id: `${input.messageID}:agent`, type: "agent-switched", agent: input.agent, time: { created } },
{
id: `${input.messageID}:model`,
type: "model-switched",
model: {
id: input.model.modelID,
providerID: input.model.providerID,
variant: input.model.variant,
},
time: { created },
},
{
id: input.messageID,
type: "user",
text: input.displayText,
files,
agents: input.agents,
time: { created },
},
])
const message = projected.messages[0]!
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)
})
if (load) {
load.removedMessages.delete(input.message.id)
load.optimisticParts.set(input.message.id, new Set(parts.map((part) => part.id)))
}
const items = optimistic.get(input.sessionID)
const removedMessagesForSession = removedMessages.get(input.sessionID)
removedMessagesForSession?.delete(input.message.id)
if (removedMessagesForSession?.size === 0) removedMessages.delete(input.sessionID)
if (items) items.set(input.message.id, { ...input, parts, confirmedParts: [] })
if (!items)
optimistic.set(input.sessionID, new Map([[input.message.id, { ...input, parts, confirmedParts: [] }]]))
indexProjectedMessage(input.message)
setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]).sort(compareMessages))
setData(
"part_text_accum_delta",
produce((draft) => {
for (const part of [...(data.part[input.message.id] ?? []), ...parts]) {
delete draft[part.id]
deltaBases.delete(part.id)
}
}),
)
setData("part", input.message.id, parts)
},
confirm: confirmInbox,
reconcile: reconcileInbox,
clearEcho(input: { sessionID: string; messageID: string }) {
if (echoes.get(input.sessionID)?.get(input.messageID) !== "sending") return false
return removeEcho(input.sessionID, input.messageID)
remove(input: { sessionID: string; messageID: string }) {
const item = optimistic.get(input.sessionID)?.get(input.messageID)
if (!item) return
messageLoads.get(input.sessionID)?.optimisticParts.delete(input.messageID)
clearOptimistic(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 }) {
@@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test"
import type {
McpListInput,
McpResourceCatalogInput,
OpenCodeEvent,
SessionApi,
SessionInfo,
SessionListInput,
@@ -16,13 +15,11 @@ import {
loadMcpResourcesQuery,
reconcileActiveSessionStatuses,
seedActiveSessionStatuses,
sessionListEventDirectories,
shouldRefreshWorkspaceSessions,
} from "./server-sync"
import { ServerScope } from "@/utils/server-scope"
import { createServerSession } from "./server-session"
import type { ServerApi } from "@/utils/server"
import { adaptServerEvent } from "./server-sdk"
type McpApi = ServerApi["mcp"]
@@ -217,23 +214,6 @@ describe("workspace session inventory", () => {
expect(shouldRefreshWorkspaceSessions(event("session.updated", "session.moved"))).toBe(true)
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", () => {
+16 -18
View File
@@ -88,12 +88,6 @@ const SESSION_LIST_EVENTS = new Set([
"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 = {
readonly list: (input?: McpListInput) => Promise<McpListOutput>
}
@@ -237,10 +231,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
return { pending, forms }
})
}
const hydrateSession = async (sessionID: string) => {
await Promise.all([session.sync(sessionID), hydrateSessionState(sessionID)])
session.inbox.reconcile(sessionID)
}
const hydrateSession = (sessionID: string) => Promise.all([session.sync(sessionID), hydrateSessionState(sessionID)])
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
queries: [
@@ -560,11 +551,14 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
type: "session.updated",
properties: { sessionID: info.id, info },
})
const markSessionListsChanged = (event: ServerEvent) => {
sessionListEventDirectories(event).forEach((directory) => {
const markSessionListChanged = (event: ServerEvent, directory: string, previousDirectory?: string) => {
if (SESSION_LIST_EVENTS.has(event.current?.type ?? event.type)) {
const key = directoryKey(directory)
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) => {
if (event.current?.type === "session.created") return
@@ -575,10 +569,15 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
}
const unsub = serverSDK.event.listen((e) => {
const directory = e.name
const key = directoryKey(directory)
const event = e.details
const directory = event.current?.location?.directory
const eventType: string = event.type
markSessionListsChanged(event)
const previousDirectory =
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)
session.apply(event)
if (event.current?.type === "session.moved") {
@@ -630,9 +629,9 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
}
homeSessions.refresh(event.type)
catalog.handleEvent({ type: eventType, directory })
connection.handleEvent({ type: eventType })
connection.handleEvent({ type: eventType, directory })
if (!directory) {
if (directory === "global") {
applyGlobalEvent({
event,
project: globalStore.project,
@@ -645,7 +644,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
return
}
const key = directoryKey(directory)
if (event.current?.type === "session.forked")
void session
.resolve(event.current.data.sessionID, { force: true })
@@ -42,7 +42,7 @@ test("invalidates global and active catalogs after connection", async () => {
load: async () => {},
})
catalog.handleEvent({ type: "server.connected" })
catalog.handleEvent({ type: "server.connected", directory: "global" })
await Bun.sleep(0)
expect(queryClient.getQueryState(global)?.isInvalidated).toBe(true)
@@ -4,7 +4,7 @@ import { pathKey, type PathKey } from "@/utils/path-key"
type CatalogEvent = {
type: string
directory?: string
directory: string
}
export function createCatalogSync(input: {
@@ -24,7 +24,7 @@ export function createCatalogSync(input: {
event.type === "integration.updated" ||
event.type === "integration.connection.updated"
) {
void refresh(event.directory ? pathKey(event.directory) : null).catch(() => undefined)
void refresh(event.directory === "global" ? null : pathKey(event.directory)).catch(() => undefined)
}
}
@@ -12,8 +12,10 @@ test("invalidates disconnected data and synchronizes after the handshake", () =>
connected: () => calls.push("connected"),
})
connection.handleEvent({ type: "server.connected" })
connection.handleEvent({ type: "server.connected", directory: "global" })
expect(calls).toContain("connected")
connection.handleEvent({ type: "server.connected", directory: "/repo" })
expect(calls.filter((call) => call === "connected")).toHaveLength(1)
setStatus("connected")
return dispose
})
@@ -12,8 +12,8 @@ export function createConnectionSync(input: {
})
let connectedOnce = false
function handleEvent(event: { type: string }) {
if (event.type !== "server.connected") return
function handleEvent(event: { type: string; directory: string }) {
if (event.directory !== "global" || event.type !== "server.connected") return
input.connected({ reconnect: connectedOnce })
connectedOnce = true
}
@@ -0,0 +1,137 @@
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,6 +1,114 @@
import { Binary } from "@opencode-ai/core/util/binary"
import { createMemo } from "solid-js"
import { useServerSync } from "./server-sync"
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 = () => {
const serverSync = useServerSync()
+5 -16
View File
@@ -2,7 +2,6 @@ import { useQueryOptions } from "@/context/server-sync"
import { Iterable, pipe } from "effect"
import { type Accessor } from "solid-js"
import { emptyProviderCatalog } from "./provider-catalog"
import { useIntegrations } from "./use-integrations"
import { useQuery } from "@tanstack/solid-query"
import { pathKey } from "@/utils/path-key"
@@ -24,7 +23,6 @@ export function useProviders(directory: Accessor<string | undefined>) {
const dir = directory()
return queryOpts.providers(dir ? pathKey(dir) : null)
})
const integrations = useIntegrations(directory)
const providers = () => (!providersQuery.isSuccess ? emptyProviderCatalog : providersQuery.data)
@@ -32,22 +30,13 @@ export function useProviders(directory: Accessor<string | undefined>) {
ready: () => providersQuery.isSuccess,
all: () => providers().all,
default: () => providers().default,
// V2 servers list only available providers, so the connectable catalog
// 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(
popular: () =>
pipe(
providers().all,
Iterable.map(([, p]) => p),
Iterable.filter((p) => popularProviderSet.has(p.id) && !seen.has(p.id)),
Iterable.map((p) => ({ id: p.id, name: p.name })),
(v) => [...catalog, ...v],
)
},
Iterable.filter((p) => popularProviderSet.has(p.id)),
(v) => Array.from(v),
),
connected: () => {
const connected = new Set(providers().connected)
return pipe(
+1 -1
View File
@@ -4,7 +4,7 @@ export type UpdaterState =
| { status: "disabled" }
| { status: "idle" }
| { status: "checking" }
| { status: "downloading"; version: string }
| { status: "downloading"; version: string; percent?: number }
| { status: "ready"; version: string }
| { status: "up-to-date" }
| { status: "installing"; version: string }
+1 -4
View File
@@ -22,10 +22,7 @@ function blobUrl(id: string, blob: Blob) {
}
async function blobID(blob: Blob) {
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)
const id = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", await blob.arrayBuffer())))
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("")
return id
+2 -2
View File
@@ -13,7 +13,7 @@ import { ConfigAgentV1 } from "../../v1/config/agent.js"
import { ConfigMigrateV1 } from "../../v1/config/migrate.js"
import { Global } from "@opencode-ai/util/global"
import { Permission } from "../../permission.js"
import type { LocationPath } from "../../location-path.js"
import type { LocationMutation } from "../../location-mutation.js"
import type { ReadTool } from "../../tool/plugin/read.js"
import type { EditTool } from "../../tool/plugin/edit.js"
import { AbsolutePath } from "../../schema.js"
@@ -28,7 +28,7 @@ const decodeAgent = Schema.decodeUnknownOption(ConfigAgent.Info)
const decodeLegacyAgent = Schema.decodeUnknownOption(ConfigAgentV1.Info)
const decodeConfig = Schema.decodeUnknownOption(Info)
type PathAction =
| LocationPath.ExternalDirectoryAuthorization["action"]
| LocationMutation.ExternalDirectoryAuthorization["action"]
| typeof ReadTool.name
| typeof EditTool.name
const pathActions = ["external_directory", "read", "edit"] as const satisfies readonly PathAction[]
+20 -36
View File
@@ -23,32 +23,6 @@ export type Options = typeof Options.Type
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem/Search") {}
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(
Service,
@@ -58,13 +32,12 @@ export const ripgrepLayer = Layer.effect(
const scope = yield* Scope.Scope
const clock = yield* Clock.Clock
const home = Protected.isHome(location.directory)
let index = emptyIndex()
let index = { files: [] as string[], directories: new Set<string>() }
let initialized = false
let settledAt = Number.NEGATIVE_INFINITY
let refreshing = false
const scan = Effect.gen(function* () {
const next = emptyIndex()
const previous = index
const next = { files: [] as string[], directories: new Set<string>() }
if (!initialized) index = next
yield* ripgrep.find({
cwd: location.directory,
@@ -73,13 +46,11 @@ export const ripgrepLayer = Layer.effect(
exclude: home ? [...Protected.names()].map((name) => `${name}/**`) : undefined,
onEntry: (entry) =>
Effect.sync(() => {
next.files.set(entry.path, previous.files.get(entry.path) ?? fuzzysort.prepare(entry.path))
next.files.push(entry.path)
const parts = entry.path.split("/")
parts.slice(0, -1).forEach((_, offset) => {
const directory = parts.slice(0, offset + 1).join("/") + path.sep
if (!next.directories.has(directory))
next.directories.set(directory, previous.directories.get(directory) ?? fuzzysort.prepare(directory))
})
parts
.slice(0, -1)
.forEach((_, offset) => next.directories.add(parts.slice(0, offset + 1).join("/") + path.sep))
}),
})
index = next
@@ -103,7 +74,20 @@ export const ripgrepLayer = Layer.effect(
find: (input) =>
Effect.gen(function* () {
yield* refresh
return search(index, input)
const items =
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,
})
})
}),
})
}),
@@ -1,4 +1,4 @@
export * as LocationPath from "./location-path.js"
export * as LocationMutation from "./location-mutation.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import path from "path"
@@ -12,8 +12,8 @@ export const Kind = Schema.Literals(["file", "directory"])
export type Kind = typeof Kind.Type
/**
* Tool paths do not accept project references. Relative paths resolve from
* the active Location. Paths outside its project require separate
* Mutation paths do not accept project references. Relative paths resolve
* from the active Location. Paths outside it require separate
* `external_directory` approval.
*/
export const ResolveInput = Schema.Struct({
@@ -49,13 +49,13 @@ export interface Target {
export interface Interface {
/**
* Resolve a path and derive its permission resources. Relative paths resolve
* from the Location. Paths outside its project require separate
* `external_directory` approval. This does not approve access.
* from the Location. Paths outside it require separate `external_directory`
* approval. This does not approve the mutation.
*/
readonly resolve: (input: ResolveInput) => Effect.Effect<Target, FSUtil.Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/LocationPath") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/LocationMutation") {}
const slash = (value: string) => value.replaceAll("\\", "/")
@@ -65,13 +65,9 @@ const layer = Layer.effect(
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const resolve = Effect.fn("LocationPath.resolve")(function* (input: ResolveInput) {
const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) {
const absolute = path.resolve(location.directory, input.path)
const projectRoot = path.parse(location.project.directory).root
if (
FSUtil.contains(location.directory, absolute) ||
(location.project.directory !== projectRoot && FSUtil.contains(location.project.directory, absolute))
) {
if (FSUtil.contains(location.directory, absolute)) {
return {
absolute,
resource: slash(path.relative(location.directory, absolute) || "."),
+7 -11
View File
@@ -1,5 +1,4 @@
import { Effect, Layer, LayerMap } from "effect"
import path from "path"
import { Agent } from "./agent.js"
import { AISDK } from "./aisdk.js"
import { Catalog } from "./catalog.js"
@@ -19,7 +18,7 @@ import { Image } from "./image.js"
import { LocationWatcher } from "./filesystem/location-watcher.js"
import { Integration } from "./integration.js"
import { Location } from "./location.js"
import { LocationPath } from "./location-path.js"
import { LocationMutation } from "./location-mutation.js"
import { LocationServiceMap } from "./location-service-map.js"
import { ModelResolver } from "./model-resolver.js"
import { MCP } from "./mcp/index.js"
@@ -50,7 +49,6 @@ import { ReadToolFileSystem } from "./tool/read-filesystem.js"
import { Tool } from "./tool.js"
import { ToolOutput } from "./tool-output.js"
import { Vcs } from "./vcs.js"
import { AbsolutePath } from "./schema.js"
export { LocationServiceMap } from "./location-service-map.js"
@@ -76,7 +74,7 @@ const locationServiceNodes = [
Skill.node,
InstructionBuiltIns.node,
InstructionDiscovery.node,
LocationPath.node,
LocationMutation.node,
FileMutation.node,
Formatter.node,
MCP.node,
@@ -112,13 +110,11 @@ export type LocationError = LayerNode.Error<typeof locationServices>
export function buildLocationServiceMap(
replacements: LayerNode.Replacements = [],
): Layer.Layer<LocationServiceMap.Service> {
// Structural Equal distinguishes optional-key shape and Windows separator style.
// The RcMap caches the raw key before the build callback, so normalize both here.
const canonical = (ref: Location.Ref) =>
Location.Ref.make({
directory: AbsolutePath.make(process.platform === "win32" ? path.normalize(ref.directory) : ref.directory),
workspaceID: ref.workspaceID,
})
// Structural Equal is own-key-set sensitive, so `{ directory }` (schema-decoded
// payloads omit optional keys) and `{ directory, workspaceID: undefined }` are
// different RcMap keys. The RcMap caches by the raw key before the build
// callback runs, so canonicalize at the map boundary to the key-present shape.
const canonical = (ref: Location.Ref) => Location.Ref.make({ directory: ref.directory, workspaceID: ref.workspaceID })
return Layer.effect(
LocationServiceMap.Service,
Effect.map(
+4 -4
View File
@@ -32,7 +32,7 @@ import { InstructionDiscovery } from "../instruction-discovery.js"
import { Integration } from "../integration.js"
import { KV } from "../kv.js"
import { Location } from "../location.js"
import { LocationPath } from "../location-path.js"
import { LocationMutation } from "../location-mutation.js"
import { ModelsDev } from "../models-dev.js"
import { Npm } from "@opencode-ai/util/npm"
import { Permission } from "../permission.js"
@@ -92,7 +92,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const integration = yield* Integration.Service
const kv = yield* KV.Service
const location = yield* Location.Service
const locationMutation = yield* LocationPath.Service
const locationMutation = yield* LocationMutation.Service
const models = yield* ModelsDev.Service
const npm = yield* Npm.Service
const permission = yield* Permission.Service
@@ -129,7 +129,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(Integration.Service, integration),
Context.make(KV.Service, kv),
Context.make(Location.Service, location),
Context.make(LocationPath.Service, locationMutation),
Context.make(LocationMutation.Service, locationMutation),
Context.make(ModelsDev.Service, models),
Context.make(Npm.Service, npm),
Context.make(Permission.Service, permission),
@@ -173,7 +173,7 @@ export const requirements = LayerNode.group([
Integration.node,
KV.node,
Location.node,
LocationPath.node,
LocationMutation.node,
ModelsDev.node,
Npm.node,
Permission.node,
+3 -3
View File
@@ -16,7 +16,7 @@ import { Environment } from "../../environment/index.js"
import { FileMutation } from "../../file-mutation.js"
import { Formatter } from "../../formatter.js"
import { Location } from "../../location.js"
import { LocationPath } from "../../location-path.js"
import { LocationMutation } from "../../location-mutation.js"
import { Permission } from "../../permission.js"
import { fileDiff } from "./file-diff.js"
@@ -110,7 +110,7 @@ const findLineOccurrences = (content: string, search: string) => {
export const Plugin = {
id: "opencode.tool.edit",
effect: Effect.fn("EditTool.Plugin")(function* (ctx: PluginContext) {
const mutation = yield* LocationPath.Service
const mutation = yield* LocationMutation.Service
const fileMutation = yield* FileMutation.Service
const environment = yield* Environment.Service
const formatter = yield* Formatter.Service
@@ -148,7 +148,7 @@ export const Plugin = {
const external = target.externalDirectory
if (external) {
yield* permission.assert({
...LocationPath.externalDirectoryPermission(external),
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source: permissionSource,
+3 -3
View File
@@ -7,7 +7,7 @@ import path from "path"
import { Environment } from "../../environment/index.js"
import { FileSystem } from "../../filesystem.js"
import { Location } from "../../location.js"
import { LocationPath } from "../../location-path.js"
import { LocationMutation } from "../../location-mutation.js"
import { Ripgrep } from "../../ripgrep.js"
import { RelativePath } from "../../schema.js"
import { Permission } from "../../permission.js"
@@ -45,7 +45,7 @@ export const Plugin = {
const environment = yield* Environment.Service
const ripgrep = yield* Ripgrep.Service
const location = yield* Location.Service
const mutation = yield* LocationPath.Service
const mutation = yield* LocationMutation.Service
const permission = yield* Permission.Service
yield* ctx.tool
@@ -64,7 +64,7 @@ export const Plugin = {
const external = target.externalDirectory
if (external)
yield* permission.assert({
...LocationPath.externalDirectoryPermission(external),
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source,
+3 -3
View File
@@ -7,7 +7,7 @@ import path from "path"
import { Environment } from "../../environment/index.js"
import { FileSystem } from "../../filesystem.js"
import { Location } from "../../location.js"
import { LocationPath } from "../../location-path.js"
import { LocationMutation } from "../../location-mutation.js"
import { Permission } from "../../permission.js"
import { Ripgrep } from "../../ripgrep.js"
import { RelativePath } from "../../schema.js"
@@ -61,7 +61,7 @@ export const Plugin = {
const environment = yield* Environment.Service
const ripgrep = yield* Ripgrep.Service
const location = yield* Location.Service
const mutation = yield* LocationPath.Service
const mutation = yield* LocationMutation.Service
const permission = yield* Permission.Service
yield* ctx.tool
@@ -79,7 +79,7 @@ export const Plugin = {
const target = yield* mutation.resolve({ path: input.path ?? "." })
if (target.externalDirectory)
yield* permission.assert({
...LocationPath.externalDirectoryPermission(target.externalDirectory),
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
sessionID: context.sessionID,
agent: context.agent,
source,
+7 -7
View File
@@ -10,7 +10,7 @@ import { Environment } from "../../environment/index.js"
import { Formatter } from "../../formatter.js"
import { FileMutation } from "../../file-mutation.js"
import { Location } from "../../location.js"
import { LocationPath } from "../../location-path.js"
import { LocationMutation } from "../../location-mutation.js"
import { Patch } from "@opencode-ai/util/patch"
import { Permission } from "../../permission.js"
import DESCRIPTION from "../patch.txt"
@@ -46,29 +46,29 @@ export const toModelOutput = (output: Output) =>
type Prepared =
| (Extract<Patch.Hunk, { readonly type: "add" }> & {
readonly target: LocationPath.Target
readonly target: LocationMutation.Target
readonly content: string
readonly before: string
readonly after: string
})
| (Extract<Patch.Hunk, { readonly type: "delete" }> & {
readonly target: LocationPath.Target
readonly target: LocationMutation.Target
readonly before: string
readonly after: string
})
| (Extract<Patch.Hunk, { readonly type: "update" }> & {
readonly target: LocationPath.Target
readonly target: LocationMutation.Target
readonly content: string
readonly before: string
readonly after: string
readonly moveTarget?: LocationPath.Target
readonly moveTarget?: LocationMutation.Target
})
export const Plugin = {
id: "opencode.tool.patch",
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
const environment = yield* Environment.Service
const mutation = yield* LocationPath.Service
const mutation = yield* LocationMutation.Service
const fileMutation = yield* FileMutation.Service
const formatter = yield* Formatter.Service
const location = yield* Location.Service
@@ -116,7 +116,7 @@ export const Plugin = {
const target = yield* mutation.resolve({ path: value, kind: "file" })
if (!target.externalDirectory) return target
yield* permission.assert({
...LocationPath.externalDirectoryPermission(target.externalDirectory),
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
metadata: {
filepath: target.absolute,
parentDir: target.externalDirectory.directory,
+3 -3
View File
@@ -6,7 +6,7 @@ import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "../../location.js"
import { LocationPath } from "../../location-path.js"
import { LocationMutation } from "../../location-mutation.js"
import { Permission } from "../../permission.js"
import { SessionInstructions } from "../../session/instructions.js"
import { AbsolutePath } from "../../schema.js"
@@ -32,7 +32,7 @@ export const Plugin = {
id: "opencode.tool.read",
effect: Effect.fn("ReadTool.Plugin")(function* (ctx: PluginContext) {
const reader = yield* ReadToolFileSystem.Service
const mutation = yield* LocationPath.Service
const mutation = yield* LocationMutation.Service
const permission = yield* Permission.Service
const sessionInstructions = yield* SessionInstructions.Service
const fs = yield* FSUtil.Service
@@ -58,7 +58,7 @@ export const Plugin = {
const external = target.externalDirectory
if (external)
yield* permission.assert({
...LocationPath.externalDirectoryPermission(external),
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source,
+2 -2
View File
@@ -7,7 +7,7 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
import { Deferred, Effect, Schema, Scope } from "effect"
import { Config } from "../../config.js"
import { Environment } from "../../environment/index.js"
import { LocationPath } from "../../location-path.js"
import { LocationMutation } from "../../location-mutation.js"
import { Permission } from "../../permission.js"
import { PluginRuntime } from "../../plugin/runtime.js"
import { NonNegativeInt } from "../../schema.js"
@@ -84,7 +84,7 @@ export const Plugin = {
const runtime = yield* PluginRuntime.Service
const scope = yield* Scope.Scope
const environment = yield* Environment.Service
const mutation = yield* LocationPath.Service
const mutation = yield* LocationMutation.Service
const shell = yield* Shell.Service
const permission = yield* Permission.Service
const config = yield* Config.Service
+3 -3
View File
@@ -13,7 +13,7 @@ import { Bom } from "@opencode-ai/util/bom"
import { Environment } from "../../environment/index.js"
import { FileMutation } from "../../file-mutation.js"
import { Formatter } from "../../formatter.js"
import { LocationPath } from "../../location-path.js"
import { LocationMutation } from "../../location-mutation.js"
import { Permission } from "../../permission.js"
import { fileDiff } from "./file-diff.js"
@@ -46,7 +46,7 @@ export const toModelOutput = (output: Output) =>
export const Plugin = {
id: "opencode.tool.write",
effect: Effect.fn("WriteTool.Plugin")(function* (ctx: PluginContext) {
const mutation = yield* LocationPath.Service
const mutation = yield* LocationMutation.Service
const fileMutation = yield* FileMutation.Service
const environment = yield* Environment.Service
const formatter = yield* Formatter.Service
@@ -72,7 +72,7 @@ export const Plugin = {
const external = target.externalDirectory
if (external)
yield* permission.assert({
...LocationPath.externalDirectoryPermission(external),
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source,
+9 -9
View File
@@ -7,7 +7,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Environment } from "@opencode-ai/core/environment/index"
import { Location } from "@opencode-ai/core/location"
import { LocationPath } from "@opencode-ai/core/location-path"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { type EnvironmentFilesTransform, transformEnvironmentFiles } from "./fixture/environment"
import { location } from "./fixture/location"
@@ -20,7 +20,7 @@ function provide(directory: string, transformFiles: EnvironmentFilesTransform =
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
)
return Effect.provide(
AppNodeBuilder.build(LayerNode.group([LocationPath.node, FileMutation.node]), [
AppNodeBuilder.build(LayerNode.group([LocationMutation.node, FileMutation.node]), [
[Location.node, activeLocation],
[Environment.node, transformEnvironmentFiles(activeLocation, transformFiles)],
]),
@@ -40,7 +40,7 @@ describe("FileMutation", () => {
Effect.gen(function* () {
const targetPath = path.join(directory, "hello.txt")
yield* Effect.promise(() => fs.writeFile(targetPath, "before"))
const target = yield* (yield* LocationPath.Service).resolve({ path: "hello.txt" })
const target = yield* (yield* LocationMutation.Service).resolve({ path: "hello.txt" })
expect(yield* (yield* FileMutation.Service).write({ target, content: "after" })).toEqual({
operation: "write",
@@ -56,7 +56,7 @@ describe("FileMutation", () => {
it.live("writes a prospective internal file and creates parent directories", () =>
withTmp((directory) =>
Effect.gen(function* () {
const target = yield* (yield* LocationPath.Service).resolve({
const target = yield* (yield* LocationMutation.Service).resolve({
path: path.join("src", "nested", "hello.txt"),
})
const result = yield* (yield* FileMutation.Service).write({ target, content: "hello" })
@@ -77,8 +77,8 @@ describe("FileMutation", () => {
Effect.gen(function* () {
const preservedPath = path.join(directory, "preserved.txt")
yield* Effect.promise(() => fs.writeFile(preservedPath, "\uFEFFbefore"))
const preserved = yield* (yield* LocationPath.Service).resolve({ path: "preserved.txt" })
const created = yield* (yield* LocationPath.Service).resolve({ path: "created.txt" })
const preserved = yield* (yield* LocationMutation.Service).resolve({ path: "preserved.txt" })
const created = yield* (yield* LocationMutation.Service).resolve({ path: "created.txt" })
const files = yield* FileMutation.Service
yield* files.writeTextPreservingBom({ target: preserved, content: "\uFEFFafter" })
@@ -95,7 +95,7 @@ describe("FileMutation", () => {
withTmp((outside) =>
Effect.gen(function* () {
const targetPath = path.join(outside, "external.txt")
const target = yield* (yield* LocationPath.Service).resolve({ path: targetPath })
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
const result = yield* (yield* FileMutation.Service).write({ target, content: "external" })
expect(result).toEqual({
@@ -133,7 +133,7 @@ describe("FileMutation", () => {
)
yield* Effect.gen(function* () {
const mutation = yield* LocationPath.Service
const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service
const firstPlan = yield* mutation.resolve({ path: "shared.txt" })
const secondPlan = yield* mutation.resolve({ path: "shared.txt" })
@@ -222,7 +222,7 @@ describe("FileMutation", () => {
)
yield* Effect.gen(function* () {
const mutation = yield* LocationPath.Service
const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service
const firstPlan = yield* mutation.resolve({ path: "first.txt" })
const secondPlan = yield* mutation.resolve({ path: "second.txt" })
+1 -56
View File
@@ -1,5 +1,4 @@
import { describe, expect, spyOn, test } from "bun:test"
import fuzzysort from "fuzzysort"
import { describe, expect, test } from "bun:test"
import os from "os"
import path from "path"
import { Deferred, Effect, Layer } from "effect"
@@ -124,58 +123,4 @@ describe("FileSystemSearch", () => {
}).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()
})
})
+3 -7
View File
@@ -510,7 +510,7 @@ describe("LocationServiceMap", () => {
),
)
it.live("normalizes equivalent refs to one cached location graph", () =>
it.live("normalizes ref key shapes to one cached location graph", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
@@ -520,20 +520,16 @@ describe("LocationServiceMap", () => {
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const directory = AbsolutePath.make(dir.path)
const alternate = AbsolutePath.make(directory.replaceAll("\\", "/"))
const absent = Location.Ref.make({ directory: alternate })
const absent = Location.Ref.make({ directory })
const present = Location.Ref.make({ directory, workspaceID: undefined })
// The two shapes are not structurally Equal: own-key sets differ.
expect(Object.keys(absent)).toEqual(["directory"])
expect(Object.keys(present)).toEqual(["directory", "workspaceID"])
expect(Equal.equals(absent, present)).toBe(false)
if (process.platform === "win32") expect(absent.directory).not.toBe(present.directory)
const first = yield* locations.contextEffect(absent)
expect(yield* locations.contextEffect(present)).toBe(first)
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([
Location.Ref.make({ directory, workspaceID: undefined }),
])
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toHaveLength(1)
// Invalidating with the shape opposite to the one that booted must evict.
yield* locations.invalidate(present)
@@ -4,26 +4,18 @@ import { describe, expect, test } from "bun:test"
import { Effect, Layer, Schema } from "effect"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Location } from "@opencode-ai/core/location"
import { LocationPath } from "@opencode-ai/core/location-path"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { tmpdir } from "./fixture/tmpdir"
import { location } from "./fixture/location"
import { it } from "./lib/effect"
function provide(directory: string, projectDirectory = directory) {
function provide(directory: string) {
return Effect.provide(
LayerNode.compile(LocationPath.node, [
LayerNode.compile(LocationMutation.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
{ projectDirectory: AbsolutePath.make(projectDirectory) },
),
),
),
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
],
]),
)
@@ -36,13 +28,13 @@ function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
}
describe("LocationPath", () => {
describe("LocationMutation", () => {
it.live("resolves an active relative existing file target", () =>
withTmp((directory) =>
Effect.gen(function* () {
const targetPath = path.join(directory, "hello.txt")
yield* Effect.promise(() => fs.writeFile(targetPath, "hello"))
const target = yield* (yield* LocationPath.Service).resolve({ path: "hello.txt" })
const target = yield* (yield* LocationMutation.Service).resolve({ path: "hello.txt" })
expect(target).toMatchObject({
absolute: targetPath,
@@ -57,7 +49,7 @@ describe("LocationPath", () => {
withTmp((directory) =>
Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
const target = yield* (yield* LocationPath.Service).resolve({ path: path.join("src", "new.txt") })
const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("src", "new.txt") })
expect(target).toMatchObject({
absolute: path.join(directory, "src", "new.txt"),
resource: "src/new.txt",
@@ -66,43 +58,10 @@ describe("LocationPath", () => {
),
)
it.live("does not require external authorization inside the project but outside the active directory", () =>
withTmp((project) =>
Effect.gen(function* () {
const directory = path.join(project, "packages", "app")
const targetPath = path.join(project, "README.md")
yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
yield* Effect.promise(() => fs.writeFile(targetPath, "hello"))
const locationPath = yield* LocationPath.Service
const target = yield* locationPath.resolve({ path: targetPath })
expect(target).toMatchObject({
absolute: targetPath,
resource: "../../README.md",
})
expect(target.externalDirectory).toBeUndefined()
}).pipe(provide(path.join(project, "packages", "app"), project)),
),
)
it.live("does not treat a filesystem-root project fallback as internal", () =>
withTmp((directory) =>
withTmp((outside) =>
Effect.gen(function* () {
const locationPath = yield* LocationPath.Service
const target = yield* locationPath.resolve({ path: path.join(outside, "target.txt") })
expect(target.externalDirectory).toBeDefined()
}).pipe(provide(directory, path.parse(directory).root)),
),
),
)
it.live("requires external-directory authorization for a relative lexical escape", () =>
withTmp((directory) =>
Effect.gen(function* () {
const target = yield* (yield* LocationPath.Service).resolve({ path: "../outside.txt" })
const target = yield* (yield* LocationMutation.Service).resolve({ path: "../outside.txt" })
const root = path.dirname(directory)
expect(target).toMatchObject({
absolute: path.join(root, "outside.txt"),
@@ -125,7 +84,7 @@ describe("LocationPath", () => {
await fs.mkdir(outside)
await fs.symlink(outside, path.join(directory, "escape"))
})
const target = yield* (yield* LocationPath.Service).resolve({ path: path.join("escape", "new.txt") })
const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("escape", "new.txt") })
expect(target).toMatchObject({
absolute: path.join(directory, "escape", "new.txt"),
resource: "escape/new.txt",
@@ -145,7 +104,7 @@ describe("LocationPath", () => {
await fs.symlink(path.join(directory, "actual"), path.join(directory, "linked"))
})
expect(yield* (yield* LocationPath.Service).resolve({ path: "linked/new.txt" })).toMatchObject({
expect(yield* (yield* LocationMutation.Service).resolve({ path: "linked/new.txt" })).toMatchObject({
absolute: path.join(directory, "linked", "new.txt"),
resource: "linked/new.txt",
})
@@ -157,7 +116,7 @@ describe("LocationPath", () => {
withTmp((directory) =>
Effect.gen(function* () {
const targetPath = path.join(directory, "new.txt")
const target = yield* (yield* LocationPath.Service).resolve({ path: targetPath })
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
expect(target).toMatchObject({
absolute: targetPath,
resource: "new.txt",
@@ -172,7 +131,7 @@ describe("LocationPath", () => {
withTmp((outside) =>
Effect.gen(function* () {
const targetPath = path.join(outside, "new.txt")
const target = yield* (yield* LocationPath.Service).resolve({ path: targetPath })
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
const root = outside
expect(target).toMatchObject({
absolute: path.join(root, "new.txt"),
@@ -193,7 +152,7 @@ describe("LocationPath", () => {
Effect.gen(function* () {
const targetPath = path.join(outside, "existing.txt")
yield* Effect.promise(() => fs.writeFile(targetPath, "existing"))
const target = yield* (yield* LocationPath.Service).resolve({ path: targetPath })
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
expect(target).toMatchObject({ absolute: targetPath })
expect(target.externalDirectory?.directory).toBe(outside)
}).pipe(provide(directory)),
@@ -205,7 +164,7 @@ describe("LocationPath", () => {
withTmp((directory) =>
withTmp((outside) =>
Effect.gen(function* () {
const target = yield* (yield* LocationPath.Service).resolve({ path: outside, kind: "file" })
const target = yield* (yield* LocationMutation.Service).resolve({ path: outside, kind: "file" })
expect(target.externalDirectory).toMatchObject({
directory: path.dirname(outside),
resource: path.join(path.dirname(outside), "*").replaceAll("\\", "/"),
@@ -220,7 +179,7 @@ describe("LocationPath", () => {
withTmp((outside) =>
Effect.gen(function* () {
const targetPath = path.join(outside, "new", "nested", "file.txt")
const target = yield* (yield* LocationPath.Service).resolve({ path: targetPath })
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
const parent = path.dirname(targetPath)
expect(target.externalDirectory).toMatchObject({
directory: parent,
@@ -231,9 +190,9 @@ describe("LocationPath", () => {
),
)
test("ignores unknown path input fields", () => {
expect(Object.keys(LocationPath.ResolveInput.fields)).toEqual(["path", "kind"])
expect(Schema.decodeUnknownSync(LocationPath.ResolveInput)({ path: "README.md", reference: "docs" })).toEqual({
test("ignores unknown mutation input fields", () => {
expect(Object.keys(LocationMutation.ResolveInput.fields)).toEqual(["path", "kind"])
expect(Schema.decodeUnknownSync(LocationMutation.ResolveInput)({ path: "README.md", reference: "docs" })).toEqual({
path: "README.md",
})
})
@@ -13,7 +13,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Image } from "@opencode-ai/core/image"
import { Location } from "@opencode-ai/core/location"
import { LocationPath } from "@opencode-ai/core/location-path"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { Model } from "@opencode-ai/core/model"
import { Permission } from "@opencode-ai/core/permission"
import { Project } from "@opencode-ai/core/project"
@@ -43,7 +43,7 @@ const readToolNode = makeLocationNode({
deps: [
Tool.node,
ReadToolFileSystem.node,
LocationPath.node,
LocationMutation.node,
Image.node,
Permission.node,
SessionInstructions.node,
@@ -65,7 +65,7 @@ const testLayer = AppNodeBuilder.build(
Session.node,
Location.node,
FSUtil.node,
LocationPath.node,
LocationMutation.node,
ReadToolFileSystem.node,
readToolNode,
Tool.node,
+3 -3
View File
@@ -8,7 +8,7 @@ import { Environment } from "@opencode-ai/core/environment/index"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Formatter } from "@opencode-ai/core/formatter"
import { Location } from "@opencode-ai/core/location"
import { LocationPath } from "@opencode-ai/core/location-path"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { Permission } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
@@ -27,7 +27,7 @@ const editToolNode = makeLocationNode({
layer: Layer.effectDiscard(registerToolPlugin(EditTool.Plugin)),
deps: [
Tool.node,
LocationPath.node,
LocationMutation.node,
FileMutation.node,
Environment.node,
Formatter.node,
@@ -84,7 +84,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
}).pipe(
Effect.provide(
AppNodeBuilder.build(
LayerNode.group([Tool.node, Tool.node, LocationPath.node, FileMutation.node, editToolNode]),
LayerNode.group([Tool.node, Tool.node, LocationMutation.node, FileMutation.node, editToolNode]),
[
[
Environment.node,
+7 -6
View File
@@ -8,7 +8,7 @@ import { Environment } from "@opencode-ai/core/environment/index"
import { Formatter } from "@opencode-ai/core/formatter"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Location } from "@opencode-ai/core/location"
import { LocationPath } from "@opencode-ai/core/location-path"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { Permission } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
@@ -27,7 +27,7 @@ const patchToolNode = makeLocationNode({
layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
deps: [
Tool.node,
LocationPath.node,
LocationMutation.node,
FileMutation.node,
Environment.node,
Formatter.node,
@@ -99,7 +99,7 @@ const withTool = <A, E, R>(
return yield* body(yield* Tool.Service)
}).pipe(
Effect.provide(
AppNodeBuilder.build(LayerNode.group([Tool.node, LocationPath.node, FileMutation.node, patchToolNode]), [
AppNodeBuilder.build(LayerNode.group([Tool.node, LocationMutation.node, FileMutation.node, patchToolNode]), [
[
Environment.node,
transformEnvironmentFiles(activeLocation, (files) => ({
@@ -920,7 +920,7 @@ describe("PatchTool", () => {
),
)
it.live("treats a sibling path inside the project worktree as internal", () =>
it.live("treats a sibling path inside the project worktree as external to the Location", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
@@ -939,8 +939,9 @@ describe("PatchTool", () => {
call("*** Begin Patch\n*** Update File: ../sibling.txt\n@@\n-before\n+after\n*** End Patch"),
),
).toMatchObject({ status: "completed" })
expect(assertions.map((input) => input.action)).toEqual(["edit"])
expect(assertions[0]?.resources).toEqual(["../sibling.txt"])
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
expect(assertions[0]?.resources).toEqual([path.join(tmp.path, "*").replaceAll("\\", "/")])
expect(assertions[1]?.resources).toEqual([target.replaceAll("\\", "/")])
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
}),
tmp.path,
+5 -5
View File
@@ -14,7 +14,7 @@ import { Permission } from "@opencode-ai/core/permission"
import { Session } from "@opencode-ai/core/session"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { Global } from "@opencode-ai/util/global"
import { LocationPath } from "@opencode-ai/core/location-path"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { location } from "./fixture/location"
import { Tool } from "@opencode-ai/core/tool"
import { ReadTool } from "@opencode-ai/core/tool/plugin/read"
@@ -32,7 +32,7 @@ const readToolNode = makeLocationNode({
deps: [
Tool.node,
ReadToolFileSystem.node,
LocationPath.node,
LocationMutation.node,
Image.node,
Permission.node,
SessionInstructions.node,
@@ -107,8 +107,8 @@ const locationLayer = Layer.succeed(
Location.Service.of(location({ directory: AbsolutePath.make(process.cwd()) })),
)
const mutation = Layer.succeed(
LocationPath.Service,
LocationPath.Service.of({
LocationMutation.Service,
LocationMutation.Service.of({
resolve: (input) => {
const absolute = path.resolve(process.cwd(), input.path)
const external = path.isAbsolute(input.path) && !FSUtil.contains(process.cwd(), absolute)
@@ -141,7 +141,7 @@ const readLayer = (imageLayer: Layer.Layer<Image.Service>) =>
[Permission.node, permission],
[Config.node, config],
[Image.node, imageLayer],
[LocationPath.node, mutation],
[LocationMutation.node, mutation],
[FSUtil.node, testFileSystem],
[Location.node, locationLayer],
[Global.node, Global.layerWith({ data: Global.Path.data })],
+3 -3
View File
@@ -8,7 +8,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Environment } from "@opencode-ai/core/environment/index"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Location } from "@opencode-ai/core/location"
import { LocationPath } from "@opencode-ai/core/location-path"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { Permission } from "@opencode-ai/core/permission"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -25,12 +25,12 @@ import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
const globToolNode = makeLocationNode({
name: "test/glob-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(GlobTool.Plugin)),
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationPath.node, Permission.node],
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node],
})
const grepToolNode = makeLocationNode({
name: "test/grep-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(GrepTool.Plugin)),
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationPath.node, Permission.node],
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node],
})
const sessionID = Session.ID.make("ses_search_tool_test")
+2 -2
View File
@@ -16,7 +16,7 @@ import { Environment } from "@opencode-ai/core/environment/index"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Location } from "@opencode-ai/core/location"
import { LocationPath } from "@opencode-ai/core/location-path"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
@@ -132,7 +132,7 @@ const shellPluginSupervisor = makeLocationNode({
deps: [
Config.node,
Environment.node,
LocationPath.node,
LocationMutation.node,
Permission.node,
PluginRuntime.node,
Shell.node,
+3 -3
View File
@@ -8,7 +8,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Environment } from "@opencode-ai/core/environment/index"
import { Location } from "@opencode-ai/core/location"
import { LocationPath } from "@opencode-ai/core/location-path"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { Permission } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
@@ -25,7 +25,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
const writeToolNode = makeLocationNode({
name: "test/write-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(WriteTool.Plugin)),
deps: [Tool.node, LocationPath.node, FileMutation.node, Environment.node, Formatter.node, Permission.node],
deps: [Tool.node, LocationMutation.node, FileMutation.node, Environment.node, Formatter.node, Permission.node],
})
const sessionID = Session.ID.make("ses_write_tool_test")
@@ -72,7 +72,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
}).pipe(
Effect.provide(
AppNodeBuilder.build(
LayerNode.group([Tool.node, Tool.node, LocationPath.node, FileMutation.node, writeToolNode]),
LayerNode.group([Tool.node, Tool.node, LocationMutation.node, FileMutation.node, writeToolNode]),
[
[
Environment.node,
+4 -3
View File
@@ -24,7 +24,8 @@ import {
isOldLayoutEligible,
} from "./onboarding"
import { getDefaultServerUrl, preferAppEnv, setDefaultServerUrl } from "./server"
import { registerUpdaterIpc, setupAutoUpdater, showUpdaterDialog } from "./updater"
import { setupAutoUpdater, showUpdaterDialog } from "./updater"
import { registerUpdaterIpc } from "./updater-ipc"
import { safeWebContentsURL } from "./window-state"
import {
getLastFocusedWindow,
@@ -241,7 +242,7 @@ const main = Effect.gen(function* () {
const win = getLastFocusedWindow()
if (win) sendMenuCommand(win, id)
},
checkForUpdates: () => void showUpdaterDialog(updater),
checkForUpdates: () => void showUpdaterDialog(updater, true),
relaunch,
}
registerIpcHandlers({
@@ -266,7 +267,7 @@ const main = Effect.gen(function* () {
setDisplayBackend: async () => undefined,
checkAppExists: (appName) => checkAppExists(appName),
resolveAppPath: async (appName) => resolveAppPath(appName),
showUpdater: () => showUpdaterDialog(updater),
showUpdater: () => showUpdaterDialog(updater, true),
setBackgroundColor: (color) => setBackgroundColor(color),
exportDebugLogs: () => exportDebugLogs(),
recordFatalRendererError: (error) => writeLog("renderer", "fatal renderer error", { ...error }, "error"),
@@ -1,35 +1,26 @@
import { describe, expect, test } from "bun:test"
import { createUpdaterController, type UpdaterReadyRecord } from "./updater-controller"
import { createUpdaterController, type UpdaterPlatform, type UpdaterReadyRecord } from "./updater-controller"
// 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>
}) {
function setup(input?: { currentVersion?: string; ready?: UpdaterReadyRecord }) {
const calls: string[] = []
const states: string[] = []
const platform: UpdaterPlatform = {
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
const controller = createUpdaterController({
enabled: true,
currentVersion: input?.currentVersion ?? "1.0.0",
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>(() => {})
},
},
platform,
lifecycle: {
async prepareToRestart() {
calls.push("prepare")
@@ -45,29 +36,21 @@ function setup(input?: {
},
},
})
controller.subscribe((state) => states.push(state.status))
return { controller, calls, states, getReady: () => ready }
return { controller, calls, getReady: () => ready }
}
describe("updater controller", () => {
test("stages an update found at launch and shows it as ready", async () => {
test("checks, downloads, persists, and publishes one authoritative ready state", async () => {
const app = setup()
const states: ReturnType<typeof app.controller.getState>[] = []
app.controller.subscribe((state) => states.push(state))
await app.controller.start()
expect(app.states).toEqual(["idle", "checking", "downloading", "ready"])
expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
expect(app.calls).toEqual(["check", "download"])
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()
expect(states.map((state) => state.status)).toEqual(["idle", "checking", "downloading", "ready"])
expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
})
test("revalidates a persisted target through the updater cache on launch", async () => {
@@ -79,7 +62,16 @@ describe("updater controller", () => {
expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
})
test("concurrent checks share one platform check", async () => {
test("clears a target already installed before checking", 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()
await Promise.all([app.controller.check(), app.controller.check(), app.controller.check()])
@@ -87,142 +79,81 @@ describe("updater controller", () => {
expect(app.calls).toEqual(["check", "download"])
})
test("clicking install twice checks once and installs the staged version once", async () => {
test("starts installing synchronously and coalesces restart requests", async () => {
const app = setup()
await app.controller.start()
void app.controller.install()
void app.controller.install()
const first = app.controller.install()
const second = app.controller.install()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(app.calls).toEqual(["check", "download", "check", "prepare", "install:2.0.0"])
expect(first).toBe(second)
await Promise.resolve()
expect(app.calls).toEqual(["check", "download", "prepare", "install"])
expect(app.controller.getState()).toEqual({ status: "installing", version: "2.0.0" })
})
test("ignores checks while an installation is in progress", async () => {
test("does not check for updates while installation is in progress", async () => {
const app = setup()
await app.controller.start()
void app.controller.install()
await app.controller.check()
await new Promise((resolve) => setTimeout(resolve, 0))
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.calls).toEqual(["check", "download", "prepare", "install"])
expect(app.controller.getState()).toEqual({ status: "installing", version: "2.0.0" })
})
test("later checks stay silent while ready and pick up newer versions", async () => {
let latest = "2.0.0"
const app = setup({ latest: () => latest })
test("returns to ready when installation fails", async () => {
const app = setup()
await app.controller.start()
await app.controller.check()
// 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("keeps the staged update installable when a silent re-check fails", async () => {
let offline = false
const app = setup({
latest: () => {
if (offline) throw new Error("offline")
return "2.0.0"
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 app.controller.start()
await failed.start()
offline = true
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" })
await expect(failed.install()).rejects.toThrow("install failed")
expect(failed.getState()).toEqual({ status: "ready", 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 app.controller.start()
latest = "3.0.0"
slowStage = true
const refresh = app.controller.check()
await new Promise((resolve) => setTimeout(resolve, 0))
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 () => {
test("allows a state subscriber to retry after installation fails", async () => {
let attempts = 0
const app = setup({
install() {
attempts++
if (attempts === 1) return Promise.reject(new Error("install failed"))
return new Promise<never>(() => {})
let sawInstalling = false
let retry: Promise<void> | undefined
const failed = createUpdaterController({
enabled: true,
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() {} },
})
await app.controller.start()
failed.subscribe((state) => {
if (state.status === "installing") sawInstalling = true
if (!sawInstalling || state.status !== "ready" || retry) return
retry = failed.install()
})
await failed.start()
await expect(app.controller.install()).rejects.toThrow("install failed")
expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
await expect(failed.install()).rejects.toThrow("install failed")
void app.controller.install()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(retry).toBeDefined()
expect(attempts).toBe(2)
expect(app.controller.getState()).toEqual({ status: "installing", version: "2.0.0" })
expect(failed.getState()).toEqual({ status: "installing", version: "2.0.0" })
})
})
+32 -47
View File
@@ -21,13 +21,14 @@ type UpdaterPersistence = {
}
export function createUpdaterController(input: {
enabled: boolean
currentVersion: string
platform?: UpdaterPlatform
lifecycle: UpdaterLifecycle
persistence: UpdaterPersistence
log?: (message: string, data?: object) => void
}) {
let state: UpdaterState = input.platform ? { status: "idle" } : { status: "disabled" }
let state: UpdaterState = input.enabled ? { status: "idle" } : { status: "disabled" }
let pending: Promise<UpdaterState> | undefined
let installing: Promise<void> | undefined
const listeners = new Set<(state: UpdaterState) => void>()
@@ -40,21 +41,13 @@ export function createUpdaterController(input: {
}
const check = () => {
if (!input.enabled) return Promise.resolve(state)
const platform = input.platform
if (!platform) return Promise.resolve(state)
if (state.status === "installing") return Promise.resolve(state)
if (state.status === "ready" || state.status === "installing") return Promise.resolve(state)
if (pending) return pending
pending = (state.status === "ready" ? refreshStaged(platform, state.version) : findAndStage(platform)).finally(
() => {
pending = undefined
},
)
return pending
}
const findAndStage = (platform: UpdaterPlatform) =>
(async () => {
pending = (async () => {
transition({ status: "checking" })
const version = await platform.checkForUpdate()
if (!version || version === input.currentVersion) {
@@ -66,49 +59,41 @@ export function createUpdaterController(input: {
await platform.stageUpdate()
await input.persistence.set({ version })
return transition({ status: "ready", version })
})().catch((error) =>
transition({ status: "error", message: error instanceof Error ? error.message : String(error) }),
)
// A staged update stays visible and installable throughout: the refresh makes no
// 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),
})()
.catch((error) =>
transition({ status: "error", message: error instanceof Error ? error.message : String(error) }),
)
.finally(() => {
pending = undefined
})
return state
})
return pending
}
const install = () => {
if (installing) return installing
const platform = input.platform
if (!platform || state.status !== "ready") return Promise.reject(new Error("Update is not ready to install"))
if (state.status !== "ready") return Promise.reject(new Error("Update is not ready to install"))
const staged = state.version
transition({ status: "installing", version: staged })
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.
await (pending ?? refreshStaged(platform, staged))
await input.lifecycle.prepareToRestart()
await platform.installAndRestart()
})().catch((error) => {
const version = startInstalling(state.version)
installing = restartWithUpdate(version)
return installing
}
const startInstalling = (version: string) => {
transition({ status: "installing", version })
return version
}
const restartWithUpdate = (version: string) =>
prepareAndRestart().catch((error) => {
installing = undefined
if (state.status === "installing") transition({ status: "ready", version: state.version })
transition({ status: "ready", version })
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 {
+23
View File
@@ -0,0 +1,23 @@
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())
}
@@ -0,0 +1,16 @@
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"])
})
})
@@ -0,0 +1,20 @@
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()
},
}
}
+6 -28
View File
@@ -1,6 +1,6 @@
import { app, dialog, ipcMain } from "electron"
import { app, dialog } from "electron"
import { UPDATER_ENABLED } from "./constants"
import { createUpdaterController, type UpdaterController, type UpdaterReadyRecord } from "./updater-controller"
import { createUpdaterController, type UpdaterReadyRecord } from "./updater-controller"
import { getLogger } from "./logging"
import { getStore } from "./store"
import { nativeT } from "./native-translations"
@@ -12,6 +12,7 @@ export function setupAutoUpdater(prepareToRestart: () => Promise<void>) {
const logger = getLogger()
const store = getStore("opencode.updater")
return createUpdaterController({
enabled: UPDATER_ENABLED,
currentVersion: app.getVersion(),
platform: UPDATER_ENABLED ? createUpdaterPlatform(logger) : undefined,
lifecycle: { prepareToRestart },
@@ -28,34 +29,10 @@ export function setupAutoUpdater(prepareToRestart: () => Promise<void>) {
})
}
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) {
export async function showUpdaterDialog(controller: ReturnType<typeof setupAutoUpdater>, alertOnFail: boolean) {
const state = await controller.check()
if (state.status === "error") {
if (!alertOnFail) return
await dialog.showMessageBox({
type: "error",
message: nativeT("desktop.updater.dialog.checkFailed.message"),
@@ -64,6 +41,7 @@ export async function showUpdaterDialog(controller: UpdaterController) {
return
}
if (state.status === "up-to-date") {
if (!alertOnFail) return
await dialog.showMessageBox({
type: "info",
message: nativeT("desktop.updater.dialog.upToDate.message"),
@@ -3,43 +3,7 @@ import { patchFiles } from "./apply-patch-file"
import { text } from "./session-diff"
describe("apply patch file", () => {
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", () => {
test("parses patch metadata from the server", () => {
const file = patchFiles([
{
filePath: "/tmp/a.ts",
@@ -55,8 +19,8 @@ describe("apply patch file", () => {
expect(file).toBeDefined()
expect(file?.view.fileDiff.name).toBe("a.ts")
expect(file?.view.fileDiff.isPartial).toBe(false)
expect(text(file.view, "deletions")).toBe("one\ntwo\n")
expect(text(file.view, "additions")).toBe("one\nthree\n")
expect(text(file!.view, "deletions")).toBe("one\ntwo\n")
expect(text(file!.view, "additions")).toBe("one\nthree\n")
})
test("keeps legacy before and after payloads working", () => {
@@ -73,7 +37,7 @@ describe("apply patch file", () => {
])[0]
expect(file).toBeDefined()
expect(text(file.view, "deletions")).toBe("one\n")
expect(text(file.view, "additions")).toBe("two\n")
expect(text(file!.view, "deletions")).toBe("one\n")
expect(text(file!.view, "additions")).toBe("two\n")
})
})
@@ -3,11 +3,9 @@ import { normalize, type ViewDiff } from "./session-diff"
type Kind = "add" | "update" | "delete" | "move"
type Raw = {
file?: string
filePath?: string
relativePath?: string
type?: Kind
status?: "added" | "deleted" | "modified"
patch?: string
diff?: string
before?: string
@@ -29,10 +27,6 @@ export type ApplyPatchFile = {
function kind(value: unknown) {
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" {
@@ -42,19 +36,18 @@ function status(type: Kind): "added" | "deleted" | "modified" {
}
export function patchFile(raw: unknown): ApplyPatchFile | undefined {
if (!raw || typeof raw !== "object") return undefined
if (!raw || typeof raw !== "object") return
const value = raw as Raw
const type = kind(value.type) ?? kind(value.status)
const filePath =
typeof value.filePath === "string" ? value.filePath : typeof value.file === "string" ? value.file : undefined
const type = kind(value.type)
const filePath = typeof value.filePath === "string" ? value.filePath : undefined
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 before = typeof value.before === "string" ? value.before : undefined
const after = typeof value.after === "string" ? value.after : undefined
if (!type || !filePath || !relativePath) return undefined
if (!patch && before === undefined && after === undefined) return undefined
if (!type || !filePath || !relativePath) return
if (!patch && before === undefined && after === undefined) return
const additions = typeof value.additions === "number" ? value.additions : 0
const deletions = typeof value.deletions === "number" ? value.deletions : 0
@@ -474,17 +474,6 @@ function webSearchProviderLabel(provider: unknown, i18n: ReturnType<typeof useI1
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(
tool: string,
input: any = {},
@@ -492,14 +481,12 @@ export function getToolInfo(
): ToolInfo {
const i18n = useI18n()
switch (tool) {
case "read": {
const path = readToolPath(input)
case "read":
return {
icon: "glasses",
title: i18n.t("ui.tool.read"),
subtitle: path ? getFilename(path) : undefined,
subtitle: input.filePath ? getFilename(input.filePath) : undefined,
}
}
case "list":
return {
icon: "bullet-list",
@@ -581,7 +568,7 @@ export function getToolInfo(
case "skill":
return {
icon: "brain",
title: skillToolName(input, metadata) || i18n.t("ui.tool.skill"),
title: input.name || i18n.t("ui.tool.skill"),
}
default:
return {
@@ -860,7 +847,7 @@ function contextToolDetail(part: ToolPart): string | undefined {
function contextToolTrigger(part: ToolPart, i18n: ReturnType<typeof useI18n>) {
const input = (part.state.input ?? {}) as Record<string, unknown>
const path = typeof input.path === "string" ? input.path : "/"
const filePath = readToolPath(input)
const filePath = typeof input.filePath === "string" ? input.filePath : undefined
const pattern = typeof input.pattern === "string" ? input.pattern : undefined
const include = typeof input.include === "string" ? input.include : undefined
const offset = typeof input.offset === "number" ? input.offset : undefined
@@ -1806,7 +1793,7 @@ ToolRegistry.register({
icon="glasses"
trigger={{
title: i18n.t("ui.tool.read"),
subtitle: getFilename(readToolPath(props.input) ?? ""),
subtitle: props.input.filePath ? getFilename(props.input.filePath) : "",
args,
}}
/>
@@ -2637,7 +2624,7 @@ ToolRegistry.register({
name: "skill",
render(props) {
const i18n = useI18n()
const title = createMemo(() => skillToolName(props.input, props.metadata) || i18n.t("ui.tool.skill"))
const title = createMemo(() => props.input.name || i18n.t("ui.tool.skill"))
const running = createMemo(() => props.status === "pending" || props.status === "running")
const titleContent = () => <TextShimmer text={title()} active={running()} />
@@ -26,21 +26,6 @@ describe("partDefaultOpen", () => {
).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", () => {
expect(
partDefaultOpen(
@@ -56,21 +41,6 @@ describe("partDefaultOpen", () => {
).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", () => {
expect(partDefaultOpen(tool("shell", {}), true, false)).toBe(true)
})
@@ -7,12 +7,7 @@ function deletionOnly(part: ToolPart) {
const files = metadata.files
if (Array.isArray(files) && files.length > 0) {
return files.every(
(file) =>
!!file &&
typeof file === "object" &&
(("type" in file && file.type === "delete") || ("status" in file && file.status === "deleted")),
)
return files.every((file) => !!file && typeof file === "object" && "type" in file && file.type === "delete")
}
const filediff = metadata.filediff
@@ -21,12 +16,11 @@ function deletionOnly(part: ToolPart) {
return filediff.additions === 0 && typeof filediff.deletions === "number" && filediff.deletions > 0
}
export function partDefaultOpen(part: PartType, shell = false, edit = false): boolean | undefined {
if (part.type !== "tool") return undefined
export function partDefaultOpen(part: PartType, shell = false, edit = false) {
if (part.type !== "tool") return
if (part.tool === "bash" || part.tool === "shell") return shell
if (part.tool === "edit" || part.tool === "write" || part.tool === "patch" || part.tool === "apply_patch") {
if (!edit) return false
return !deletionOnly(part)
}
return undefined
}
+194 -89
View File
@@ -57,7 +57,12 @@ import {
type LocalAttachment,
} from "./local-attachment"
import { useData } from "../../context/data"
import { usePromptRef } from "../../context/prompt"
import { useLocation } from "../../context/location"
import type { LocationRef, PromptFileAttachment, PromptSkillAttachment, SkillInfo } from "@opencode-ai/client"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { SessionID } from "@opencode-ai/schema/session-id"
import { useSessionTabs } from "../../context/session-tabs"
import { Keymap, type KeymapCommand } from "../../context/keymap"
import { abbreviateHome } from "../../runtime"
import { Slot } from "../../plugin/render"
@@ -100,6 +105,59 @@ export type PromptRef = {
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) {
if (count <= 0) return 0
return Math.floor(Math.random() * count)
@@ -201,6 +259,8 @@ export function Prompt(props: PromptProps) {
const editor = useEditorContext()
const route = useRoute()
const data = useData()
const activePrompt = usePromptRef()
const sessionTabs = useSessionTabs()
const directoryRecents = useDirectoryRecents()
const keymapCommands = Keymap.useCommands()
const currentLocation = useLocation()
@@ -1070,6 +1130,20 @@ 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
async function submit(delivery: SessionInbox.Delivery = "steer") {
// Prevent overlapping invocations (e.g. a double-pressed Enter, or the
@@ -1170,8 +1244,9 @@ export function Prompt(props: PromptProps) {
const variant = selection.variant
let sessionID = props.sessionID
let session = sessionID ? data.session.get(sessionID) : undefined
let finishMoveProgress = false
let createSession: (() => Promise<unknown>) | undefined
let createLocation: LocationRef | undefined
if (sessionID == null) {
const directory = await move.getDirectory()
if (move.pending() && !directory) return false
@@ -1179,32 +1254,46 @@ export function Prompt(props: PromptProps) {
// The location context is where the next session is created: seeded by the home
// route (launch cwd, inherited session location, or picked project) and updated
// by /cd before a session exists.
const location = currentLocation.ref ?? data.location.default()
const created = await client.api.session
.create({
location: directory ? { directory } : location,
const location = directory ? { directory } : (currentLocation.ref ?? data.location.default())
const model = { providerID: selection.providerID, id: selection.modelID, variant }
// Seed the session locally with a client-generated ID and create it in the
// background so navigation and the prompt echo render immediately. Optimistic
// creation covers the plain prompt path when the location's project is known.
// Worktree-backed creation keeps its progress flow, and command, skill, and
// shell submissions target the session right away, so those await creation.
const plainPrompt = store.mode !== "shell" && !(slashHead && isCommand) && !isSkill
const projectID = directory ? undefined : data.location.info(location)?.project.id
if (plainPrompt && projectID) {
const created = SessionID.create()
data.session.optimistic.create({
sessionID: created,
projectID,
location,
agent: agent.id,
model: {
providerID: selection.providerID,
id: selection.modelID,
variant,
},
model,
})
.catch(() => undefined)
if (!created) {
if (finishMoveProgress) move.finishSubmit()
toast.show({
message: "Creating a session failed. Open console for more details.",
variant: "error",
})
return true
sessionID = created
createLocation = location
createSession = () => client.api.session.create({ id: created, location, agent: agent.id, model })
}
sessionID = created.id
session = created
if (sessionID == null) {
const created = await client.api.session
.create({ location, agent: agent.id, model })
.catch(() => undefined)
if (!created) {
if (finishMoveProgress) move.finishSubmit()
toast.show({
message: "Creating a session failed. Open console for more details.",
variant: "error",
})
return true
}
sessionID = created.id
}
}
// Capture mode before it gets reset
@@ -1245,70 +1334,87 @@ export function Prompt(props: PromptProps) {
})
} else {
move.startSubmit()
if (!session) {
await data.session.sync(sessionID)
session = data.session.get(sessionID)
}
if (session?.agent !== agent.id) {
await client.api.session.switchAgent({ sessionID, agent: agent.id })
}
if (
session?.model?.providerID !== selection.providerID ||
session.model.id !== selection.modelID ||
(session.model.variant ?? "default") !== (variant ?? "default")
) {
const model = { providerID: selection.providerID, id: selection.modelID, variant }
const cancelCommit = local.model.trackSessionCommit(sessionID, model)
await client.api.session.switchModel({ sessionID, model }).catch((error) => {
cancelCommit()
throw error
})
}
if (session?.revert) {
const error = await client.api.session.revert.commit({ sessionID }).then(
() => undefined,
(error) => error,
)
if (error) {
toast.show({ title: "Failed to commit revert", message: errorMessage(error), variant: "error" })
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,
// Echo the prompt locally and admit it in the background: the editor clears and
// the message renders immediately, while a per-session queue preserves admission
// order. Failure rolls the echo back and restores the captured prompt.
const submitSessionID = sessionID
const messageID = SessionMessage.ID.create()
const snapshot = structuredClone(unwrap(store.prompt))
const editorContextText = pendingEditorSelection ? formatEditorContext(pendingEditorSelection) : undefined
const targetAgent = agent.id
const model = { providerID: selection.providerID, id: selection.modelID, variant }
data.session.optimistic.prompt({
sessionID: submitSessionID,
messageID,
delivery,
text: inputText,
files: optimisticFiles(snapshot.files),
agents: snapshot.agents?.length ? snapshot.agents : undefined,
skills: optimisticSkills(snapshot.skills, data.location.skill.list(currentLocation.ref) ?? []),
})
// 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 (editorContextText) editor.markSelectionSent()
let createFailed = false
void enqueueSubmit(submitSessionID, async () => {
const error = await (async () => {
if (createSession)
await createSession().catch((error) => {
createFailed = true
throw error
})
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,
(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(
})().then(
() => undefined,
(error) => error,
)
if (error) {
if (error === undefined) return
data.session.optimistic.rollback(submitSessionID, messageID)
if (createFailed) {
// The session never existed server-side: leave the optimistic tab, return
// to the home screen with the prompt restored, and drop the seed.
if (route.data.type === "session" && route.data.sessionID === submitSessionID)
route.navigate({ type: "home", prompt: snapshot, location: createLocation })
else saveDraft(undefined, { prompt: snapshot, cursor: snapshot.text.length })
sessionTabs.close(submitSessionID)
data.session.optimistic.rollbackCreate(submitSessionID)
toast.show({ title: "Failed to create session", message: errorMessage(error), variant: "error" })
return
}
restorePrompt(submitSessionID, snapshot)
toast.show({ title: "Failed to send prompt", message: errorMessage(error), variant: "error" })
return false
}
if (pendingEditorSelection) editor.markSelectionSent()
})
}
history.append({
...store.prompt,
@@ -1319,15 +1425,14 @@ export function Prompt(props: PromptProps) {
setStore("extmarkToPart", new Map())
props.onSubmit?.()
// temporary hack to make sure the message is sent
if (!props.sessionID) {
if (pendingEditorSelection) editor.preserveSelectionFromNewSession()
setTimeout(() => {
route.navigate({
type: "session",
sessionID,
})
}, 50)
// The optimistic echo is already in the data store, so the session route
// renders the prompt immediately; admission continues in the background.
route.navigate({
type: "session",
sessionID,
})
}
input.clear()
if (finishMoveProgress) move.finishSubmit()
+176 -21
View File
@@ -20,6 +20,7 @@ import type {
ProviderInfo,
ReferenceInfo,
SessionMessageInfo,
SessionMessageUser,
SessionMessageAssistant,
SessionMessageAssistantReasoning,
SessionMessageAssistantText,
@@ -40,7 +41,7 @@ import { useClient } from "./client"
import { nonEmptyToolContent } from "../util/tool-display"
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
import { Worktree } from "@opencode-ai/schema/worktree"
import { createEffect, createSignal, onCleanup } from "solid-js"
import { batch, createEffect, createSignal, onCleanup } from "solid-js"
export type DataSessionStatus = "idle" | "running"
@@ -161,6 +162,25 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
const messageIndex = new Map<string, Map<string, number>>()
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) {
setStore("session", "active", sessionID, status)
}
@@ -188,6 +208,17 @@ 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) {
const requests = store.session.permission[sessionID]
if (!requests?.some((request) => request.id === requestID)) return
@@ -313,7 +344,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
function removeSession(sessionID: string) {
messageIndex.delete(sessionID)
optimisticPrompts.delete(sessionID)
sync.invalidate(`session:${sessionID}`)
sync.invalidate(`session.family:${sessionID}`)
sync.invalidate(`session.pending:${sessionID}`)
sync.invalidate(`session.message:${sessionID}`)
sync.invalidate(`session.permission:${sessionID}`)
@@ -471,6 +504,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
break
}
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
removePending(event.data.sessionID, event.data.inboxID)
message.update(event.data.sessionID, (draft, index) => {
@@ -489,25 +525,31 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
updatePending(event.data.sessionID, event.data.inboxID, event.data.delivery)
break
case "session.inbox.cancelled": {
confirmOptimistic(event.data.sessionID, event.data.inboxID)
removePending(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)
})
removeMessage(event.data.sessionID, event.data.inboxID)
break
}
case "session.inbox.enqueued": {
const item = event.data.item
addPending({
// The admission echo is authoritative for an optimistic local copy: it
// 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,
sessionID: event.data.sessionID,
timeCreated: event.created,
...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))
setStore("session", "input", event.data.sessionID, [
...(store.session.input[event.data.sessionID] ?? []),
@@ -515,9 +557,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
])
if (item.type !== "user" && item.type !== "synthetic") break
message.update(event.data.sessionID, (draft, index) => {
message.append(
draft,
index,
const next: SessionMessageInfo =
item.type === "user"
? {
id: event.data.inboxID,
@@ -530,8 +570,13 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
type: "synthetic",
...item.payload,
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
}
@@ -825,22 +870,30 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "revert", undefined)
break
case "session.revert.committed":
case "session.revert.committed": {
if (store.session.info[event.data.sessionID]) {
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(
"session",
"input",
event.data.sessionID,
(store.session.input[event.data.sessionID] ?? []).filter((id) => id < event.data.to),
(store.session.input[event.data.sessionID] ?? []).filter(
(id) => id < event.data.to || local?.has(id) === true,
),
)
message.update(event.data.sessionID, (draft, index) => {
const position = draft.findIndex((item) => item.id >= event.data.to)
if (position === -1) return
for (const item of draft.splice(position)) index.delete(item.id)
const dropped = draft.splice(position)
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
}
case "session.compaction.delta":
message.update(event.data.sessionID, (draft) => {
const current = message.compaction(draft)
@@ -1013,13 +1066,109 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return store.session.input[sessionID]?.includes(inboxID) ?? false
},
},
optimistic: {
// Seed a session locally before server creation so navigation and the first
// prompt echo render immediately. Reads are marked complete so the session
// route does not fetch a session the server does not know yet; the
// session.created echo invalidates the info read and loads server truth.
create(input: {
sessionID: string
projectID: string
location: LocationRef
agent?: string
model?: SessionInfo["model"]
}) {
const now = Date.now()
const info: SessionInfo = {
id: input.sessionID,
projectID: input.projectID,
location: input.location,
agent: input.agent,
model: input.model,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: now, updated: now },
}
batch(() => {
setStore("session", "info", input.sessionID, info)
registerSession(input.sessionID)
})
sync.complete(`session:${input.sessionID}`)
sync.complete(`session.family:${input.sessionID}`)
sync.complete(`session.pending:${input.sessionID}`)
sync.complete(`session.message:${input.sessionID}`)
},
// Remove a seeded session after creation fails. Callers navigate away first.
rollbackCreate(sessionID: string) {
removeSession(sessionID)
},
// 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: {
list(sessionID: string) {
return store.session.pending[sessionID] ?? []
},
sync(sessionID: string) {
return sync.run(`session.pending:${sessionID}`, async () => {
const pending = await client.api.session.inbox.list({ sessionID })
const fetched = 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",
@@ -1069,9 +1218,15 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
},
sync(sessionID: string) {
return sync.run(`session.message:${sessionID}`, async () => {
const messages = (
const fetched = (
await client.api.message.list({ sessionID, limit: 200, order: "desc" })
).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])))
setStore("session", "message", sessionID, reconcile(messages))
})
@@ -0,0 +1,378 @@
/** @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("optimistic session creation seeds info and suppresses initial reads", async () => {
const events = createEventStream()
const sessionID = "ses_optimistic"
const fetched: string[] = []
const calls = createFetch((url) => {
if (!url.pathname.startsWith(`/api/session/${sessionID}`)) return
fetched.push(url.pathname)
if (url.pathname === `/api/session/${sessionID}`)
return json({
data: {
id: sessionID,
projectID: "proj_test",
location: { directory },
agent: "build",
title: "Server title",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
},
})
}, events)
const { app, data } = await renderData(calls.fetch)
try {
data.session.optimistic.create({
sessionID,
projectID: "proj_test",
location: { directory },
agent: "build",
model: { providerID: "provider", id: "model" },
})
const seeded = data.session.get(sessionID)
expect(seeded?.projectID).toBe("proj_test")
expect(seeded?.agent).toBe("build")
// Seeded reads are marked complete: the server does not know the session yet.
await data.session.sync(sessionID, { children: true })
await data.session.message.sync(sessionID)
await data.session.pending.sync(sessionID)
expect(fetched).toEqual([])
// The session.created echo invalidates the info read and loads server truth.
emitEvent(events, {
id: "evt_created",
created: 2,
type: "session.created",
durable: durable(sessionID),
data: {
sessionID,
projectID: "proj_test",
location: { directory },
slug: "server-slug",
agent: "build",
version: "test",
},
})
await wait(() => data.session.get(sessionID)?.title === "Server title")
expect(fetched).toEqual([`/api/session/${sessionID}`])
} finally {
app.renderer.destroy()
}
})
test("rollbackCreate removes the seeded session and re-enables reads", async () => {
const events = createEventStream()
const sessionID = "ses_rollback"
const fetched: string[] = []
const calls = createFetch((url) => {
if (url.pathname !== `/api/session/${sessionID}/message`) return
fetched.push(url.pathname)
return json({ data: [], cursor: {} })
}, events)
const { app, data } = await renderData(calls.fetch)
try {
data.session.optimistic.create({
sessionID,
projectID: "proj_test",
location: { directory },
})
data.session.optimistic.prompt({
sessionID,
messageID: "msg_first",
delivery: "steer",
text: "First prompt",
})
expect(data.session.message.list(sessionID)).toHaveLength(1)
data.session.optimistic.rollback(sessionID, "msg_first")
data.session.optimistic.rollbackCreate(sessionID)
expect(data.session.get(sessionID)).toBeUndefined()
expect(data.session.message.list(sessionID)).toHaveLength(0)
expect(data.session.pending.list(sessionID)).toHaveLength(0)
// The seed's completed read markers are gone with it.
await data.session.message.sync(sessionID)
expect(fetched).toEqual([`/api/session/${sessionID}/message`])
} 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()
}
})
+1 -1
View File
@@ -27,7 +27,6 @@
justify-content: center;
align-items: center;
padding: 0;
padding-inline: 4px;
gap: 4px;
min-width: 14px;
height: 14px;
@@ -49,6 +48,7 @@
flex-direction: row;
justify-content: center;
align-items: center;
min-width: 14px;
height: 11px;
padding: 0;
flex: none;
@@ -58,8 +58,6 @@ export const MultipleKeys = {
<div style={{ display: "flex", gap: "24px", "align-items": "center" }}>
<KeybindV2 keys={["⌘", "K"]} variant="neutral" />
<KeybindV2 keys={["⌘", "K"]} variant="ghost" />
<KeybindV2 keys={["Ctrl", "B"]} variant="neutral" />
<KeybindV2 keys={["Ctrl", "B"]} variant="ghost" />
</div>
),
}
@@ -72,14 +70,12 @@ export const AllExamples = {
<KeybindV2 keys={["⌘"]} variant="neutral" />
<KeybindV2 keys={["⌘", "K"]} variant="neutral" />
<KeybindV2 keys={["⌘", "⇧", "P"]} variant="neutral" />
<KeybindV2 keys={["Ctrl", "Shift", "B"]} variant="neutral" />
</div>
<div style={{ display: "flex", gap: "24px", "align-items": "center" }}>
<span style={{ "font-size": "11px", color: "#808080", width: "50px" }}>Ghost</span>
<KeybindV2 keys={["⌘"]} variant="ghost" />
<KeybindV2 keys={["⌘", "K"]} variant="ghost" />
<KeybindV2 keys={["⌘", "⇧", "P"]} variant="ghost" />
<KeybindV2 keys={["Ctrl", "Shift", "B"]} variant="ghost" />
</div>
</div>
),