Compare commits

..
Author SHA1 Message Date
Aiden Cline f81d771e47 fix(core): treat project paths as internal 2026-08-15 12:00:30 +00:00
Luke Parker 79fc74afbf fix(app): show skill names (#42749) 2026-08-15 20:20:53 +10:00
Luke Parker 30dfe5352c fix(app): show read tool filenames (#42740) 2026-08-15 20:05:13 +10:00
Luke Parker f725443e30 fix(core): normalize Windows location keys (#42745) 2026-08-15 19:56:43 +10:00
opencode-agent[bot]andLuke Parker cae205a3d9 fix(core): bound fuzzy search memory (#42741)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-15 19:40:24 +10:00
Luke Parker d35c6f04ed fix(ui): add keybind label padding (#42726) 2026-08-15 17:54:31 +10:00
opencode-agent[bot]andLuke Parker 4d021b4660 fix(app): fade tab titles beneath close button (#42730)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-15 17:10:56 +10:00
Luke Parker f63f912178 test(app): typecheck complete e2e suite (#42728) 2026-08-15 17:06:58 +10:00
opencode-agent[bot] 6564d1442a chore: generate 2026-08-15 06:56:31 +00:00
Luke Parker a5f3e9e735 fix(app): preserve assistant content order (#42721) 2026-08-15 16:55:22 +10:00
Luke Parker d24a24a2ca fix(app): route global events without directory sentinel (#42719) 2026-08-15 16:47:37 +10:00
Luke Parker 8610d90838 fix(app): project sent messages through inbox events (#42714) 2026-08-15 15:57:46 +10:00
Luke Parker 57b050e9fc fix(desktop): render v2 patch metadata (#42716) 2026-08-15 15:57:34 +10:00
Luke Parker 51091be7e4 fix(desktop): verify updates before install (#42715) 2026-08-15 14:39:01 +10:00
Luke Parker c8584ec0c8 fix(app): derive popular providers from integrations (#42713) 2026-08-15 14:29:19 +10:00
Luke Parker 7301c5e798 fix(desktop): keep staged updates fresh with silent re-checks (#42707) 2026-08-15 14:22:24 +10:00
Luke ParkerandOpenCode Vite Investigation 41f70bfbb1 fix(app): generate blob ids without crypto.subtle in non-secure contexts (#42706)
Co-authored-by: OpenCode Vite Investigation <opencode-vite-investigation@local>
2026-08-15 14:12:57 +10:00
85 changed files with 2157 additions and 2762 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 output.write(chunk.base64Encoded ? Buffer.from(chunk.data, "base64") : chunk.data)
await (chunk.base64Encoded ? output.write(Buffer.from(chunk.data, "base64")) : output.write(chunk.data))
if (chunk.eof) break
}
} finally {
@@ -125,17 +125,20 @@ export async function installTimelineStreamProbe(
const scrollTo = Element.prototype.scrollTo
const scrollTop = Object.getOwnPropertyDescriptor(Element.prototype, "scrollTop")!
if (profileVisual) {
Element.prototype.scrollTo = function (...args) {
function measuredScrollTo(this: Element, options?: ScrollToOptions): void
function measuredScrollTo(this: Element, x: number, y: number): void
function measuredScrollTo(this: Element, first?: number | ScrollToOptions, second?: number) {
state.scroll.calls += 1
const top = typeof args[0] === "object" ? args[0]?.top : args[1]
const top = typeof first === "object" ? first?.top : second
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
return scrollTo.apply(this, args)
Reflect.apply(scrollTo, this, typeof first === "number" ? [first, second] : [first])
}
Element.prototype.scrollTo = measuredScrollTo
Object.defineProperty(Element.prototype, "scrollTop", {
configurable: true,
get: scrollTop.get,
@@ -267,18 +267,19 @@ 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: {
@@ -333,7 +334,7 @@ export const fixture = {
sourceID,
targetID,
childID,
messages: { [sourceID]: sourceMessages, [targetID]: targetMessages, [childID]: childMessages },
messages,
expected: {
sourceTitle: "Uncommitted changes inquiry",
targetTitle: "Example Game: sample jump movement & sample physics analysis",
@@ -345,16 +346,12 @@ 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) =>
orderedParts(message)
.filter(renderable)
.map((part) => part.id),
),
targetPartIDs: targetMessages.flatMap((message) => message.parts.filter(renderable).map((part) => part.id)),
},
}
export function pageMessages(sessionID: string, limit: number, before?: string) {
const messages = fixture.messages[sessionID as keyof typeof fixture.messages] ?? []
const messages = fixture.messages[sessionID] ?? []
const end = before
? Math.max(
0,
@@ -364,6 +361,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,7 +220,8 @@ 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(currentSession), cursor: {} })
if (url.pathname === "/api/session")
return json(route, { data: sessions.map((session) => currentSession(session)), cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
const currentSessionInfo = sessions.find((session) => url.pathname === `/api/session/${session.id}`)
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
@@ -82,6 +82,7 @@ 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,28 +1,27 @@
import { expect, test } from "@playwright/test"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
import { session, sessionID, setupTimeline } from "../performance/timeline-stability/fixture"
const user = { id: "msg_user", type: "user", text: "Run it", time: { created: 1 } } satisfies SessionMessageInfo
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
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 } : {}) },
})
test("renders current protocol notices in CLI order", async ({ page }) => {
const ownerWarnings: string[] = []
@@ -280,6 +280,7 @@ 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,11 +23,10 @@ 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,prt_boundary_03_glob,prt_boundary_04_grep"]'),
).toBeVisible()
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_06_list"]')).toBeVisible()
await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(4)
await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(5)
})
test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => {
@@ -131,6 +131,7 @@ 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,6 +83,40 @@ 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 }, reducedMotion: "no-preference" })
test.use({ viewport: { width: 1440, height: 900 } })
test("animates todo opening without replaying it across session tabs", async ({ page }) => {
test.setTimeout(90_000)
@@ -57,7 +57,6 @@ test("animates todo opening without replaying it across session tabs", async ({
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
},
sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)],
sessionStatus: { [sourceID]: { type: "busy" } },
pageMessages: () => ({ items: [] }),
events: () => events.splice(0, 1),
eventRetry: 16,
@@ -90,7 +90,8 @@ 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(currentSession), cursor: {} })
if (url.pathname === "/api/session")
return json(route, { data: sessions.map((session) => currentSession(session)), cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`)
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
@@ -227,25 +227,24 @@ 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 []
})
.sort()
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 []
})
}
export const fixture = {
@@ -292,7 +291,7 @@ export const fixture = {
],
sourceID,
targetID,
messages: { [sourceID]: sourceMessages, [targetID]: targetMessages },
messages,
expected: {
sourceTitle: "Uncommitted changes inquiry",
targetTitle: "Example Game: sample jump movement & sample physics analysis",
@@ -306,7 +305,7 @@ export const fixture = {
}
export function pageMessages(sessionID: string, limit: number, before?: string) {
const messages = fixture.messages[sessionID as keyof typeof fixture.messages] ?? []
const messages = fixture.messages[sessionID] ?? []
const end = before
? Math.max(
0,
@@ -316,6 +315,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 as keyof typeof fixture.messages] ?? [] }),
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID] ?? [] }),
})
await configureSmokePage(page, fixture.directory)
await page.addInitScript(
@@ -188,7 +188,11 @@ 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 - view.bottom })
samples.push({
ids: visible,
last: visible.includes(last),
bottomError: bottom ? bottom.bottom - view.bottom : undefined,
})
if (
!firstPaint &&
visible.includes(last) &&
@@ -263,7 +267,7 @@ test.describe("smoke: session timeline", () => {
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }),
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID] ?? [] }),
})
await configureSmokePage(page, fixture.directory)
await page.addInitScript(
+3 -13
View File
@@ -1,21 +1,11 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"composite": false,
"emitDeclarationOnly": false,
"noEmit": true,
"rootDir": "..",
"types": ["node", "bun"]
},
"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"
]
"include": ["./**/*.ts", "./**/*.tsx", "../src/types.ts"]
}
@@ -0,0 +1,321 @@
import { describe, expect, test } from "bun:test"
import type { Prompt } from "@/context/prompt"
import { buildPromptRequest } from "./build-prompt-request"
describe("buildPromptRequest", () => {
test("builds text, files, and agents from the prompt", () => {
const prompt: Prompt = [
{ type: "text", content: "hello", start: 0, end: 5 },
{
type: "file",
path: "src/foo.ts",
content: "@src/foo.ts",
start: 5,
end: 16,
selection: { startLine: 4, startChar: 1, endLine: 6, endChar: 1 },
},
{ type: "agent", name: "planner", content: "@planner", start: 16, end: 24 },
]
const result = buildPromptRequest({
prompt,
context: [{ key: "ctx:1", type: "file", path: "src/bar.ts", comment: "check this" }],
images: [
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
],
text: "hello @src/foo.ts @planner",
sessionDirectory: "/repo",
})
expect(result.text).toContain("hello @src/foo.ts @planner")
expect(result.text).toContain("check this")
expect(result.displayText).toBe("hello @src/foo.ts @planner")
expect(result.comments).toMatchObject([{ path: "src/bar.ts", comment: "check this" }])
expect(result.agents).toEqual([{ name: "planner", mention: { start: 16, end: 24, text: "@planner" } }])
expect(result.files.some((file) => file.uri.startsWith("file:///repo/src/foo.ts"))).toBe(true)
expect(result.files.find((file) => file.uri.startsWith("file:///repo/src/foo.ts"))?.mention).toEqual({
start: 5,
end: 16,
text: "@src/foo.ts",
})
})
test("keeps multiple uploaded attachments in order", () => {
const result = buildPromptRequest({
prompt: [{ type: "text", content: "check these", start: 0, end: 11 }],
context: [],
images: [
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
{
type: "image",
id: "img_2",
filename: "b.pdf",
mime: "application/pdf",
dataUrl: "data:application/pdf;base64,BBB",
},
],
text: "check these",
sessionDirectory: "/repo",
})
const uploads = result.files.filter((file) => file.uri.startsWith("data:"))
expect(uploads).toHaveLength(2)
expect(uploads.map((file) => file.name)).toEqual(["a.png", "b.pdf"])
})
test("preserves an external attachment source path for the model", () => {
const result = buildPromptRequest({
prompt: [],
context: [],
images: [
{
type: "image",
id: "img_external",
filename: "opencode.global.dat",
sourcePath: "C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
mime: "text/plain",
dataUrl: "data:text/plain;base64,AAA",
},
],
text: "inspect this",
sessionDirectory: "C:\\Repos\\sst\\opencode",
})
expect(result.files[0]?.name).toBe(
"C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
)
})
test("preserves reference aliases as directory files", () => {
const result = buildPromptRequest({
prompt: [
{
type: "file",
path: "/repo/../docs",
content: "@docs",
start: 0,
end: 5,
mime: "application/x-directory",
filename: "docs",
},
],
context: [],
images: [],
text: "@docs",
sessionDirectory: "/repo/app",
})
expect(result.files[0]).toEqual({
uri: "file:///repo/../docs",
mime: "application/x-directory",
name: "docs",
mention: { start: 0, end: 5, text: "@docs" },
})
})
test("deduplicates context files when prompt already includes same path", () => {
const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
const result = buildPromptRequest({
prompt,
context: [
{ key: "ctx:dup", type: "file", path: "src/foo.ts" },
{ key: "ctx:comment", type: "file", path: "src/foo.ts", comment: "focus here" },
],
images: [],
text: "@src/foo.ts",
sessionDirectory: "/repo",
})
const fooFiles = result.files.filter((file) => file.uri.startsWith("file:///repo/src/foo.ts"))
expect(fooFiles).toHaveLength(2)
expect(result.text).toContain("focus here")
})
test("adds files for @mentions inside comment text", () => {
const result = buildPromptRequest({
prompt: [{ type: "text", content: "look", start: 0, end: 4 }],
context: [
{
key: "ctx:comment-mention",
type: "file",
path: "src/review.ts",
comment: "Compare with @src/shared.ts and @src/review.ts.",
},
],
images: [],
text: "look",
sessionDirectory: "/repo",
})
expect(result.files).toHaveLength(2)
expect(result.files.some((file) => file.uri === "file:///repo/src/review.ts")).toBe(true)
expect(result.files.some((file) => file.uri === "file:///repo/src/shared.ts")).toBe(true)
})
test("handles Windows paths correctly (simulated on macOS)", () => {
const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }]
const result = buildPromptRequest({
prompt,
context: [],
images: [],
text: "@src\\foo.ts",
sessionDirectory: "D:\\projects\\myapp", // Windows path
})
const file = result.files[0]
expect(file).toBeDefined()
// URL should be parseable
expect(() => new URL(file!.uri)).not.toThrow()
// Should not have encoded backslashes in wrong place
expect(file!.uri).not.toContain("%5C")
// Should have normalized to forward slashes
expect(file!.uri).toContain("/src/foo.ts")
})
test("handles Windows absolute path with special characters", () => {
const prompt: Prompt = [{ type: "file", path: "file#name.txt", content: "@file#name.txt", start: 0, end: 14 }]
const result = buildPromptRequest({
prompt,
context: [],
images: [],
text: "@file#name.txt",
sessionDirectory: "C:\\Users\\test\\Documents", // Windows path
})
const file = result.files[0]
expect(file).toBeDefined()
// URL should be parseable
expect(() => new URL(file!.uri)).not.toThrow()
// Special chars should be encoded
expect(file!.uri).toContain("file%23name.txt")
// Should have Windows drive letter properly encoded
expect(file!.uri).toMatch(/file:\/\/\/[A-Z]:/)
})
test("handles Linux absolute paths correctly", () => {
const prompt: Prompt = [{ type: "file", path: "src/app.ts", content: "@src/app.ts", start: 0, end: 10 }]
const result = buildPromptRequest({
prompt,
context: [],
images: [],
text: "@src/app.ts",
sessionDirectory: "/home/user/project",
})
expect(result.files[0]?.uri).toBe("file:///home/user/project/src/app.ts")
})
test("handles macOS paths correctly", () => {
const prompt: Prompt = [{ type: "file", path: "README.md", content: "@README.md", start: 0, end: 9 }]
const result = buildPromptRequest({
prompt,
context: [],
images: [],
text: "@README.md",
sessionDirectory: "/Users/kelvin/Projects/opencode",
})
expect(result.files[0]?.uri).toBe("file:///Users/kelvin/Projects/opencode/README.md")
})
test("handles context files with Windows paths", () => {
const result = buildPromptRequest({
prompt: [],
context: [
{ key: "ctx:1", type: "file", path: "src\\utils\\helper.ts" },
{ key: "ctx:2", type: "file", path: "test\\unit.test.ts", comment: "check tests" },
],
images: [],
text: "test",
sessionDirectory: "D:\\workspace\\app",
})
expect(result.files).toHaveLength(2)
// All file URLs should be valid
result.files.forEach((file) => {
expect(() => new URL(file.uri)).not.toThrow()
expect(file.uri).not.toContain("%5C") // No encoded backslashes
})
})
test("handles absolute Windows paths (user manually specifies full path)", () => {
const prompt: Prompt = [
{ type: "file", path: "D:\\other\\project\\file.ts", content: "@D:\\other\\project\\file.ts", start: 0, end: 25 },
]
const result = buildPromptRequest({
prompt,
context: [],
images: [],
text: "@D:\\other\\project\\file.ts",
sessionDirectory: "C:\\current\\project",
})
const file = result.files[0]
expect(file).toBeDefined()
// Should handle absolute path that differs from sessionDirectory
expect(() => new URL(file!.uri)).not.toThrow()
expect(file!.uri).toContain("/D:/other/project/file.ts")
})
test("handles selection with query parameters on Windows", () => {
const prompt: Prompt = [
{
type: "file",
path: "src\\App.tsx",
content: "@src\\App.tsx",
start: 0,
end: 11,
selection: { startLine: 10, startChar: 0, endLine: 20, endChar: 5 },
},
]
const result = buildPromptRequest({
prompt,
context: [],
images: [],
text: "@src\\App.tsx",
sessionDirectory: "C:\\project",
})
const file = result.files[0]
expect(file).toBeDefined()
// Should have query parameters
expect(file!.uri).toContain("?start=10&end=20")
// Should be valid URL
expect(() => new URL(file!.uri)).not.toThrow()
// Query params should parse correctly
const url = new URL(file!.uri)
expect(url.searchParams.get("start")).toBe("10")
expect(url.searchParams.get("end")).toBe("20")
})
test("handles file paths with dots and special segments on Windows", () => {
const prompt: Prompt = [
{ type: "file", path: "..\\..\\shared\\util.ts", content: "@..\\..\\shared\\util.ts", start: 0, end: 21 },
]
const result = buildPromptRequest({
prompt,
context: [],
images: [],
text: "@..\\..\\shared\\util.ts",
sessionDirectory: "C:\\projects\\myapp\\src",
})
const file = result.files[0]
expect(file).toBeDefined()
// Should be valid URL
expect(() => new URL(file!.uri)).not.toThrow()
// Should preserve .. segments (backend normalizes)
expect(file!.uri).toContain("/..")
})
})
@@ -0,0 +1,115 @@
import { getFilename } from "@opencode-ai/core/util/path"
import type { FileSelection } from "@/context/file"
import { encodeFilePath } from "@/context/file/path"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
import { formatCommentNote, type PromptComment } from "@/utils/comment-note"
// Network fields feed both boundaries; display fields keep desktop-only rendering details in the local echo.
type PromptRequest = {
text: string
displayText: string
files: { uri: string; mime: string; name?: string; mention?: { start: number; end: number; text: string } }[]
agents: { name: string; mention?: { start: number; end: number; text: string } }[]
comments: PromptComment[]
}
type ContextFile = {
key: string
type: "file"
path: string
selection?: FileSelection
comment?: string
commentID?: string
commentOrigin?: "review" | "file"
preview?: string
}
type BuildPromptRequestInput = {
prompt: Prompt
context: ContextFile[]
images: (Omit<ImageAttachmentPart, "blob"> & { dataUrl: string })[]
text: string
sessionDirectory: string
}
const absolute = (directory: string, path: string) => {
if (path.startsWith("/")) return path
if (/^[A-Za-z]:[\\/]/.test(path) || /^[A-Za-z]:$/.test(path)) return path
if (path.startsWith("\\\\") || path.startsWith("//")) return path
return `${directory.replace(/[\\/]+$/, "")}/${path}`
}
const fileQuery = (selection: FileSelection | undefined) =>
selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
const mention = /(^|[\s([{"'])@(\S+)/g
const parseCommentMentions = (comment: string) => {
return Array.from(comment.matchAll(mention)).flatMap((match) => {
const path = (match[2] ?? "").replace(/[.,!?;:)}\]"']+$/, "")
if (!path) return []
return [path]
})
}
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
export function buildPromptRequest(input: BuildPromptRequestInput): PromptRequest {
const files = input.prompt.filter(isFileAttachment).map((attachment) => {
const path = absolute(input.sessionDirectory, attachment.path)
return {
uri: attachment.url ?? `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`,
mime: attachment.mime ?? "text/plain",
name: attachment.filename ?? getFilename(attachment.path),
mention: { start: attachment.start, end: attachment.end, text: attachment.content },
}
})
const agents = input.prompt.filter(isAgentAttachment).map((attachment) => ({
name: attachment.name,
mention: { start: attachment.start, end: attachment.end, text: attachment.content },
}))
const used = new Set(files.map((file) => file.uri))
const comments: PromptComment[] = []
const context = input.context.flatMap((item) => {
const path = absolute(input.sessionDirectory, item.path)
const uri = `file://${encodeFilePath(path)}${fileQuery(item.selection)}`
const comment = item.comment?.trim()
if (!comment && used.has(uri)) return []
used.add(uri)
const file = { uri, mime: "text/plain", name: getFilename(item.path) }
if (!comment) return [file]
comments.push({
path: item.path,
selection: item.selection,
comment,
preview: item.preview,
origin: item.commentOrigin,
})
const mentions = parseCommentMentions(comment).flatMap((path) => {
const uri = `file://${encodeFilePath(absolute(input.sessionDirectory, path))}`
if (used.has(uri)) return []
used.add(uri)
return [{ uri, mime: "text/plain", name: getFilename(path) }]
})
return [file, ...mentions]
})
const images = input.images.map((attachment) => ({
uri: attachment.dataUrl,
mime: attachment.mime,
name: attachment.sourcePath ?? attachment.filename,
}))
return {
text: [...(input.text.trim() ? [input.text] : []), ...comments.map(formatCommentNote)].join("\n"),
displayText: input.text,
files: [...files, ...context, ...images],
agents,
comments,
}
}
@@ -1,396 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { Prompt } from "@/context/prompt"
import { buildRequestParts } from "./build-request-parts"
describe("buildRequestParts", () => {
test("builds typed request and optimistic parts without cast path", () => {
const prompt: Prompt = [
{ type: "text", content: "hello", start: 0, end: 5 },
{
type: "file",
path: "src/foo.ts",
content: "@src/foo.ts",
start: 5,
end: 16,
selection: { startLine: 4, startChar: 1, endLine: 6, endChar: 1 },
},
{ type: "agent", name: "planner", content: "@planner", start: 16, end: 24 },
]
const result = buildRequestParts({
prompt,
context: [{ key: "ctx:1", type: "file", path: "src/bar.ts", comment: "check this" }],
images: [
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
],
text: "hello @src/foo.ts @planner",
messageID: "msg_1",
sessionID: "ses_1",
sessionDirectory: "/repo",
})
expect(result.requestParts[0]?.type).toBe("text")
expect(result.requestParts.some((part) => part.type === "agent")).toBe(true)
expect(
result.requestParts.some((part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts")),
).toBe(true)
expect(result.requestParts.some((part) => part.type === "text" && part.synthetic)).toBe(true)
expect(
result.requestParts.some(
(part) =>
part.type === "text" &&
part.synthetic &&
part.metadata?.opencodeComment &&
(part.metadata.opencodeComment as { comment?: string }).comment === "check this",
),
).toBe(true)
expect(result.optimisticParts).toHaveLength(result.requestParts.length)
expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
})
test("keeps multiple uploaded attachments in order", () => {
const result = buildRequestParts({
prompt: [{ type: "text", content: "check these", start: 0, end: 11 }],
context: [],
images: [
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
{
type: "image",
id: "img_2",
filename: "b.pdf",
mime: "application/pdf",
dataUrl: "data:application/pdf;base64,BBB",
},
],
text: "check these",
messageID: "msg_multi",
sessionID: "ses_multi",
sessionDirectory: "/repo",
})
const files = result.requestParts.filter((part) => part.type === "file" && part.url.startsWith("data:"))
expect(files).toHaveLength(2)
expect(files.map((part) => (part.type === "file" ? part.filename : ""))).toEqual(["a.png", "b.pdf"])
})
test("preserves an external attachment source path for the model", () => {
const result = buildRequestParts({
prompt: [],
context: [],
images: [
{
type: "image",
id: "img_external",
filename: "opencode.global.dat",
sourcePath: "C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
mime: "text/plain",
dataUrl: "data:text/plain;base64,AAA",
},
],
text: "inspect this",
messageID: "msg_external",
sessionID: "ses_external",
sessionDirectory: "C:\\Repos\\sst\\opencode",
})
expect(result.requestParts.find((part) => part.type === "file")?.filename).toBe(
"C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
)
})
test("preserves reference aliases as directory file parts", () => {
const result = buildRequestParts({
prompt: [
{
type: "file",
path: "/repo/../docs",
content: "@docs",
start: 0,
end: 5,
mime: "application/x-directory",
filename: "docs",
},
],
context: [],
images: [],
text: "@docs",
messageID: "msg_reference",
sessionID: "ses_reference",
sessionDirectory: "/repo/app",
})
const filePart = result.requestParts.find((part) => part.type === "file")
expect(filePart).toBeDefined()
if (filePart?.type === "file") {
expect(filePart.mime).toBe("application/x-directory")
expect(filePart.filename).toBe("docs")
expect(filePart.url).toBe("file:///repo/../docs")
expect(filePart.source?.type).toBe("file")
if (filePart.source?.type === "file") {
expect(filePart.source.path).toBe("/repo/../docs")
expect(filePart.source.text.value).toBe("@docs")
}
}
})
test("deduplicates context files when prompt already includes same path", () => {
const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
const result = buildRequestParts({
prompt,
context: [
{ key: "ctx:dup", type: "file", path: "src/foo.ts" },
{ key: "ctx:comment", type: "file", path: "src/foo.ts", comment: "focus here" },
],
images: [],
text: "@src/foo.ts",
messageID: "msg_2",
sessionID: "ses_2",
sessionDirectory: "/repo",
})
const fooFiles = result.requestParts.filter(
(part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts"),
)
const synthetic = result.requestParts.filter((part) => part.type === "text" && part.synthetic)
expect(fooFiles).toHaveLength(2)
expect(synthetic).toHaveLength(1)
})
test("adds file parts for @mentions inside comment text", () => {
const result = buildRequestParts({
prompt: [{ type: "text", content: "look", start: 0, end: 4 }],
context: [
{
key: "ctx:comment-mention",
type: "file",
path: "src/review.ts",
comment: "Compare with @src/shared.ts and @src/review.ts.",
},
],
images: [],
text: "look",
messageID: "msg_comment_mentions",
sessionID: "ses_comment_mentions",
sessionDirectory: "/repo",
})
const files = result.requestParts.filter((part) => part.type === "file")
expect(files).toHaveLength(2)
expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/review.ts")).toBe(true)
expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/shared.ts")).toBe(true)
})
test("handles Windows paths correctly (simulated on macOS)", () => {
const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }]
const result = buildRequestParts({
prompt,
context: [],
images: [],
text: "@src\\foo.ts",
messageID: "msg_win_1",
sessionID: "ses_win_1",
sessionDirectory: "D:\\projects\\myapp", // Windows path
})
// Should create valid file URLs
const filePart = result.requestParts.find((part) => part.type === "file")
expect(filePart).toBeDefined()
if (filePart?.type === "file") {
// URL should be parseable
expect(() => new URL(filePart.url)).not.toThrow()
// Should not have encoded backslashes in wrong place
expect(filePart.url).not.toContain("%5C")
// Should have normalized to forward slashes
expect(filePart.url).toContain("/src/foo.ts")
}
})
test("handles Windows absolute path with special characters", () => {
const prompt: Prompt = [{ type: "file", path: "file#name.txt", content: "@file#name.txt", start: 0, end: 14 }]
const result = buildRequestParts({
prompt,
context: [],
images: [],
text: "@file#name.txt",
messageID: "msg_win_2",
sessionID: "ses_win_2",
sessionDirectory: "C:\\Users\\test\\Documents", // Windows path
})
const filePart = result.requestParts.find((part) => part.type === "file")
expect(filePart).toBeDefined()
if (filePart?.type === "file") {
// URL should be parseable
expect(() => new URL(filePart.url)).not.toThrow()
// Special chars should be encoded
expect(filePart.url).toContain("file%23name.txt")
// Should have Windows drive letter properly encoded
expect(filePart.url).toMatch(/file:\/\/\/[A-Z]:/)
}
})
test("handles Linux absolute paths correctly", () => {
const prompt: Prompt = [{ type: "file", path: "src/app.ts", content: "@src/app.ts", start: 0, end: 10 }]
const result = buildRequestParts({
prompt,
context: [],
images: [],
text: "@src/app.ts",
messageID: "msg_linux_1",
sessionID: "ses_linux_1",
sessionDirectory: "/home/user/project",
})
const filePart = result.requestParts.find((part) => part.type === "file")
expect(filePart).toBeDefined()
if (filePart?.type === "file") {
// URL should be parseable
expect(() => new URL(filePart.url)).not.toThrow()
// Should be a normal Unix path
expect(filePart.url).toBe("file:///home/user/project/src/app.ts")
}
})
test("handles macOS paths correctly", () => {
const prompt: Prompt = [{ type: "file", path: "README.md", content: "@README.md", start: 0, end: 9 }]
const result = buildRequestParts({
prompt,
context: [],
images: [],
text: "@README.md",
messageID: "msg_mac_1",
sessionID: "ses_mac_1",
sessionDirectory: "/Users/kelvin/Projects/opencode",
})
const filePart = result.requestParts.find((part) => part.type === "file")
expect(filePart).toBeDefined()
if (filePart?.type === "file") {
// URL should be parseable
expect(() => new URL(filePart.url)).not.toThrow()
// Should be a normal Unix path
expect(filePart.url).toBe("file:///Users/kelvin/Projects/opencode/README.md")
}
})
test("handles context files with Windows paths", () => {
const prompt: Prompt = []
const result = buildRequestParts({
prompt,
context: [
{ key: "ctx:1", type: "file", path: "src\\utils\\helper.ts" },
{ key: "ctx:2", type: "file", path: "test\\unit.test.ts", comment: "check tests" },
],
images: [],
text: "test",
messageID: "msg_win_ctx",
sessionID: "ses_win_ctx",
sessionDirectory: "D:\\workspace\\app",
})
const fileParts = result.requestParts.filter((part) => part.type === "file")
expect(fileParts).toHaveLength(2)
// All file URLs should be valid
fileParts.forEach((part) => {
if (part.type === "file") {
expect(() => new URL(part.url)).not.toThrow()
expect(part.url).not.toContain("%5C") // No encoded backslashes
}
})
})
test("handles absolute Windows paths (user manually specifies full path)", () => {
const prompt: Prompt = [
{ type: "file", path: "D:\\other\\project\\file.ts", content: "@D:\\other\\project\\file.ts", start: 0, end: 25 },
]
const result = buildRequestParts({
prompt,
context: [],
images: [],
text: "@D:\\other\\project\\file.ts",
messageID: "msg_abs",
sessionID: "ses_abs",
sessionDirectory: "C:\\current\\project",
})
const filePart = result.requestParts.find((part) => part.type === "file")
expect(filePart).toBeDefined()
if (filePart?.type === "file") {
// Should handle absolute path that differs from sessionDirectory
expect(() => new URL(filePart.url)).not.toThrow()
expect(filePart.url).toContain("/D:/other/project/file.ts")
}
})
test("handles selection with query parameters on Windows", () => {
const prompt: Prompt = [
{
type: "file",
path: "src\\App.tsx",
content: "@src\\App.tsx",
start: 0,
end: 11,
selection: { startLine: 10, startChar: 0, endLine: 20, endChar: 5 },
},
]
const result = buildRequestParts({
prompt,
context: [],
images: [],
text: "@src\\App.tsx",
messageID: "msg_sel",
sessionID: "ses_sel",
sessionDirectory: "C:\\project",
})
const filePart = result.requestParts.find((part) => part.type === "file")
expect(filePart).toBeDefined()
if (filePart?.type === "file") {
// Should have query parameters
expect(filePart.url).toContain("?start=10&end=20")
// Should be valid URL
expect(() => new URL(filePart.url)).not.toThrow()
// Query params should parse correctly
const url = new URL(filePart.url)
expect(url.searchParams.get("start")).toBe("10")
expect(url.searchParams.get("end")).toBe("20")
}
})
test("handles file paths with dots and special segments on Windows", () => {
const prompt: Prompt = [
{ type: "file", path: "..\\..\\shared\\util.ts", content: "@..\\..\\shared\\util.ts", start: 0, end: 21 },
]
const result = buildRequestParts({
prompt,
context: [],
images: [],
text: "@..\\..\\shared\\util.ts",
messageID: "msg_dots",
sessionID: "ses_dots",
sessionDirectory: "C:\\projects\\myapp\\src",
})
const filePart = result.requestParts.find((part) => part.type === "file")
expect(filePart).toBeDefined()
if (filePart?.type === "file") {
// Should be valid URL
expect(() => new URL(filePart.url)).not.toThrow()
// Should preserve .. segments (backend normalizes)
expect(filePart.url).toContain("/..")
}
})
})
@@ -1,216 +0,0 @@
import { getFilename } from "@opencode-ai/core/util/path"
import type { AgentPart as MessageAgentPart, FilePart, Part, TextPart } from "@/types"
import type { FileSelection } from "@/context/file"
import { encodeFilePath } from "@/context/file/path"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
import { Identifier } from "@/utils/id"
import { createCommentMetadata, formatCommentNote } from "@/utils/comment-note"
type PromptRequestPart =
| (Omit<TextPart, "id" | "sessionID" | "messageID"> & { id: string })
| (Omit<FilePart, "id" | "sessionID" | "messageID"> & { id: string })
| (Omit<MessageAgentPart, "id" | "sessionID" | "messageID"> & { id: string })
type ContextFile = {
key: string
type: "file"
path: string
selection?: FileSelection
comment?: string
commentID?: string
commentOrigin?: "review" | "file"
preview?: string
}
type BuildRequestPartsInput = {
prompt: Prompt
context: ContextFile[]
images: (Omit<ImageAttachmentPart, "blob"> & { dataUrl: string })[]
text: string
messageID: string
sessionID: string
sessionDirectory: string
}
const absolute = (directory: string, path: string) => {
if (path.startsWith("/")) return path
if (/^[A-Za-z]:[\\/]/.test(path) || /^[A-Za-z]:$/.test(path)) return path
if (path.startsWith("\\\\") || path.startsWith("//")) return path
return `${directory.replace(/[\\/]+$/, "")}/${path}`
}
const fileQuery = (selection: FileSelection | undefined) =>
selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
const mention = /(^|[\s([{"'])@(\S+)/g
const parseCommentMentions = (comment: string) => {
return Array.from(comment.matchAll(mention)).flatMap((match) => {
const path = (match[2] ?? "").replace(/[.,!?;:)}\]"']+$/, "")
if (!path) return []
return [path]
})
}
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
const toOptimisticPart = (part: PromptRequestPart, sessionID: string, messageID: string): Part => {
if (part.type === "text") {
return {
id: part.id,
type: "text",
text: part.text,
synthetic: part.synthetic,
ignored: part.ignored,
time: part.time,
metadata: part.metadata,
sessionID,
messageID,
}
}
if (part.type === "file") {
return {
id: part.id,
type: "file",
mime: part.mime,
filename: part.filename,
url: part.url,
source: part.source,
sessionID,
messageID,
}
}
return {
id: part.id,
type: "agent",
name: part.name,
source: part.source,
sessionID,
messageID,
}
}
export function buildRequestParts(input: BuildRequestPartsInput) {
const requestParts: PromptRequestPart[] = input.text.trim()
? [
{
id: Identifier.ascending("part"),
type: "text",
text: input.text,
},
]
: []
const files = input.prompt.filter(isFileAttachment).map((attachment) => {
const path = absolute(input.sessionDirectory, attachment.path)
const source = attachment.source
? {
...attachment.source,
text: {
value: attachment.content,
start: attachment.start,
end: attachment.end,
},
}
: {
type: "file" as const,
text: {
value: attachment.content,
start: attachment.start,
end: attachment.end,
},
path,
}
return {
id: Identifier.ascending("part"),
type: "file",
mime: attachment.mime ?? "text/plain",
url: attachment.url ?? `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`,
filename: attachment.filename ?? getFilename(attachment.path),
source,
} satisfies PromptRequestPart
})
const agents = input.prompt.filter(isAgentAttachment).map((attachment) => {
return {
id: Identifier.ascending("part"),
type: "agent",
name: attachment.name,
source: {
value: attachment.content,
start: attachment.start,
end: attachment.end,
},
} satisfies PromptRequestPart
})
const used = new Set(files.map((part) => part.url))
const context = input.context.flatMap((item) => {
const path = absolute(input.sessionDirectory, item.path)
const url = `file://${encodeFilePath(path)}${fileQuery(item.selection)}`
const comment = item.comment?.trim()
if (!comment && used.has(url)) return []
used.add(url)
const filePart = {
id: Identifier.ascending("part"),
type: "file",
mime: "text/plain",
url,
filename: getFilename(item.path),
} satisfies PromptRequestPart
if (!comment) return [filePart]
const mentions = parseCommentMentions(comment).flatMap((path) => {
const url = `file://${encodeFilePath(absolute(input.sessionDirectory, path))}`
if (used.has(url)) return []
used.add(url)
return [
{
id: Identifier.ascending("part"),
type: "file",
mime: "text/plain",
url,
filename: getFilename(path),
} satisfies PromptRequestPart,
]
})
return [
{
id: Identifier.ascending("part"),
type: "text",
text: formatCommentNote({ path: item.path, selection: item.selection, comment }),
synthetic: true,
metadata: createCommentMetadata({
path: item.path,
selection: item.selection,
comment,
preview: item.preview,
origin: item.commentOrigin,
}),
} satisfies PromptRequestPart,
filePart,
...mentions,
]
})
const images = input.images.map((attachment) => {
return {
id: Identifier.ascending("part"),
type: "file",
mime: attachment.mime,
url: attachment.dataUrl,
filename: attachment.sourcePath ?? attachment.filename,
} satisfies PromptRequestPart
})
requestParts.push(...files, ...context, ...agents, ...images)
return {
requestParts,
optimisticParts: requestParts.map((part) => toOptimisticPart(part, input.sessionID, input.messageID)),
}
}
@@ -11,15 +11,17 @@ type SessionCreateInput = {
model?: { id: string; providerID: string; variant?: string }
location?: { directory: string }
}
const optimistic: Array<{
const admitted: Array<{
directory?: string
sessionID?: string
message: {
agent: string
model: { providerID: string; modelID: string }
variant?: string
}
sessionID: string
messageID: string
text: string
displayText: string
agent: string
model: { providerID: string; modelID: string; variant?: string }
comments: unknown[]
}> = []
const confirmed: unknown[] = []
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
const sentShellDirectories: string[] = []
@@ -35,9 +37,11 @@ const switchedModels: Array<{
const sessionRequestOrder: string[] = []
const updatedDrafts: Array<{ draftID: string; worktree?: string }> = []
const syncedServers: string[] = []
const optimisticServers: string[] = []
const admittedServers: 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 } = {}
@@ -47,6 +51,8 @@ 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"
@@ -74,7 +80,7 @@ const prompt = {
set: () => undefined,
},
reset: () => undefined,
set: () => undefined,
set: () => restoredPrompts++,
context: {
add: () => undefined,
remove: () => undefined,
@@ -116,7 +122,16 @@ const clientFor = (directory: string) => {
sessionRequestOrder.push("prompt")
sentPrompts.push(sessionDirectories[(input as { sessionID: string }).sessionID] ?? directory)
promptInputs.push(input)
return { data: undefined }
if (promptFailure) throw promptFailure
const prompt = input as { sessionID: string; id: string; text: string }
return {
id: prompt.id,
sessionID: prompt.sessionID,
timeCreated: 1,
type: "user" as const,
delivery: "steer" as const,
payload: { text: prompt.text },
}
},
switchAgent: async (input: { sessionID: string; agent: string }) => {
sessionRequestOrder.push("agent")
@@ -235,16 +250,27 @@ beforeAll(async () => {
return {
data: { command: commands, project: "project" },
session: {
optimistic: {
add: (value: {
inbox: {
echo: (value: {
directory?: string
sessionID?: string
message: { agent: string; model: { providerID: string; modelID: string; variant?: string } }
sessionID: string
messageID: string
text: string
displayText: string
agent: string
model: { providerID: string; modelID: string; variant?: string }
comments: unknown[]
}) => {
optimisticServers.push(server)
optimistic.push(value)
admittedServers.push(server)
admitted.push(value)
},
confirm: (value: unknown) => {
confirmed.push(value)
},
clearEcho: () => {
clearEchoCalls++
return clearEchoResult
},
remove: () => undefined,
},
},
set: () => undefined,
@@ -304,7 +330,8 @@ beforeAll(async () => {
beforeEach(() => {
createdSessions.length = 0
optimistic.length = 0
admitted.length = 0
confirmed.length = 0
promotedDrafts.length = 0
updatedDrafts.length = 0
sentCommands.length = 0
@@ -314,8 +341,10 @@ beforeEach(() => {
switchedModels.length = 0
sessionRequestOrder.length = 0
syncedServers.length = 0
optimisticServers.length = 0
admittedServers.length = 0
promptCaptures.length = 0
restoredPrompts = 0
clearEchoCalls = 0
params = {}
search = {}
sentShell.length = 0
@@ -333,6 +362,8 @@ 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]
@@ -421,7 +452,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(optimisticServers).toEqual(["server-a"])
expect(admittedServers).toEqual(["server-a"])
expect(promptCaptures.at(-1)?.target).toEqual({ server: "project-server-a", scope: ServerScope.local })
expect(submitted).toBe(0)
})
@@ -441,13 +472,15 @@ describe("prompt submit worktree selection", () => {
await submit.handleSubmit(event)
await Bun.sleep(0)
expect(optimistic).toHaveLength(1)
expect(optimistic[0]).toMatchObject({
message: {
agent: "agent",
model: { providerID: "provider", modelID: "model", variant: "high" },
},
expect(admitted).toHaveLength(1)
expect(admitted[0]).toMatchObject({
sessionID: "session-1",
text: "ls",
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([
@@ -466,6 +499,22 @@ 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,10 +1,9 @@
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 { batch, startTransition, type Accessor } from "solid-js"
import { startTransition, type Accessor } from "solid-js"
import { useTabs } from "@/context/tabs"
import { useServerSync, type ServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
@@ -15,7 +14,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 { buildRequestParts } from "./build-request-parts"
import { buildPromptRequest } from "./build-prompt-request"
import { setCursorPosition } from "./editor-dom"
import { formatServerError } from "@/utils/server-errors"
import { ScopedKey } from "@/utils/server-scope"
@@ -100,43 +99,22 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
dataUrl: await blobDataUrl(attachment.blob, attachment.mime),
})),
)
const { requestParts, optimisticParts } = buildRequestParts({
const request = buildPromptRequest({
prompt: input.draft.prompt,
context: input.draft.context,
images: encodedImages,
text,
sessionID: input.draft.sessionID,
messageID,
sessionDirectory: input.draft.sessionDirectory,
})
const message: Message = {
id: messageID,
setBusy()
input.sync.session.inbox.echo({
directory: input.draft.sessionDirectory,
sessionID: input.draft.sessionID,
role: "user",
time: { created: Date.now() },
messageID,
agent: input.draft.agent,
model: { ...input.draft.model, variant: input.draft.variant },
}
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()
...request,
})
try {
@@ -159,40 +137,23 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
})
}
await input.api.prompt({
const admitted = await input.api.prompt({
sessionID: input.draft.sessionID,
id: messageID,
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,
},
]
: [],
),
text: request.text,
files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
agents: request.agents,
})
input.sync.session.inbox.confirm(admitted)
return true
} catch (err) {
batch(() => {
setIdle()
remove()
const failed = input.sync.session.inbox.clearEcho({
directory: input.draft.sessionDirectory,
sessionID: input.draft.sessionID,
messageID,
})
if (!failed) return true
setIdle()
throw err
}
}
@@ -538,14 +499,6 @@ 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()
@@ -565,7 +518,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
title: language.t("prompt.toast.promptSendFailed.title"),
description: errorMessage(err),
})
removeOptimisticMessage()
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
})
} finally {
@@ -119,9 +119,11 @@ export function createProviderConnectionController(options: {
const finish = async () => {
cancelPolling()
const directory = options.directory()
await queryClient
.refetchQueries(serverSync.queryOptions.providers(directory ? pathKey(directory) : null))
.catch(() => undefined)
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),
])
if (polling.disposed) return
options.onComplete()
}
@@ -4,6 +4,7 @@ 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"
@@ -40,7 +41,9 @@ 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)
@@ -73,7 +76,14 @@ 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
@@ -92,7 +102,11 @@ export const SettingsProvidersV2: Component<{
return language.t("settings.providers.tag.other")
}
const canDisconnect = (item: ProviderItem) => source(item) !== "env" && !isConfigCustom(item.id)
const canDisconnect = (item: ProviderItem) => {
const current = integration(item.id)
if (current) return current.connections.some((connection) => connection.type === "credential")
return source(item) !== "env" && !isConfigCustom(item.id)
}
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
@@ -68,7 +68,8 @@
display: none;
}
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-link"] {
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-link"],
[data-titlebar-tab]:is(:hover, [data-active="true"]):not([data-editing="true"]) [data-slot="tab-link"] {
--tab-title-fade-offset: 4px;
-webkit-mask-image: linear-gradient(
to right,
@@ -86,7 +87,8 @@
);
}
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]):dir(rtl) [data-slot="tab-link"] {
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]):dir(rtl) [data-slot="tab-link"],
[data-titlebar-tab]:is(:hover, [data-active="true"]):not([data-editing="true"]):dir(rtl) [data-slot="tab-link"] {
-webkit-mask-image: linear-gradient(
to left,
black 0,
@@ -103,8 +105,7 @@
);
}
[data-titlebar-tab][data-title-overflow="true"]:is(:hover, [data-active="true"]):not([data-editing="true"])
[data-slot="tab-link"] {
[data-titlebar-tab]:is(:hover, [data-active="true"]):not([data-editing="true"]) [data-slot="tab-link"] {
--tab-title-fade-offset: 24px;
}
+10 -28
View File
@@ -1,10 +1,10 @@
import { Binary } from "@opencode-ai/core/util/binary"
import type { Message, Part } from "@/types"
import type { SessionInfo } from "@opencode-ai/client/promise"
import type { SessionInboxInfo, 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,34 +82,16 @@ export const createDirSyncContext = (
const session = serverSync.session.get(sessionID)
if (session?.location.directory === directory) return session
},
optimistic: {
add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
serverSync.session.optimistic.add(input)
inbox: {
echo(input: PromptEcho & { directory?: string }) {
serverSync.session.inbox.echo(input)
},
remove(input: { directory?: string; sessionID: string; messageID: string }) {
serverSync.session.optimistic.remove(input)
confirm(input: SessionInboxInfo) {
return serverSync.session.inbox.confirm(input)
},
clearEcho(input: { directory?: string; sessionID: string; messageID: string }) {
return serverSync.session.inbox.clearEcho(input)
},
},
addOptimisticMessage(input: {
sessionID: string
messageID: string
parts: Part[]
agent: string
model: { providerID: string; modelID: string }
variant?: string
}) {
serverSync.session.optimistic.add({
sessionID: input.sessionID,
message: {
id: input.messageID,
sessionID: input.sessionID,
role: "user",
time: { created: Date.now() },
agent: input.agent,
model: { ...input.model, variant: input.variant },
},
parts: input.parts,
})
},
async sync(sessionID: string, options?: { force?: boolean }) {
await serverSync.session.sync(sessionID, options)
+1 -1
View File
@@ -143,7 +143,7 @@ describe("encodeFilePath", () => {
})
test("should handle mixed separator path (Windows + Unix)", () => {
// This is what happens in build-request-parts.ts when concatenating paths
// This is what happens in build-prompt-request.ts when concatenating paths
const mixedPath = "D:\\dev\\projects\\opencode/README.bs.md"
const result = encodeFilePath(mixedPath)
const fileUrl = `file://${result}`
+2 -1
View File
@@ -287,7 +287,8 @@ export function createServerNotificationState(input: { sdk: ServerSDK; sync: Ser
)
return
const directory = e.name
const directory = event.current?.location?.directory
if (!directory) return
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, e.name)
void respondPending(event.properties, event.current?.location?.directory)
}
const unsubscribe = input.sdk.event.listen((event) => {
+12 -21
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
import { adaptServerEvent, coalesceServerEvents, resumeStreamAfterPageShow } from "./server-sdk"
describe("resumeStreamAfterPageShow", () => {
test("restarts a stream only after a back-forward cache restore", () => {
@@ -45,23 +45,21 @@ describe("adaptServerEvent", () => {
})
describe("current event buffering", () => {
const delta = (id: string, value: string, ordinal = 0) => ({
directory: "/repo",
payload: adaptServerEvent({
const delta = (id: string, value: string, ordinal = 0) =>
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]?.payload.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
expect(result[0]?.payload.properties).toMatchObject({ delta: "hello world" })
expect(result[0]?.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
expect(result[0]?.properties).toMatchObject({ delta: "hello world" })
})
test("coalesces current tool input deltas by tool ID", () => {
@@ -74,26 +72,19 @@ describe("current event buffering", () => {
data: { sessionID: "ses", assistantMessageID: "msg", id, delta },
} as OpenCodeEvent)
const result = coalesceServerEvents([
{ directory: "/repo", payload: current("evt_1", "call_1", "{") },
{ directory: "/repo", payload: current("evt_2", "call_1", "}") },
{ directory: "/repo", payload: current("evt_3", "call_2", "[]") },
current("evt_1", "call_1", "{"),
current("evt_2", "call_1", "}"),
current("evt_3", "call_2", "[]"),
])
expect(result).toHaveLength(2)
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: "[]" } })
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: "[]" } })
})
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.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"])
expect(coalesceServerEvents(events).map((event) => event.current?.id)).toEqual(["evt_1", "evt_2", "evt_3"])
})
})
+31 -29
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 QueuedServerEvent = { directory: string; payload: ServerEvent }
type ServerEventMap = { [Type in ServerEvent["type"]]: Extract<ServerEvent, { type: Type }> }
type CurrentDelta = Extract<
OpenCodeEvent,
{ type: "session.text.delta" | "session.reasoning.delta" | "session.tool.input.delta" | "session.compaction.delta" }
@@ -22,22 +22,17 @@ export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
}
export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServerEvent) {
queue.push(event)
return true
}
export function coalesceServerEvents(events: QueuedServerEvent[]) {
const output: QueuedServerEvent[] = []
export function coalesceServerEvents(events: ServerEvent[]) {
const output: ServerEvent[] = []
events.forEach((event) => {
const current = currentDelta(event.payload.current)
const current = currentDelta(event.current)
if (current) {
const previous = output[output.length - 1]
const prior = currentDelta(previous?.payload.current)
const prior = currentDelta(previous?.current)
if (
previous &&
prior &&
previous.directory === event.directory &&
prior.location?.directory === current.location?.directory &&
currentDeltaKey(prior) === currentDeltaKey(current)
) {
const fragment = currentDeltaFragment(prior) + currentDeltaFragment(current)
@@ -46,13 +41,10 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) {
? { ...current.data, text: fragment }
: { ...current.data, delta: fragment }
output[output.length - 1] = {
directory: event.directory,
payload: {
...event.payload,
properties: data,
current: { ...current, data } as CurrentDelta,
} as ServerEvent,
}
...event,
properties: data,
current: { ...current, data } as CurrentDelta,
} as ServerEvent
return
}
output.push(event)
@@ -89,7 +81,8 @@ export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: ()
start()
}
type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<{ [key: string]: ServerEvent }>>
type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<ServerEventMap>>
type ServerLocationEventEmitter = ReturnType<typeof createGlobalEmitter<{ [directory: string]: ServerEvent }>>
export type ServerConnectionStatus = "connecting" | "connected" | "reconnecting"
type ServerSDKBase = {
server: ServerConnection.Any
@@ -104,6 +97,9 @@ type ServerSDKBase = {
event: {
on: ServerEventEmitter["on"]
listen: ServerEventEmitter["listen"]
location: {
on: ServerLocationEventEmitter["on"]
}
}
}
@@ -123,18 +119,16 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
})()
const eventApi = createApiForServer({ server: server.http, fetch: eventFetch })
const emitter = createGlobalEmitter<{
[key: string]: ServerEvent
}>()
const emitter = createGlobalEmitter<ServerEventMap>()
const locations = createGlobalEmitter<{ [directory: 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: Queued[] = []
let buffer: Queued[] = []
let queue: ServerEvent[] = []
let buffer: ServerEvent[] = []
let timer: ReturnType<typeof setTimeout> | undefined
let last = 0
@@ -152,7 +146,11 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
last = Date.now()
const output = coalesceServerEvents(events)
batch(() => {
output.forEach((event) => emitter.emit(event.directory, event.payload))
output.forEach((event) => {
emitter.emit(event.type, event)
const directory = event.current?.location?.directory
if (directory) locations.emit(directory, event)
})
})
buffer.length = 0
@@ -165,8 +163,8 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
}
function publish(event: OpenCodeEvent) {
const directory = event.location?.directory ?? "global"
if (enqueueServerEvent(queue, { directory, payload: adaptServerEvent(event) })) schedule()
queue.push(adaptServerEvent(event))
schedule()
}
function wait(delay: number, signal: AbortSignal) {
@@ -313,6 +311,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
queue = []
buffer = []
emitter.clear()
locations.clear()
})
const api = createApiForServer({ server: server.http, fetch: platform.fetch })
@@ -330,6 +329,9 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
event: {
on: emitter.on.bind(emitter),
listen: emitter.listen.bind(emitter),
location: {
on: locations.on.bind(locations),
},
},
}
}
@@ -365,7 +367,7 @@ export type DirectorySDK = {
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): DirectorySDK {
const emitter = createGlobalEmitter<SDKEventMap>()
const unsub = serverSDK.event.on(directory, (event) => {
const unsub = serverSDK.event.location.on(directory, (event) => {
emitter.emit(event.type, event)
})
onCleanup(unsub)
@@ -6,6 +6,32 @@ const event = (input: object) => input as OpenCodeEvent
const base = { created: 1, location: { directory: "/repo" }, durable: { aggregateID: "ses_1", seq: 1, version: 1 } }
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,4 +1,10 @@
import type { OpenCodeEvent, SessionInboxItem, SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
import type {
OpenCodeEvent,
SessionInboxInfo,
SessionInboxItem,
SessionInfo,
SessionMessageInfo,
} from "@opencode-ai/client/promise"
type Assistant = Extract<SessionMessageInfo, { type: "assistant" }>
type Compaction = Extract<SessionMessageInfo, { type: "compaction" }>
@@ -29,12 +35,14 @@ 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 append({
return replace({
id: event.data.inboxID,
type: "user",
metadata: event.data.item.payload.metadata,
@@ -44,7 +52,7 @@ export function createV2SessionReducer() {
time: { created: event.created },
})
if (event.data.item.type !== "synthetic") return result([...source])
return append({
return replace({
id: event.data.inboxID,
type: "synthetic",
metadata: event.data.item.payload.metadata,
@@ -480,6 +488,9 @@ 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)
+377 -259
View File
@@ -185,6 +185,16 @@ 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) },
@@ -299,6 +309,26 @@ 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"))
@@ -340,14 +370,38 @@ 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" }],
content: [
{ type: "text", text: "world" },
{ type: "tool", id: "call_z" },
{ type: "tool", id: "call_a" },
],
})
expect(ctx.store.data.message.child?.map((message) => message.id)).toEqual(["msg_1_user", "msg_2_assistant"])
expect(ctx.store.data.part.msg_2_assistant).toMatchObject([{ type: "text", text: "world" }])
expect(ctx.store.data.part.msg_2_assistant?.map((part) => part.id)).toEqual([
"msg_2_assistant:text:0",
"call_z",
"call_a",
])
})
test("projects V2 pending inputs and forms", () => {
@@ -606,6 +660,45 @@ 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) => ({
@@ -710,19 +803,17 @@ describe("server session", () => {
expect(store.data.part[parent.id]).toBeUndefined()
})
test("does not let an optimistic user suppress initial root backfill", async () => {
test("does not let an admitted user suppress initial root backfill", async () => {
const user = userMessage("message-1")
const 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.optimistic.add({ sessionID: "child", message: user, parts: [part] })
store.inbox.echo(promptEcho(user.id, "text"))
await store.sync("child")
store.optimistic.remove({ sessionID: "child", messageID: user.id })
expect(client.requests).toHaveLength(1)
expect(client.rootRequests).toHaveLength(1)
@@ -783,28 +874,6 @@ 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")
@@ -1040,30 +1109,6 @@ 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()))
@@ -1085,170 +1130,309 @@ describe("server session", () => {
expect(store.data.message.child).toEqual([live, fetched])
})
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.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
store.apply({
type: "message.part.removed",
properties: { sessionID: "child", messageID: kept.id, partID: keptPart.id },
})
await store.sync("child", { force: true })
expect(store.data.message.child).toEqual([kept])
expect(store.data.part[message.id]).toBeUndefined()
expect(store.data.part[kept.id]).toBeUndefined()
})
test("replaces confirmed optimistic content with the initial page", async () => {
const optimistic = userMessage("message")
const fetched = { ...optimistic, time: { created: 2 } }
const store = createServerSession(messageClient(response([{ info: fetched, parts: [] }])))
store.optimistic.add({ sessionID: "child", message: optimistic, parts: [] })
await store.sync("child")
expect(store.data.message.child).toEqual([fetched])
})
test("replaces a confirmed optimistic part with fetched content", async () => {
const pending = deferredResponse()
const message = userMessage("message")
const optimistic = textPart(message.id, { text: "optimistic" })
const fetched = { ...optimistic, text: "fetched" }
const store = createServerSession(messageClient(pending.promise))
const loading = store.sync("child")
store.optimistic.add({ sessionID: "child", message, parts: [optimistic] })
pending.resolve(response([{ info: message, parts: [fetched] }]))
await loading
expect(store.data.part[message.id]).toEqual([fetched])
})
test("rolls back only unconfirmed optimistic parts", async () => {
const pending = deferredResponse()
const message = userMessage("message")
const confirmed = textPart(message.id, { id: "confirmed", text: "confirmed" })
const pendingPart = textPart(message.id, { id: "pending", text: "pending" })
const store = createServerSession(messageClient(pending.promise))
const loading = store.sync("child")
store.optimistic.add({ sessionID: "child", message, parts: [confirmed, pendingPart] })
pending.resolve(response([{ info: message, parts: [confirmed] }]))
await loading
store.optimistic.remove({ sessionID: "child", messageID: message.id })
expect(store.data.message.child).toEqual([message])
expect(store.data.part[message.id]).toEqual([confirmed])
})
test("updates confirmed optimistic parts from later pages", async () => {
const message = userMessage("message")
const confirmed = textPart(message.id, { id: "confirmed", text: "first" })
const updated = { ...confirmed, text: "updated" }
const pendingPart = textPart(message.id, { id: "pending", text: "pending" })
const store = createServerSession(
messageClient(response([{ info: message, parts: [confirmed] }]), response([{ info: message, parts: [updated] }])),
)
store.optimistic.add({ sessionID: "child", message, parts: [confirmed, pendingPart] })
await store.sync("child")
await store.sync("child", { force: true })
store.optimistic.remove({ sessionID: "child", messageID: message.id })
expect(store.data.part[message.id]).toEqual([updated])
})
test("does not restore a confirmed optimistic part after its removal event", async () => {
const message = userMessage("message")
const confirmed = textPart(message.id, { id: "confirmed", text: "confirmed" })
const pendingPart = textPart(message.id, { id: "pending", text: "pending" })
const store = createServerSession(
messageClient(response([{ info: message, parts: [confirmed] }]), response([{ info: message, parts: [] }])),
)
store.optimistic.add({ sessionID: "child", message, parts: [confirmed, pendingPart] })
await store.sync("child")
store.apply({
type: "message.part.removed",
properties: { sessionID: "child", messageID: message.id, partID: confirmed.id },
})
await store.sync("child", { force: true })
expect(store.data.part[message.id]).toEqual([pendingPart])
})
test("clears delta buffers when removing optimistic content", () => {
const message = userMessage("message")
const part = textPart(message.id, { text: "optimistic" })
test("echoes a prompt without changing durable message order", () => {
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" },
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.optimistic.remove({ sessionID: "child", messageID: message.id })
expect(store.data.pending.child).toMatchObject([{ id: "msg_prompt", type: "user", delivery: "steer" }])
expect(store.data.input.child).toEqual(["msg_prompt"])
expect(store.data.session_message.child).toBeUndefined()
expect(store.data.message.child?.map((message) => message.id)).toEqual(["msg_prompt"])
expect(store.data.part.msg_prompt).toMatchObject([
{ id: "msg_prompt:text:0", type: "text", text: "hello" },
{ id: "msg_prompt:file:0", type: "file", filename: "foo.ts" },
{ id: "msg_prompt:agent:0", type: "agent", name: "explore" },
{
id: "msg_prompt:comment:0",
type: "text",
synthetic: true,
metadata: {
opencodeComment: {
path: "src/foo.ts",
selection: { startLine: 4, startChar: 1, endLine: 4, endChar: 5 },
comment: "check this",
preview: "const value = 1",
origin: "review",
},
},
},
])
expect(store.data.part[message.id]).toBeUndefined()
expect(store.data.part_text_accum_delta[part.id]).toBeUndefined()
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 },
])
})
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: [] })
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" },
})
store.optimistic.remove({ sessionID: "child", messageID: message.id })
await store.sync("child")
expect(store.data.message.child?.map((message) => message.id)).toEqual(["msg_prompt"])
expect(store.data.part.msg_prompt).toMatchObject([{ type: "text", text: "hello" }])
})
test("preserves local comment presentation through message refresh", async () => {
const note = "The user made the following comment regarding line 4 of src/foo.ts: check this"
const message = userMessage("msg_prompt")
const store = createServerSession(
messageClient(response([{ info: message, parts: [textPart(message.id, { text: note })] }])),
)
store.inbox.echo({
...promptEcho(message.id),
text: `hello\n${note}`,
comments: [
{
path: "src/foo.ts",
selection: { startLine: 4, startChar: 1, endLine: 4, endChar: 5 },
comment: "check this",
origin: "review",
},
],
})
await store.sync("child")
expect(store.data.part.msg_prompt).toMatchObject([
{ id: "msg_prompt:text:0", type: "text", text: "hello" },
{ id: "msg_prompt:comment:0", type: "text", synthetic: true },
])
})
test("retires an admitted echo absent from authoritative reconnect state", async () => {
const store = createServerSession(messageClient(response()))
store.inbox.echo(promptEcho("msg_prompt"))
store.inbox.confirm({
id: "msg_prompt",
sessionID: "child",
timeCreated: 1,
type: "user",
delivery: "steer",
payload: { text: "hello" },
})
await Promise.all([store.sync("child"), store.hydrateTransient("child", async () => ({ pending: [], forms: [] }))])
store.inbox.reconcile("child")
expect(store.data.pending.child).toEqual([])
expect(store.data.message.child).toEqual([])
expect(store.data.part.msg_prompt).toBeUndefined()
})
test("retires a stale enqueued message when inbox hydration finishes after history", async () => {
const store = createServerSession(messageClient(response()))
store.applyV2({
id: "evt_prompt",
created: 1,
type: "session.inbox.enqueued",
durable: { aggregateID: "child", seq: 1, version: 1 },
data: {
sessionID: "child",
inboxID: "msg_prompt",
item: { type: "user", delivery: "steer", payload: { text: "hello" } },
},
} as OpenCodeEvent)
await store.sync("child")
await store.hydrateTransient("child", async () => ({ pending: [], forms: [] }))
store.inbox.reconcile("child")
expect(store.data.pending.child).toEqual([])
expect(store.data.session_message.child).toEqual([])
expect(store.data.message.child).toEqual([])
expect(store.data.part.msg_prompt).toBeUndefined()
})
test("does not remove content confirmed by a message event", () => {
const message = userMessage("message")
const part = textPart(message.id)
test("deduplicates the durable admission event against its local echo", () => {
const store = setup({ child: session("child") }).store
store.optimistic.add({ sessionID: "child", message, parts: [part] })
store.apply({ type: "message.updated", properties: { sessionID: "child", info: message } })
store.inbox.echo(promptEcho("msg_prompt"))
store.optimistic.remove({ sessionID: "child", messageID: message.id })
store.applyV2({
id: "evt_prompt",
created: 2,
type: "session.inbox.enqueued",
durable: { aggregateID: "child", seq: 1, version: 1 },
data: {
sessionID: "child",
inboxID: "msg_prompt",
item: { type: "user", delivery: "steer", payload: { text: "hello" } },
},
} as OpenCodeEvent)
expect(store.data.message.child).toEqual([message])
expect(store.data.part[message.id]).toBeUndefined()
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" }])
})
test("does not remove parts confirmed by part events", () => {
const message = userMessage("message")
const part = textPart(message.id)
test("uses the prompt response when the admission event was missed", () => {
const store = setup({ child: session("child") }).store
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.inbox.echo(promptEcho("msg_prompt"))
store.inbox.confirm({
id: "msg_prompt",
sessionID: "child",
timeCreated: 2,
type: "user",
delivery: "steer",
payload: { text: "hello" },
})
store.optimistic.remove({ sessionID: "child", messageID: message.id })
store.applyV2({
id: "evt_delivered",
created: Date.now() + 1,
type: "session.inbox.delivered",
durable: { aggregateID: "child", seq: 2, version: 1 },
data: { sessionID: "child", inboxID: "msg_prompt" },
} as OpenCodeEvent)
expect(store.data.message.child).toEqual([message])
expect(store.data.part[message.id]).toEqual([part])
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" }])
})
test("treats a part event as confirmation when it precedes the message event", () => {
const message = userMessage("message")
const part = textPart(message.id)
test("keeps a durable admission when the HTTP request later fails", () => {
const store = setup({ child: session("child") }).store
store.optimistic.add({ sessionID: "child", message, parts: [part] })
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } })
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.remove({ sessionID: "child", messageID: message.id })
expect(store.inbox.clearEcho({ sessionID: "child", messageID: "msg_prompt" })).toBe(false)
expect(store.data.pending.child).toHaveLength(1)
expect(store.data.message.child?.map((message) => message.id)).toEqual(["msg_prompt"])
})
expect(store.data.message.child).toEqual([message])
expect(store.data.part[message.id]).toEqual([part])
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()
})
test("clears stale parts when the initial page has none", async () => {
@@ -1469,28 +1653,6 @@ 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>()
@@ -1654,33 +1816,6 @@ 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 } })
@@ -1906,24 +2041,7 @@ describe("server session", () => {
test("preserves pinned session content under server-wide cache pressure", () => {
const ctx = setup({})
ctx.store.pin("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: [],
})
ctx.store.inbox.echo({ ...promptEcho("message", "keep"), sessionID: "active" })
for (let index = 0; index < 50; index++) {
ctx.store.remember(session(`session-${index}`))
+256 -279
View File
@@ -18,6 +18,13 @@ 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"]
@@ -28,31 +35,6 @@ 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) =>
@@ -64,13 +46,6 @@ 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[] }[]
@@ -81,6 +56,18 @@ 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>
@@ -90,7 +77,6 @@ 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>
@@ -101,34 +87,6 @@ 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
@@ -203,6 +161,7 @@ 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) {
@@ -212,7 +171,6 @@ 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>()
@@ -223,7 +181,45 @@ 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,
@@ -253,18 +249,6 @@ 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)
@@ -276,7 +260,7 @@ export function createServerSession(
...inflight.keys(),
...inflightTodo.keys(),
...messageLoads.keys(),
...optimistic.keys(),
...echoes.keys(),
...Object.entries(data.permission)
.filter(([, items]) => items.length > 0)
.map(([sessionID]) => sessionID),
@@ -352,65 +336,6 @@ 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
@@ -448,14 +373,6 @@ 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))
@@ -486,7 +403,9 @@ export function createServerSession(
sessionIDs.forEach((sessionID) => {
messageHydrationRevision.set(sessionID, (messageHydrationRevision.get(sessionID) ?? 0) + 1)
generations.delete(sessionID)
clearOptimistic(sessionID)
echoes.delete(sessionID)
messageSnapshots.delete(sessionID)
settledInputs.delete(sessionID)
requests.delete(sessionID)
inflight.delete(sessionID)
inflightTodo.delete(sessionID)
@@ -521,7 +440,7 @@ export function createServerSession(
...inflight.keys(),
...inflightTodo.keys(),
...messageLoads.keys(),
...optimistic.keys(),
...echoes.keys(),
...Object.entries(data.permission)
.filter(([, items]) => items.length > 0)
.map(([sessionID]) => sessionID),
@@ -556,9 +475,7 @@ export function createServerSession(
const normalized = normalizeSessionMessages(sessionID, source)
return {
session: normalized.messages.sort(compareMessages),
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)),
part: [...normalized.parts.entries()].map(([id, part]) => ({ id, part })).sort((a, b) => cmp(a.id, b.id)),
source,
sourceMode: before ? ("older" as const) : ("latest" as const),
projectSource: true,
@@ -598,9 +515,10 @@ export function createServerSession(
) => {
for (const item of items) {
if (!messageIDs.has(item.id)) continue
const fetched = load?.clearedMessageParts.has(item.id)
? []
: item.part.filter((part) => !SKIP_PARTS.has(part.type))
const fetched = present(
item.id,
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 ?? [])])
@@ -651,47 +569,56 @@ 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 (page.sourceMode === "older" ? [...page.source, ...current] : [...current, ...page.source]).map(
(message) => (load?.touchedSource.has(message.id) ? (live.get(message.id) ?? message) : message),
return combined.map((message) =>
load?.touchedSource.has(message.id) ? (live.get(message.id) ?? message) : message,
)
})()
: undefined
const projected =
const merged =
page.projectSource && source
? (() => {
const normalized = normalizeSessionMessages(sessionID, source)
return {
...page,
session: normalized.messages.sort(compareMessages),
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)),
part: [...normalized.parts.entries()].map(([id, part]) => ({ id, part })).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,
preserveUnfetched: (message) =>
echoes.get(sessionID)?.has(message.id) === true ||
preserveUnfetched === true ||
(typeof preserveUnfetched === "function" && preserveUnfetched(message)),
compare: compareMessages,
})
batch(() => {
@@ -723,7 +650,6 @@ export function createServerSession(
deltaParts: new Map(),
carriedDeltaParts: new Map(),
removedParts: new Map(),
optimisticParts: new Map(),
orphanParents: new Set(),
clearedMessageParts: new Set(),
touchedSource: new Set(),
@@ -744,11 +670,7 @@ export function createServerSession(
const users = new Set([
...page.session.filter((message) => message.role === "user").map((message) => message.id),
...(data.message[sessionID] ?? [])
.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)
})
.filter((message) => message.role === "user" && load.touchedMessages.has(message.id))
.map((message) => message.id),
])
const parentIDs = [
@@ -893,12 +815,12 @@ export function createServerSession(
apply({ type: "message.updated", properties: { sessionID: reduction.sessionID, info: message } })
}
for (const messageID of touched) {
const next = normalized.parts.get(messageID) ?? []
const next = present(messageID, normalized.parts.get(messageID) ?? [])
const nextIDs = new Set(next.map((part) => part.id))
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",
@@ -926,6 +848,67 @@ 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)
@@ -949,6 +932,9 @@ 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" ||
@@ -960,11 +946,10 @@ export function createServerSession(
pendingRevision.set(sessionID, (pendingRevision.get(sessionID) ?? 0) + 1)
if (event.type === "session.inbox.enqueued") {
const current = data.pending[sessionID] ?? []
if (!current.some((item) => item.id === event.data.inboxID))
setData("pending", sessionID, [
...current,
{ id: event.data.inboxID, sessionID, timeCreated: event.created, ...event.data.item },
])
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 (event.data.item.type !== "compaction" && !data.input[sessionID]?.includes(event.data.inboxID))
setData("input", sessionID, [...(data.input[sessionID] ?? []), event.data.inboxID])
}
@@ -1101,16 +1086,9 @@ 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)
@@ -1123,13 +1101,18 @@ export function createServerSession(
return
}
const result = Binary.search(messages, messageKey(info), messageKey)
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
})
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
})
return
}
case "message.removed": {
@@ -1144,13 +1127,11 @@ 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]
@@ -1196,12 +1177,8 @@ 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]),
@@ -1211,14 +1188,9 @@ export function createServerSession(
setData("part", part.messageID, [part])
return
}
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
})
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])
return
}
case "message.part.removed": {
@@ -1240,20 +1212,16 @@ 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 result = Binary.search(parts, props.partID, (part) => part.id)
if (result.found) parts.splice(result.index, 1)
const index = parts.findIndex((part) => part.id === props.partID)
if (index >= 0) parts.splice(index, 1)
if (parts.length === 0) delete draft.part[props.messageID]
}),
)
@@ -1269,8 +1237,8 @@ export function createServerSession(
}
const parts = data.part[props.messageID]
if (!parts) return
const result = Binary.search(parts, props.partID, (part) => part.id)
if (!result.found) return
const index = parts.findIndex((part) => part.id === props.partID)
if (index < 0) return
trackPartChange(props.sessionID, props.messageID, props.partID)
const load = messageLoads.get(props.sessionID)
if (load) {
@@ -1282,7 +1250,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[result.index]?.[field]
const current = parts[index]?.[field]
if (!deltaBases.has(props.partID) && typeof current === "string")
deltaBases.set(props.partID, { base: current, sessionID: props.sessionID })
setData(
@@ -1295,7 +1263,7 @@ export function createServerSession(
props.messageID,
produce((draft) => {
if (!draft) return
const part = draft[result.index]
const part = draft[index]
const field = props.field as keyof typeof part
;(part[field] as string) = ((part[field] as string | undefined) ?? "") + props.delta
}),
@@ -1354,25 +1322,30 @@ 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(result.pending.filter((item) => item.type !== "compaction").map((item) => item.id)),
)
setData("input", sessionID, reconcile([...current]))
}
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(
[...pinned.keys()].flatMap((sessionID) => [sync(sessionID, { force: true }), hydrateTransient(sessionID)]),
).then(() => undefined)
sessions.flatMap((sessionID) => [sync(sessionID, { force: true }), hydrateTransient(sessionID)]),
).then(() => sessions.forEach(reconcileInbox))
},
invalidate() {
invalidationRevision += 1
@@ -1390,68 +1363,72 @@ export function createServerSession(
fresh(sessionID: string, ttl: number) {
return Date.now() - (meta.at[sessionID] ?? 0) <= ttl
},
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)
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 },
}
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)
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)
})
},
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)))
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)
},
},
async todo(sessionID: string, request?: { force?: boolean }) {
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
import type {
McpListInput,
McpResourceCatalogInput,
OpenCodeEvent,
SessionApi,
SessionInfo,
SessionListInput,
@@ -15,11 +16,13 @@ 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"]
@@ -214,6 +217,23 @@ 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", () => {
+18 -16
View File
@@ -88,6 +88,12 @@ 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>
}
@@ -231,7 +237,10 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
return { pending, forms }
})
}
const hydrateSession = (sessionID: string) => Promise.all([session.sync(sessionID), hydrateSessionState(sessionID)])
const hydrateSession = async (sessionID: string) => {
await Promise.all([session.sync(sessionID), hydrateSessionState(sessionID)])
session.inbox.reconcile(sessionID)
}
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
queries: [
@@ -551,14 +560,11 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
type: "session.updated",
properties: { sessionID: info.id, info },
})
const markSessionListChanged = (event: ServerEvent, directory: string, previousDirectory?: string) => {
if (SESSION_LIST_EVENTS.has(event.current?.type ?? event.type)) {
const markSessionListsChanged = (event: ServerEvent) => {
sessionListEventDirectories(event).forEach((directory) => {
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
@@ -569,15 +575,10 @@ 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
const previousDirectory =
event.current?.type === "session.moved"
? session.get(event.current.data.sessionID)?.location.directory
: undefined
markSessionListChanged(event, directory, previousDirectory)
markSessionListsChanged(event)
if (event.current) session.applyV2(event.current)
session.apply(event)
if (event.current?.type === "session.moved") {
@@ -629,9 +630,9 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
}
homeSessions.refresh(event.type)
catalog.handleEvent({ type: eventType, directory })
connection.handleEvent({ type: eventType, directory })
connection.handleEvent({ type: eventType })
if (directory === "global") {
if (!directory) {
applyGlobalEvent({
event,
project: globalStore.project,
@@ -644,6 +645,7 @@ 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", directory: "global" })
catalog.handleEvent({ type: "server.connected" })
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 === "global" ? null : pathKey(event.directory)).catch(() => undefined)
void refresh(event.directory ? pathKey(event.directory) : null).catch(() => undefined)
}
}
@@ -12,10 +12,8 @@ test("invalidates disconnected data and synchronizes after the handshake", () =>
connected: () => calls.push("connected"),
})
connection.handleEvent({ type: "server.connected", directory: "global" })
connection.handleEvent({ type: "server.connected" })
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; directory: string }) {
if (event.directory !== "global" || event.type !== "server.connected") return
function handleEvent(event: { type: string }) {
if (event.type !== "server.connected") return
input.connected({ reconnect: connectedOnce })
connectedOnce = true
}
@@ -1,137 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { Message, Part } from "@/types"
import { applyOptimisticAdd, applyOptimisticRemove, mergeOptimisticPage } from "./sync"
type Text = Extract<Part, { type: "text" }>
const userMessage = (id: string, sessionID: string, created = 1): Message => ({
id,
sessionID,
role: "user",
time: { created },
agent: "assistant",
model: { providerID: "openai", modelID: "gpt" },
})
const textPart = (id: string, sessionID: string, messageID: string): Text => ({
id,
sessionID,
messageID,
type: "text",
text: id,
})
describe("sync optimistic reducers", () => {
test("applyOptimisticAdd inserts by creation time", () => {
const sessionID = "ses_1"
const draft = {
message: { [sessionID]: [userMessage("msg_z", sessionID, 1)] },
part: {} as Record<string, Part[] | undefined>,
}
applyOptimisticAdd(draft, {
sessionID,
message: userMessage("msg_a", sessionID, 2),
parts: [textPart("prt_2", sessionID, "msg_a"), textPart("prt_1", sessionID, "msg_a")],
})
expect(draft.message[sessionID]?.map((x) => x.id)).toEqual(["msg_z", "msg_a"])
expect(draft.part.msg_a?.map((x) => x.id)).toEqual(["prt_1", "prt_2"])
})
test("applyOptimisticRemove removes message and part entries", () => {
const sessionID = "ses_1"
const draft = {
message: { [sessionID]: [userMessage("msg_1", sessionID), userMessage("msg_2", sessionID)] },
part: {
msg_1: [textPart("prt_1", sessionID, "msg_1")],
msg_2: [textPart("prt_2", sessionID, "msg_2")],
} as Record<string, Part[] | undefined>,
}
applyOptimisticRemove(draft, { sessionID, messageID: "msg_1" })
expect(draft.message[sessionID]?.map((x) => x.id)).toEqual(["msg_2"])
expect(draft.part.msg_1).toBeUndefined()
expect(draft.part.msg_2).toHaveLength(1)
})
test("mergeOptimisticPage keeps pending messages in fetched timelines", () => {
const sessionID = "ses_1"
const page = mergeOptimisticPage(
{
session: [userMessage("msg_z", sessionID, 1)],
part: [{ id: "msg_z", part: [textPart("prt_1", sessionID, "msg_z")] }],
complete: true,
},
[{ message: userMessage("msg_a", sessionID, 2), parts: [textPart("prt_2", sessionID, "msg_a")] }],
)
expect(page.session.map((x) => x.id)).toEqual(["msg_z", "msg_a"])
expect(page.part.find((x) => x.id === "msg_a")?.part.map((x) => x.id)).toEqual(["prt_2"])
expect(page.confirmed).toEqual([])
expect(page.complete).toBe(true)
})
test("mergeOptimisticPage uses IDs only to break equal-time ties", () => {
const sessionID = "ses_1"
const page = mergeOptimisticPage(
{
session: [userMessage("msg_z", sessionID, 1)],
part: [],
complete: true,
},
[{ message: userMessage("msg_a", sessionID, 1), parts: [] }],
)
expect(page.session.map((message) => message.id)).toEqual(["msg_a", "msg_z"])
})
test("mergeOptimisticPage keeps missing optimistic parts until the server has them", () => {
const sessionID = "ses_1"
const page = mergeOptimisticPage(
{
session: [userMessage("msg_2", sessionID)],
part: [{ id: "msg_2", part: [textPart("prt_2", sessionID, "msg_2")] }],
complete: true,
},
[
{
message: userMessage("msg_2", sessionID),
parts: [textPart("prt_1", sessionID, "msg_2"), textPart("prt_2", sessionID, "msg_2")],
},
],
)
expect(page.part.find((x) => x.id === "msg_2")?.part.map((x) => x.id)).toEqual(["prt_1", "prt_2"])
expect(page.confirmed).toEqual([])
})
test("mergeOptimisticPage confirms echoed messages once all parts arrive", () => {
const sessionID = "ses_1"
const page = mergeOptimisticPage(
{
session: [userMessage("msg_2", sessionID)],
part: [
{
id: "msg_2",
part: [{ ...textPart("prt_1", sessionID, "msg_2"), text: "server" }, textPart("prt_2", sessionID, "msg_2")],
},
],
complete: true,
},
[
{
message: userMessage("msg_2", sessionID),
parts: [textPart("prt_1", sessionID, "msg_2"), textPart("prt_2", sessionID, "msg_2")],
},
],
)
expect(page.confirmed).toEqual(["msg_2"])
expect(page.part.find((x) => x.id === "msg_2")?.part).toMatchObject([
{ id: "prt_1", type: "text", text: "server" },
{ id: "prt_2", type: "text", text: "prt_2" },
])
})
})
-108
View File
@@ -1,114 +1,6 @@
import { Binary } from "@opencode-ai/core/util/binary"
import { createMemo } from "solid-js"
import { 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()
+16 -5
View File
@@ -2,6 +2,7 @@ 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"
@@ -23,6 +24,7 @@ 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)
@@ -30,13 +32,22 @@ export function useProviders(directory: Accessor<string | undefined>) {
ready: () => providersQuery.isSuccess,
all: () => providers().all,
default: () => providers().default,
popular: () =>
pipe(
// 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(
providers().all,
Iterable.map(([, p]) => p),
Iterable.filter((p) => popularProviderSet.has(p.id)),
(v) => Array.from(v),
),
Iterable.filter((p) => popularProviderSet.has(p.id) && !seen.has(p.id)),
Iterable.map((p) => ({ id: p.id, name: p.name })),
(v) => [...catalog, ...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; percent?: number }
| { status: "downloading"; version: string }
| { status: "ready"; version: string }
| { status: "up-to-date" }
| { status: "installing"; version: string }
+4 -1
View File
@@ -22,7 +22,10 @@ function blobUrl(id: string, blob: Blob) {
}
async function blobID(blob: Blob) {
const id = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", await blob.arrayBuffer())))
const bytes = crypto.subtle
? new Uint8Array(await crypto.subtle.digest("SHA-256", await blob.arrayBuffer()))
: crypto.getRandomValues(new Uint8Array(16))
const id = Array.from(bytes)
.map((byte) => byte.toString(16).padStart(2, "0"))
.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 { LocationMutation } from "../../location-mutation.js"
import type { LocationPath } from "../../location-path.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 =
| LocationMutation.ExternalDirectoryAuthorization["action"]
| LocationPath.ExternalDirectoryAuthorization["action"]
| typeof ReadTool.name
| typeof EditTool.name
const pathActions = ["external_directory", "read", "edit"] as const satisfies readonly PathAction[]
+36 -20
View File
@@ -23,6 +23,32 @@ export type Options = typeof Options.Type
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem/Search") {}
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,
@@ -32,12 +58,13 @@ export const ripgrepLayer = Layer.effect(
const scope = yield* Scope.Scope
const clock = yield* Clock.Clock
const home = Protected.isHome(location.directory)
let index = { files: [] as string[], directories: new Set<string>() }
let index = emptyIndex()
let initialized = false
let settledAt = Number.NEGATIVE_INFINITY
let refreshing = false
const scan = Effect.gen(function* () {
const next = { files: [] as string[], directories: new Set<string>() }
const next = emptyIndex()
const previous = index
if (!initialized) index = next
yield* ripgrep.find({
cwd: location.directory,
@@ -46,11 +73,13 @@ export const ripgrepLayer = Layer.effect(
exclude: home ? [...Protected.names()].map((name) => `${name}/**`) : undefined,
onEntry: (entry) =>
Effect.sync(() => {
next.files.push(entry.path)
next.files.set(entry.path, previous.files.get(entry.path) ?? fuzzysort.prepare(entry.path))
const parts = entry.path.split("/")
parts
.slice(0, -1)
.forEach((_, offset) => next.directories.add(parts.slice(0, offset + 1).join("/") + path.sep))
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))
})
}),
})
index = next
@@ -74,20 +103,7 @@ export const ripgrepLayer = Layer.effect(
find: (input) =>
Effect.gen(function* () {
yield* refresh
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,
})
})
return search(index, input)
}),
})
}),
@@ -1,4 +1,4 @@
export * as LocationMutation from "./location-mutation.js"
export * as LocationPath from "./location-path.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
/**
* Mutation paths do not accept project references. Relative paths resolve
* from the active Location. Paths outside it require separate
* Tool paths do not accept project references. Relative paths resolve from
* the active Location. Paths outside its project 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 it require separate `external_directory`
* approval. This does not approve the mutation.
* from the Location. Paths outside its project require separate
* `external_directory` approval. This does not approve access.
*/
readonly resolve: (input: ResolveInput) => Effect.Effect<Target, FSUtil.Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/LocationMutation") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/LocationPath") {}
const slash = (value: string) => value.replaceAll("\\", "/")
@@ -65,9 +65,13 @@ const layer = Layer.effect(
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) {
const resolve = Effect.fn("LocationPath.resolve")(function* (input: ResolveInput) {
const absolute = path.resolve(location.directory, input.path)
if (FSUtil.contains(location.directory, absolute)) {
const projectRoot = path.parse(location.project.directory).root
if (
FSUtil.contains(location.directory, absolute) ||
(location.project.directory !== projectRoot && FSUtil.contains(location.project.directory, absolute))
) {
return {
absolute,
resource: slash(path.relative(location.directory, absolute) || "."),
+11 -7
View File
@@ -1,4 +1,5 @@
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"
@@ -18,7 +19,7 @@ import { Image } from "./image.js"
import { LocationWatcher } from "./filesystem/location-watcher.js"
import { Integration } from "./integration.js"
import { Location } from "./location.js"
import { LocationMutation } from "./location-mutation.js"
import { LocationPath } from "./location-path.js"
import { LocationServiceMap } from "./location-service-map.js"
import { ModelResolver } from "./model-resolver.js"
import { MCP } from "./mcp/index.js"
@@ -49,6 +50,7 @@ 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"
@@ -74,7 +76,7 @@ const locationServiceNodes = [
Skill.node,
InstructionBuiltIns.node,
InstructionDiscovery.node,
LocationMutation.node,
LocationPath.node,
FileMutation.node,
Formatter.node,
MCP.node,
@@ -110,11 +112,13 @@ export type LocationError = LayerNode.Error<typeof locationServices>
export function buildLocationServiceMap(
replacements: LayerNode.Replacements = [],
): Layer.Layer<LocationServiceMap.Service> {
// 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 })
// 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,
})
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 { LocationMutation } from "../location-mutation.js"
import { LocationPath } from "../location-path.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* LocationMutation.Service
const locationMutation = yield* LocationPath.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(LocationMutation.Service, locationMutation),
Context.make(LocationPath.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,
LocationMutation.node,
LocationPath.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 { LocationMutation } from "../../location-mutation.js"
import { LocationPath } from "../../location-path.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* LocationMutation.Service
const mutation = yield* LocationPath.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({
...LocationMutation.externalDirectoryPermission(external),
...LocationPath.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 { LocationMutation } from "../../location-mutation.js"
import { LocationPath } from "../../location-path.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* LocationMutation.Service
const mutation = yield* LocationPath.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({
...LocationMutation.externalDirectoryPermission(external),
...LocationPath.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 { LocationMutation } from "../../location-mutation.js"
import { LocationPath } from "../../location-path.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* LocationMutation.Service
const mutation = yield* LocationPath.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({
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
...LocationPath.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 { LocationMutation } from "../../location-mutation.js"
import { LocationPath } from "../../location-path.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: LocationMutation.Target
readonly target: LocationPath.Target
readonly content: string
readonly before: string
readonly after: string
})
| (Extract<Patch.Hunk, { readonly type: "delete" }> & {
readonly target: LocationMutation.Target
readonly target: LocationPath.Target
readonly before: string
readonly after: string
})
| (Extract<Patch.Hunk, { readonly type: "update" }> & {
readonly target: LocationMutation.Target
readonly target: LocationPath.Target
readonly content: string
readonly before: string
readonly after: string
readonly moveTarget?: LocationMutation.Target
readonly moveTarget?: LocationPath.Target
})
export const Plugin = {
id: "opencode.tool.patch",
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
const environment = yield* Environment.Service
const mutation = yield* LocationMutation.Service
const mutation = yield* LocationPath.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({
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
...LocationPath.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 { LocationMutation } from "../../location-mutation.js"
import { LocationPath } from "../../location-path.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* LocationMutation.Service
const mutation = yield* LocationPath.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({
...LocationMutation.externalDirectoryPermission(external),
...LocationPath.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 { LocationMutation } from "../../location-mutation.js"
import { LocationPath } from "../../location-path.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* LocationMutation.Service
const mutation = yield* LocationPath.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 { LocationMutation } from "../../location-mutation.js"
import { LocationPath } from "../../location-path.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* LocationMutation.Service
const mutation = yield* LocationPath.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({
...LocationMutation.externalDirectoryPermission(external),
...LocationPath.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 { LocationMutation } from "@opencode-ai/core/location-mutation"
import { LocationPath } from "@opencode-ai/core/location-path"
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([LocationMutation.node, FileMutation.node]), [
AppNodeBuilder.build(LayerNode.group([LocationPath.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* LocationMutation.Service).resolve({ path: "hello.txt" })
const target = yield* (yield* LocationPath.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* LocationMutation.Service).resolve({
const target = yield* (yield* LocationPath.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* LocationMutation.Service).resolve({ path: "preserved.txt" })
const created = yield* (yield* LocationMutation.Service).resolve({ path: "created.txt" })
const preserved = yield* (yield* LocationPath.Service).resolve({ path: "preserved.txt" })
const created = yield* (yield* LocationPath.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* LocationMutation.Service).resolve({ path: targetPath })
const target = yield* (yield* LocationPath.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* LocationMutation.Service
const mutation = yield* LocationPath.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* LocationMutation.Service
const mutation = yield* LocationPath.Service
const files = yield* FileMutation.Service
const firstPlan = yield* mutation.resolve({ path: "first.txt" })
const secondPlan = yield* mutation.resolve({ path: "second.txt" })
+56 -1
View File
@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test"
import { describe, expect, spyOn, test } from "bun:test"
import fuzzysort from "fuzzysort"
import os from "os"
import path from "path"
import { Deferred, Effect, Layer } from "effect"
@@ -123,4 +124,58 @@ 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()
})
})
+7 -3
View File
@@ -510,7 +510,7 @@ describe("LocationServiceMap", () => {
),
)
it.live("normalizes ref key shapes to one cached location graph", () =>
it.live("normalizes equivalent refs to one cached location graph", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
@@ -520,16 +520,20 @@ describe("LocationServiceMap", () => {
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const directory = AbsolutePath.make(dir.path)
const absent = Location.Ref.make({ directory })
const alternate = AbsolutePath.make(directory.replaceAll("\\", "/"))
const absent = Location.Ref.make({ directory: alternate })
const present = Location.Ref.make({ directory, workspaceID: undefined })
// 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))).toHaveLength(1)
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([
Location.Ref.make({ directory, workspaceID: undefined }),
])
// Invalidating with the shape opposite to the one that booted must evict.
yield* locations.invalidate(present)
@@ -4,18 +4,26 @@ 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 { LocationMutation } from "@opencode-ai/core/location-mutation"
import { LocationPath } from "@opencode-ai/core/location-path"
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) {
function provide(directory: string, projectDirectory = directory) {
return Effect.provide(
LayerNode.compile(LocationMutation.node, [
LayerNode.compile(LocationPath.node, [
[
Location.node,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
{ projectDirectory: AbsolutePath.make(projectDirectory) },
),
),
),
],
]),
)
@@ -28,13 +36,13 @@ function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
}
describe("LocationMutation", () => {
describe("LocationPath", () => {
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* LocationMutation.Service).resolve({ path: "hello.txt" })
const target = yield* (yield* LocationPath.Service).resolve({ path: "hello.txt" })
expect(target).toMatchObject({
absolute: targetPath,
@@ -49,7 +57,7 @@ describe("LocationMutation", () => {
withTmp((directory) =>
Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("src", "new.txt") })
const target = yield* (yield* LocationPath.Service).resolve({ path: path.join("src", "new.txt") })
expect(target).toMatchObject({
absolute: path.join(directory, "src", "new.txt"),
resource: "src/new.txt",
@@ -58,10 +66,43 @@ describe("LocationMutation", () => {
),
)
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* LocationMutation.Service).resolve({ path: "../outside.txt" })
const target = yield* (yield* LocationPath.Service).resolve({ path: "../outside.txt" })
const root = path.dirname(directory)
expect(target).toMatchObject({
absolute: path.join(root, "outside.txt"),
@@ -84,7 +125,7 @@ describe("LocationMutation", () => {
await fs.mkdir(outside)
await fs.symlink(outside, path.join(directory, "escape"))
})
const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("escape", "new.txt") })
const target = yield* (yield* LocationPath.Service).resolve({ path: path.join("escape", "new.txt") })
expect(target).toMatchObject({
absolute: path.join(directory, "escape", "new.txt"),
resource: "escape/new.txt",
@@ -104,7 +145,7 @@ describe("LocationMutation", () => {
await fs.symlink(path.join(directory, "actual"), path.join(directory, "linked"))
})
expect(yield* (yield* LocationMutation.Service).resolve({ path: "linked/new.txt" })).toMatchObject({
expect(yield* (yield* LocationPath.Service).resolve({ path: "linked/new.txt" })).toMatchObject({
absolute: path.join(directory, "linked", "new.txt"),
resource: "linked/new.txt",
})
@@ -116,7 +157,7 @@ describe("LocationMutation", () => {
withTmp((directory) =>
Effect.gen(function* () {
const targetPath = path.join(directory, "new.txt")
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
const target = yield* (yield* LocationPath.Service).resolve({ path: targetPath })
expect(target).toMatchObject({
absolute: targetPath,
resource: "new.txt",
@@ -131,7 +172,7 @@ describe("LocationMutation", () => {
withTmp((outside) =>
Effect.gen(function* () {
const targetPath = path.join(outside, "new.txt")
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
const target = yield* (yield* LocationPath.Service).resolve({ path: targetPath })
const root = outside
expect(target).toMatchObject({
absolute: path.join(root, "new.txt"),
@@ -152,7 +193,7 @@ describe("LocationMutation", () => {
Effect.gen(function* () {
const targetPath = path.join(outside, "existing.txt")
yield* Effect.promise(() => fs.writeFile(targetPath, "existing"))
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
const target = yield* (yield* LocationPath.Service).resolve({ path: targetPath })
expect(target).toMatchObject({ absolute: targetPath })
expect(target.externalDirectory?.directory).toBe(outside)
}).pipe(provide(directory)),
@@ -164,7 +205,7 @@ describe("LocationMutation", () => {
withTmp((directory) =>
withTmp((outside) =>
Effect.gen(function* () {
const target = yield* (yield* LocationMutation.Service).resolve({ path: outside, kind: "file" })
const target = yield* (yield* LocationPath.Service).resolve({ path: outside, kind: "file" })
expect(target.externalDirectory).toMatchObject({
directory: path.dirname(outside),
resource: path.join(path.dirname(outside), "*").replaceAll("\\", "/"),
@@ -179,7 +220,7 @@ describe("LocationMutation", () => {
withTmp((outside) =>
Effect.gen(function* () {
const targetPath = path.join(outside, "new", "nested", "file.txt")
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
const target = yield* (yield* LocationPath.Service).resolve({ path: targetPath })
const parent = path.dirname(targetPath)
expect(target.externalDirectory).toMatchObject({
directory: parent,
@@ -190,9 +231,9 @@ describe("LocationMutation", () => {
),
)
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({
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({
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 { LocationMutation } from "@opencode-ai/core/location-mutation"
import { LocationPath } from "@opencode-ai/core/location-path"
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,
LocationMutation.node,
LocationPath.node,
Image.node,
Permission.node,
SessionInstructions.node,
@@ -65,7 +65,7 @@ const testLayer = AppNodeBuilder.build(
Session.node,
Location.node,
FSUtil.node,
LocationMutation.node,
LocationPath.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 { LocationMutation } from "@opencode-ai/core/location-mutation"
import { LocationPath } from "@opencode-ai/core/location-path"
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,
LocationMutation.node,
LocationPath.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, LocationMutation.node, FileMutation.node, editToolNode]),
LayerNode.group([Tool.node, Tool.node, LocationPath.node, FileMutation.node, editToolNode]),
[
[
Environment.node,
+6 -7
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 { LocationMutation } from "@opencode-ai/core/location-mutation"
import { LocationPath } from "@opencode-ai/core/location-path"
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,
LocationMutation.node,
LocationPath.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, LocationMutation.node, FileMutation.node, patchToolNode]), [
AppNodeBuilder.build(LayerNode.group([Tool.node, LocationPath.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 external to the Location", () =>
it.live("treats a sibling path inside the project worktree as internal", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
@@ -939,9 +939,8 @@ 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(["external_directory", "edit"])
expect(assertions[0]?.resources).toEqual([path.join(tmp.path, "*").replaceAll("\\", "/")])
expect(assertions[1]?.resources).toEqual([target.replaceAll("\\", "/")])
expect(assertions.map((input) => input.action)).toEqual(["edit"])
expect(assertions[0]?.resources).toEqual(["../sibling.txt"])
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 { LocationMutation } from "@opencode-ai/core/location-mutation"
import { LocationPath } from "@opencode-ai/core/location-path"
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,
LocationMutation.node,
LocationPath.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(
LocationMutation.Service,
LocationMutation.Service.of({
LocationPath.Service,
LocationPath.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],
[LocationMutation.node, mutation],
[LocationPath.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 { LocationMutation } from "@opencode-ai/core/location-mutation"
import { LocationPath } from "@opencode-ai/core/location-path"
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, LocationMutation.node, Permission.node],
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationPath.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, LocationMutation.node, Permission.node],
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationPath.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 { LocationMutation } from "@opencode-ai/core/location-mutation"
import { LocationPath } from "@opencode-ai/core/location-path"
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,
LocationMutation.node,
LocationPath.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 { LocationMutation } from "@opencode-ai/core/location-mutation"
import { LocationPath } from "@opencode-ai/core/location-path"
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, LocationMutation.node, FileMutation.node, Environment.node, Formatter.node, Permission.node],
deps: [Tool.node, LocationPath.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, LocationMutation.node, FileMutation.node, writeToolNode]),
LayerNode.group([Tool.node, Tool.node, LocationPath.node, FileMutation.node, writeToolNode]),
[
[
Environment.node,
+3 -4
View File
@@ -24,8 +24,7 @@ import {
isOldLayoutEligible,
} from "./onboarding"
import { getDefaultServerUrl, preferAppEnv, setDefaultServerUrl } from "./server"
import { setupAutoUpdater, showUpdaterDialog } from "./updater"
import { registerUpdaterIpc } from "./updater-ipc"
import { registerUpdaterIpc, setupAutoUpdater, showUpdaterDialog } from "./updater"
import { safeWebContentsURL } from "./window-state"
import {
getLastFocusedWindow,
@@ -242,7 +241,7 @@ const main = Effect.gen(function* () {
const win = getLastFocusedWindow()
if (win) sendMenuCommand(win, id)
},
checkForUpdates: () => void showUpdaterDialog(updater, true),
checkForUpdates: () => void showUpdaterDialog(updater),
relaunch,
}
registerIpcHandlers({
@@ -267,7 +266,7 @@ const main = Effect.gen(function* () {
setDisplayBackend: async () => undefined,
checkAppExists: (appName) => checkAppExists(appName),
resolveAppPath: async (appName) => resolveAppPath(appName),
showUpdater: () => showUpdaterDialog(updater, true),
showUpdater: () => showUpdaterDialog(updater),
setBackgroundColor: (color) => setBackgroundColor(color),
exportDebugLogs: () => exportDebugLogs(),
recordFatalRendererError: (error) => writeLog("renderer", "fatal renderer error", { ...error }, "error"),
@@ -1,26 +1,35 @@
import { describe, expect, test } from "bun:test"
import { createUpdaterController, type UpdaterPlatform, type UpdaterReadyRecord } from "./updater-controller"
import { createUpdaterController, type UpdaterReadyRecord } from "./updater-controller"
function setup(input?: { currentVersion?: string; ready?: UpdaterReadyRecord }) {
// Drives the controller the way the app does: start or check, observe the states
// the renderer sees, then install like a button click. `calls` records the platform
// operations in order; installs record the staged version they would apply.
function setup(input?: {
currentVersion?: string
ready?: UpdaterReadyRecord
latest?: () => string
stage?: () => Promise<void>
install?: () => Promise<never>
}) {
const calls: string[] = []
const platform: UpdaterPlatform = {
async checkForUpdate() {
calls.push("check")
return "2.0.0"
},
async stageUpdate() {
calls.push("download")
},
installAndRestart() {
calls.push("install")
return new Promise<never>(() => {})
},
}
const states: string[] = []
let ready = input?.ready
const controller = createUpdaterController({
enabled: true,
currentVersion: input?.currentVersion ?? "1.0.0",
platform,
platform: {
async checkForUpdate() {
calls.push("check")
return input?.latest?.() ?? "2.0.0"
},
async stageUpdate() {
calls.push("download")
await input?.stage?.()
},
installAndRestart() {
calls.push(`install:${ready?.version}`)
return input?.install?.() ?? new Promise<never>(() => {})
},
},
lifecycle: {
async prepareToRestart() {
calls.push("prepare")
@@ -36,21 +45,29 @@ function setup(input?: { currentVersion?: string; ready?: UpdaterReadyRecord })
},
},
})
return { controller, calls, getReady: () => ready }
controller.subscribe((state) => states.push(state.status))
return { controller, calls, states, getReady: () => ready }
}
describe("updater controller", () => {
test("checks, downloads, persists, and publishes one authoritative ready state", async () => {
test("stages an update found at launch and shows it as ready", async () => {
const app = setup()
const states: ReturnType<typeof app.controller.getState>[] = []
app.controller.subscribe((state) => states.push(state))
await app.controller.start()
expect(app.calls).toEqual(["check", "download"])
expect(app.getReady()).toEqual({ version: "2.0.0" })
expect(states.map((state) => state.status)).toEqual(["idle", "checking", "downloading", "ready"])
expect(app.states).toEqual(["idle", "checking", "downloading", "ready"])
expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
expect(app.getReady()).toEqual({ version: "2.0.0" })
})
test("reports up to date and clears the record once the update is installed", async () => {
const app = setup({ currentVersion: "2.0.0", ready: { version: "2.0.0" } })
await app.controller.start()
expect(app.states).toEqual(["idle", "checking", "up-to-date"])
expect(app.calls).toEqual(["check"])
expect(app.getReady()).toBeUndefined()
})
test("revalidates a persisted target through the updater cache on launch", async () => {
@@ -62,16 +79,7 @@ describe("updater controller", () => {
expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
})
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 () => {
test("concurrent checks share one platform check", async () => {
const app = setup()
await Promise.all([app.controller.check(), app.controller.check(), app.controller.check()])
@@ -79,81 +87,142 @@ describe("updater controller", () => {
expect(app.calls).toEqual(["check", "download"])
})
test("starts installing synchronously and coalesces restart requests", async () => {
test("clicking install twice checks once and installs the staged version once", async () => {
const app = setup()
await app.controller.start()
const first = app.controller.install()
const second = app.controller.install()
void app.controller.install()
void app.controller.install()
expect(first).toBe(second)
await Promise.resolve()
expect(app.calls).toEqual(["check", "download", "prepare", "install"])
await new Promise((resolve) => setTimeout(resolve, 0))
expect(app.calls).toEqual(["check", "download", "check", "prepare", "install:2.0.0"])
expect(app.controller.getState()).toEqual({ status: "installing", version: "2.0.0" })
})
test("does not check for updates while installation is in progress", async () => {
test("ignores checks while an installation is in progress", async () => {
const app = setup()
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", "prepare", "install"])
expect(app.calls).toEqual(["check", "download", "check", "prepare", "install:2.0.0"])
})
test("clicking install downloads and installs a newer release", async () => {
let latest = "2.0.0"
const app = setup({ latest: () => latest })
await app.controller.start()
latest = "3.0.0"
void app.controller.install()
expect(app.controller.getState()).toEqual({ status: "installing", version: "2.0.0" })
await new Promise((resolve) => setTimeout(resolve, 0))
expect(app.calls).toEqual(["check", "download", "check", "download", "prepare", "install:3.0.0"])
expect(app.controller.getState()).toEqual({ status: "installing", version: "3.0.0" })
})
test("clicking install uses the staged release when the final check fails", async () => {
let offline = false
const app = setup({
latest: () => {
if (offline) throw new Error("offline")
return "2.0.0"
},
})
await app.controller.start()
offline = true
void app.controller.install()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(app.calls).toEqual(["check", "download", "check", "prepare", "install:2.0.0"])
expect(app.controller.getState()).toEqual({ status: "installing", version: "2.0.0" })
})
test("returns to ready when installation fails", async () => {
const app = setup()
test("later checks stay silent while ready and pick up newer versions", async () => {
let latest = "2.0.0"
const app = setup({ latest: () => latest })
await app.controller.start()
const error = new Error("install failed")
const failed = createUpdaterController({
enabled: true,
currentVersion: "1.0.0",
platform: {
checkForUpdate: async () => "2.0.0",
stageUpdate: async () => {},
installAndRestart: () => Promise.reject(error),
},
lifecycle: { prepareToRestart: async () => {} },
persistence: { get: () => undefined, set() {}, clear() {} },
})
await failed.start()
await expect(failed.install()).rejects.toThrow("install failed")
expect(failed.getState()).toEqual({ status: "ready", version: "2.0.0" })
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("allows a state subscriber to retry after installation fails", async () => {
let attempts = 0
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>(() => {})
},
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"
},
lifecycle: { prepareToRestart: async () => {} },
persistence: { get: () => undefined, set() {}, clear() {} },
})
failed.subscribe((state) => {
if (state.status === "installing") sawInstalling = true
if (!sawInstalling || state.status !== "ready" || retry) return
retry = failed.install()
await app.controller.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" })
})
test("install during a silent refresh waits for the download, then installs the newer version", async () => {
let latest = "2.0.0"
let slowStage = false
let releaseStage = () => {}
const app = setup({
latest: () => latest,
stage: () => {
if (!slowStage) return Promise.resolve()
return new Promise<void>((resolve) => {
releaseStage = resolve
})
},
})
await failed.start()
await app.controller.start()
await expect(failed.install()).rejects.toThrow("install failed")
latest = "3.0.0"
slowStage = true
const refresh = app.controller.check()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(retry).toBeDefined()
void app.controller.install()
expect(app.controller.getState()).toEqual({ status: "installing", version: "2.0.0" })
releaseStage()
await refresh
expect(app.controller.getState()).toEqual({ status: "installing", version: "3.0.0" })
await new Promise((resolve) => setTimeout(resolve, 0))
expect(app.calls).toEqual(["check", "download", "check", "download", "prepare", "install:3.0.0"])
})
test("returns to ready after a failed installation and allows a retry", async () => {
let attempts = 0
const app = setup({
install() {
attempts++
if (attempts === 1) return Promise.reject(new Error("install failed"))
return new Promise<never>(() => {})
},
})
await app.controller.start()
await expect(app.controller.install()).rejects.toThrow("install failed")
expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
void app.controller.install()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(attempts).toBe(2)
expect(failed.getState()).toEqual({ status: "installing", version: "2.0.0" })
expect(app.controller.getState()).toEqual({ status: "installing", version: "2.0.0" })
})
})
+47 -32
View File
@@ -21,14 +21,13 @@ type UpdaterPersistence = {
}
export function createUpdaterController(input: {
enabled: boolean
currentVersion: string
platform?: UpdaterPlatform
lifecycle: UpdaterLifecycle
persistence: UpdaterPersistence
log?: (message: string, data?: object) => void
}) {
let state: UpdaterState = input.enabled ? { status: "idle" } : { status: "disabled" }
let state: UpdaterState = input.platform ? { status: "idle" } : { status: "disabled" }
let pending: Promise<UpdaterState> | undefined
let installing: Promise<void> | undefined
const listeners = new Set<(state: UpdaterState) => void>()
@@ -41,13 +40,21 @@ 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 === "ready" || state.status === "installing") return Promise.resolve(state)
if (state.status === "installing") return Promise.resolve(state)
if (pending) return pending
pending = (async () => {
pending = (state.status === "ready" ? refreshStaged(platform, state.version) : findAndStage(platform)).finally(
() => {
pending = undefined
},
)
return pending
}
const findAndStage = (platform: UpdaterPlatform) =>
(async () => {
transition({ status: "checking" })
const version = await platform.checkForUpdate()
if (!version || version === input.currentVersion) {
@@ -59,41 +66,49 @@ 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) }),
)
.finally(() => {
pending = undefined
})().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),
})
return pending
}
return state
})
const install = () => {
if (installing) return installing
if (state.status !== "ready") return Promise.reject(new Error("Update is not ready to install"))
const platform = input.platform
if (!platform || state.status !== "ready") return Promise.reject(new Error("Update is not ready to install"))
const version = startInstalling(state.version)
installing = restartWithUpdate(version)
return installing
}
const startInstalling = (version: string) => {
transition({ status: "installing", version })
return version
}
const restartWithUpdate = (version: string) =>
prepareAndRestart().catch((error) => {
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) => {
installing = undefined
transition({ status: "ready", version })
if (state.status === "installing") transition({ status: "ready", version: state.version })
throw error
})
const prepareAndRestart = async () => {
if (!input.platform) throw new Error("Updater is disabled")
await input.lifecycle.prepareToRestart()
await input.platform.installAndRestart()
return installing
}
return {
-23
View File
@@ -1,23 +0,0 @@
import { app, ipcMain } from "electron"
import type { UpdaterController } from "./updater-controller"
import { createUpdaterSubscriptions } from "./updater-subscriptions"
export function registerUpdaterIpc(controller: UpdaterController) {
const subscriptions = createUpdaterSubscriptions()
app.once("will-quit", subscriptions.clear)
ipcMain.handle("updater-subscribe", (event) => {
const id = event.sender.id
subscriptions.set(
id,
controller.subscribe((state) => {
if (event.sender.isDestroyed()) return subscriptions.delete(id)
event.sender.send("updater-state", state)
}),
)
event.sender.once("destroyed", () => subscriptions.delete(id))
})
ipcMain.handle("updater-unsubscribe", (event) => subscriptions.delete(event.sender.id))
ipcMain.handle("updater-check", () => controller.check())
ipcMain.handle("updater-install", () => controller.install())
}
@@ -1,16 +0,0 @@
import { describe, expect, test } from "bun:test"
import { createUpdaterSubscriptions } from "./updater-subscriptions"
describe("updater subscriptions", () => {
test("replaces the previous renderer subscription on reload", () => {
const subscriptions = createUpdaterSubscriptions()
const disposed: string[] = []
subscriptions.set(1, () => disposed.push("first"))
subscriptions.set(1, () => disposed.push("second"))
expect(disposed).toEqual(["first"])
subscriptions.delete(1)
expect(disposed).toEqual(["first", "second"])
})
})
@@ -1,20 +0,0 @@
export function createUpdaterSubscriptions() {
const subscriptions = new Map<number, () => void>()
const remove = (id: number) => {
subscriptions.get(id)?.()
subscriptions.delete(id)
}
return {
set(id: number, unsubscribe: () => void) {
remove(id)
subscriptions.set(id, unsubscribe)
},
delete: remove,
clear() {
subscriptions.forEach((unsubscribe) => unsubscribe())
subscriptions.clear()
},
}
}
+28 -6
View File
@@ -1,6 +1,6 @@
import { app, dialog } from "electron"
import { app, dialog, ipcMain } from "electron"
import { UPDATER_ENABLED } from "./constants"
import { createUpdaterController, type UpdaterReadyRecord } from "./updater-controller"
import { createUpdaterController, type UpdaterController, type UpdaterReadyRecord } from "./updater-controller"
import { getLogger } from "./logging"
import { getStore } from "./store"
import { nativeT } from "./native-translations"
@@ -12,7 +12,6 @@ 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 },
@@ -29,10 +28,34 @@ export function setupAutoUpdater(prepareToRestart: () => Promise<void>) {
})
}
export async function showUpdaterDialog(controller: ReturnType<typeof setupAutoUpdater>, alertOnFail: boolean) {
export function registerUpdaterIpc(controller: UpdaterController) {
const subscriptions = new Map<number, () => void>()
const unsubscribe = (id: number) => {
subscriptions.get(id)?.()
subscriptions.delete(id)
}
app.once("will-quit", () => subscriptions.forEach((dispose) => dispose()))
ipcMain.handle("updater-subscribe", (event) => {
const id = event.sender.id
subscriptions.get(id)?.() // a reloaded renderer replaces its previous subscription
subscriptions.set(
id,
controller.subscribe((state) => {
if (event.sender.isDestroyed()) return unsubscribe(id)
event.sender.send("updater-state", state)
}),
)
event.sender.once("destroyed", () => unsubscribe(id))
})
ipcMain.handle("updater-unsubscribe", (event) => unsubscribe(event.sender.id))
ipcMain.handle("updater-check", () => controller.check())
ipcMain.handle("updater-install", () => controller.install())
}
export async function showUpdaterDialog(controller: UpdaterController) {
const state = await controller.check()
if (state.status === "error") {
if (!alertOnFail) return
await dialog.showMessageBox({
type: "error",
message: nativeT("desktop.updater.dialog.checkFailed.message"),
@@ -41,7 +64,6 @@ export async function showUpdaterDialog(controller: ReturnType<typeof setupAutoU
return
}
if (state.status === "up-to-date") {
if (!alertOnFail) return
await dialog.showMessageBox({
type: "info",
message: nativeT("desktop.updater.dialog.upToDate.message"),
@@ -3,7 +3,43 @@ import { patchFiles } from "./apply-patch-file"
import { text } from "./session-diff"
describe("apply patch file", () => {
test("parses patch metadata from the server", () => {
test("parses v2 patch metadata", () => {
const file = patchFiles([
{
file: "a.ts",
status: "modified",
patch:
"Index: a.ts\n===================================================================\n--- a.ts\n+++ a.ts\n@@ -1,2 +1,2 @@\n one\n-two\n+three\n",
additions: 1,
deletions: 1,
},
])[0]
expect(file).toBeDefined()
expect(file?.filePath).toBe("a.ts")
expect(file?.relativePath).toBe("a.ts")
expect(file?.type).toBe("update")
expect(file?.view.fileDiff.name).toBe("a.ts")
expect(file?.view.fileDiff.isPartial).toBe(true)
expect(text(file.view, "deletions")).toBe("one\ntwo\n")
expect(text(file.view, "additions")).toBe("one\nthree\n")
})
test("maps all v2 patch statuses", () => {
expect(
patchFiles([
{ file: "added.ts", status: "added", patch: "+one", additions: 1, deletions: 0 },
{ file: "deleted.ts", status: "deleted", patch: "-one", additions: 0, deletions: 1 },
{ file: "modified.ts", status: "modified", patch: "-one\n+two", additions: 1, deletions: 1 },
]).map((file) => ({ file: file.filePath, type: file.type })),
).toEqual([
{ file: "added.ts", type: "add" },
{ file: "deleted.ts", type: "delete" },
{ file: "modified.ts", type: "update" },
])
})
test("parses legacy patch metadata", () => {
const file = patchFiles([
{
filePath: "/tmp/a.ts",
@@ -19,8 +55,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", () => {
@@ -37,7 +73,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,9 +3,11 @@ 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
@@ -27,6 +29,10 @@ 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" {
@@ -36,18 +42,19 @@ function status(type: Kind): "added" | "deleted" | "modified" {
}
export function patchFile(raw: unknown): ApplyPatchFile | undefined {
if (!raw || typeof raw !== "object") return
if (!raw || typeof raw !== "object") return undefined
const value = raw as Raw
const type = kind(value.type)
const filePath = typeof value.filePath === "string" ? value.filePath : undefined
const type = kind(value.type) ?? kind(value.status)
const filePath =
typeof value.filePath === "string" ? value.filePath : typeof value.file === "string" ? value.file : undefined
const relativePath = typeof value.relativePath === "string" ? value.relativePath : filePath
const 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
if (!patch && before === undefined && after === undefined) return
if (!type || !filePath || !relativePath) return undefined
if (!patch && before === undefined && after === undefined) return undefined
const additions = typeof value.additions === "number" ? value.additions : 0
const deletions = typeof value.deletions === "number" ? value.deletions : 0
@@ -474,6 +474,17 @@ function webSearchProviderLabel(provider: unknown, i18n: ReturnType<typeof useI1
return i18n.t("ui.tool.websearch")
}
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 = {},
@@ -481,12 +492,14 @@ export function getToolInfo(
): ToolInfo {
const i18n = useI18n()
switch (tool) {
case "read":
case "read": {
const path = readToolPath(input)
return {
icon: "glasses",
title: i18n.t("ui.tool.read"),
subtitle: input.filePath ? getFilename(input.filePath) : undefined,
subtitle: path ? getFilename(path) : undefined,
}
}
case "list":
return {
icon: "bullet-list",
@@ -568,7 +581,7 @@ export function getToolInfo(
case "skill":
return {
icon: "brain",
title: input.name || i18n.t("ui.tool.skill"),
title: skillToolName(input, metadata) || i18n.t("ui.tool.skill"),
}
default:
return {
@@ -847,7 +860,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 = typeof input.filePath === "string" ? input.filePath : undefined
const filePath = readToolPath(input)
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
@@ -1793,7 +1806,7 @@ ToolRegistry.register({
icon="glasses"
trigger={{
title: i18n.t("ui.tool.read"),
subtitle: props.input.filePath ? getFilename(props.input.filePath) : "",
subtitle: getFilename(readToolPath(props.input) ?? ""),
args,
}}
/>
@@ -2624,7 +2637,7 @@ ToolRegistry.register({
name: "skill",
render(props) {
const i18n = useI18n()
const title = createMemo(() => props.input.name || i18n.t("ui.tool.skill"))
const title = createMemo(() => skillToolName(props.input, props.metadata) || i18n.t("ui.tool.skill"))
const running = createMemo(() => props.status === "pending" || props.status === "running")
const titleContent = () => <TextShimmer text={title()} active={running()} />
@@ -26,6 +26,21 @@ 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(
@@ -41,6 +56,21 @@ 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,7 +7,12 @@ 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")
return files.every(
(file) =>
!!file &&
typeof file === "object" &&
(("type" in file && file.type === "delete") || ("status" in file && file.status === "deleted")),
)
}
const filediff = metadata.filediff
@@ -16,11 +21,12 @@ function deletionOnly(part: ToolPart) {
return filediff.additions === 0 && typeof filediff.deletions === "number" && filediff.deletions > 0
}
export function partDefaultOpen(part: PartType, shell = false, edit = false) {
if (part.type !== "tool") return
export function partDefaultOpen(part: PartType, shell = false, edit = false): boolean | undefined {
if (part.type !== "tool") return undefined
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
}
+89 -194
View File
@@ -57,12 +57,7 @@ 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"
@@ -105,59 +100,6 @@ 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)
@@ -259,8 +201,6 @@ 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()
@@ -1130,20 +1070,6 @@ export function Prompt(props: PromptProps) {
}
})
// Return a failed background submission to its author. When the target tab's
// editor is live and empty, restore directly; otherwise stash a draft for the
// next mount. A non-empty editor is never clobbered—prompt history retains the
// failed prompt either way.
function restorePrompt(sessionID: string, snapshot: PromptInfo) {
const active = route.data
if (active.type === "session" && active.sessionID === sessionID) {
const live = activePrompt.current
if (live && !live.current.text.trim()) live.set(snapshot)
return
}
saveDraft(sessionID, { prompt: snapshot, cursor: snapshot.text.length })
}
let submitting = false
async function submit(delivery: SessionInbox.Delivery = "steer") {
// Prevent overlapping invocations (e.g. a double-pressed Enter, or the
@@ -1244,9 +1170,8 @@ 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
@@ -1254,46 +1179,32 @@ 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 = 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,
const location = currentLocation.ref ?? data.location.default()
const created = await client.api.session
.create({
location: directory ? { directory } : location,
agent: agent.id,
model,
model: {
providerID: selection.providerID,
id: selection.modelID,
variant,
},
})
sessionID = created
createLocation = location
createSession = () => client.api.session.create({ id: created, 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
}
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
}
sessionID = created.id
session = created
}
// Capture mode before it gets reset
@@ -1334,87 +1245,70 @@ export function Prompt(props: PromptProps) {
})
} else {
move.startSubmit()
// 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(
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 === 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
if (error) {
toast.show({ title: "Failed to commit revert", message: errorMessage(error), variant: "error" })
return false
}
restorePrompt(submitSessionID, snapshot)
}
if (pendingEditorSelection) {
// Keep editor context hidden while admitting it before the corresponding user prompt.
const error = await client.api.session
.synthetic({
sessionID,
text: formatEditorContext(pendingEditorSelection),
resume: false,
})
.then(
() => undefined,
(error) => error,
)
if (error) {
toast.show({ title: "Failed to send editor context", message: errorMessage(error), variant: "error" })
return false
}
}
const error = await client.api.session
.prompt({
sessionID,
text: inputText,
files: store.prompt.files,
agents: store.prompt.agents,
skills: store.prompt.skills?.length ? store.prompt.skills : undefined,
delivery,
})
.then(
() => undefined,
(error) => error,
)
if (error) {
toast.show({ title: "Failed to send prompt", message: errorMessage(error), variant: "error" })
})
return false
}
if (pendingEditorSelection) editor.markSelectionSent()
}
history.append({
...store.prompt,
@@ -1425,14 +1319,15 @@ 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()
// 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,
})
setTimeout(() => {
route.navigate({
type: "session",
sessionID,
})
}, 50)
}
input.clear()
if (finishMoveProgress) move.finishSubmit()
+21 -176
View File
@@ -20,7 +20,6 @@ import type {
ProviderInfo,
ReferenceInfo,
SessionMessageInfo,
SessionMessageUser,
SessionMessageAssistant,
SessionMessageAssistantReasoning,
SessionMessageAssistantText,
@@ -41,7 +40,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 { batch, createEffect, createSignal, onCleanup } from "solid-js"
import { createEffect, createSignal, onCleanup } from "solid-js"
export type DataSessionStatus = "idle" | "running"
@@ -162,25 +161,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
const messageIndex = new Map<string, Map<string, number>>()
const 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)
}
@@ -208,17 +188,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
)
}
function removeMessage(sessionID: string, messageID: string) {
if (!messageIndex.get(sessionID)?.has(messageID)) return
message.update(sessionID, (draft, index) => {
const position = index.get(messageID)
if (position === undefined) return
draft.splice(position, 1)
index.delete(messageID)
message.reindex(draft, index, position)
})
}
function removePermission(sessionID: string, requestID: string) {
const requests = store.session.permission[sessionID]
if (!requests?.some((request) => request.id === requestID)) return
@@ -344,9 +313,7 @@ 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}`)
@@ -504,9 +471,6 @@ 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) => {
@@ -525,31 +489,25 @@ 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)
removeMessage(event.data.sessionID, event.data.inboxID)
if (messageIndex.get(event.data.sessionID)?.has(event.data.inboxID))
message.update(event.data.sessionID, (draft, index) => {
const position = index.get(event.data.inboxID)
if (position === undefined) return
draft.splice(position, 1)
index.delete(event.data.inboxID)
message.reindex(draft, index, position)
})
break
}
case "session.inbox.enqueued": {
const item = event.data.item
// 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 = {
addPending({
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] ?? []),
@@ -557,7 +515,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
])
if (item.type !== "user" && item.type !== "synthetic") break
message.update(event.data.sessionID, (draft, index) => {
const next: SessionMessageInfo =
message.append(
draft,
index,
item.type === "user"
? {
id: event.data.inboxID,
@@ -570,13 +530,8 @@ 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
}
@@ -870,30 +825,22 @@ 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 || local?.has(id) === true,
),
(store.session.input[event.data.sessionID] ?? []).filter((id) => id < event.data.to),
)
message.update(event.data.sessionID, (draft, index) => {
const position = draft.findIndex((item) => item.id >= event.data.to)
if (position === -1) return
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)
for (const item of draft.splice(position)) index.delete(item.id)
})
break
}
case "session.compaction.delta":
message.update(event.data.sessionID, (draft) => {
const current = message.compaction(draft)
@@ -1066,109 +1013,13 @@ 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 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)),
]
const pending = await client.api.session.inbox.list({ sessionID })
setStore("session", "pending", sessionID, reconcile(pending))
setStore(
"session",
@@ -1218,15 +1069,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
},
sync(sessionID: string) {
return sync.run(`session.message:${sessionID}`, async () => {
const fetched = (
const messages = (
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))
})
@@ -1,378 +0,0 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client"
import { createEffect, type ParentProps } from "solid-js"
import { ConfigProvider } from "../../../src/config"
import { ClientProvider, useClient } from "../../../src/context/client"
import { DataProvider as DataProviderBase, useData } from "../../../src/context/data"
import { LocationProvider, useLocation } from "../../../src/context/location"
import { createApi, createEventStream, createFetch, directory, json } from "../../fixture/tui-client"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
async function wait(fn: () => boolean, timeout = 2000) {
const start = Date.now()
while (!fn()) {
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
await Bun.sleep(10)
}
}
function emitEvent(events: ReturnType<typeof createEventStream>, event: OpenCodeEvent) {
events.emit({ ...event, location: { directory } })
}
const config = createTuiResolvedConfig()
function DataProvider(props: ParentProps) {
return (
<ConfigProvider config={config}>
<DataProviderBase>
<LocationProvider>
<SyncLocation />
{props.children}
</LocationProvider>
</DataProviderBase>
</ConfigProvider>
)
}
function SyncLocation() {
const data = useData()
const location = useLocation()
createEffect(() => location.set(data.location.default()))
return null
}
function durable(sessionID: string, seq = 0): { aggregateID: string; seq: number; version: 1 } {
return { aggregateID: sessionID, seq, version: 1 }
}
type Harness = {
data: ReturnType<typeof useData>
client: ReturnType<typeof useClient>
}
async function renderData(fetch: ReturnType<typeof createFetch>["fetch"]) {
const harness = {} as Harness
function Probe() {
harness.client = useClient()
harness.data = useData()
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<ClientProvider api={createApi(fetch)}>
<DataProvider>
<Probe />
</DataProvider>
</ClientProvider>
</TestTuiContexts>
))
await wait(() => harness.client.connection.status() === "connected")
return { app, ...harness }
}
test("echoes an optimistic prompt and replaces it with the admission echo", async () => {
const events = createEventStream()
const sessionID = "session-optimistic-echo"
const calls = createFetch(undefined, events)
const { app, data } = await renderData(calls.fetch)
try {
data.session.optimistic.prompt({
sessionID,
messageID: "msg_optimistic",
delivery: "steer",
text: "Hello",
files: [{ data: "", mime: "text/plain", source: { type: "uri", uri: "file:///tmp/a.ts" }, name: "a.ts" }],
})
const echoed = data.session.message.get(sessionID, "msg_optimistic")
expect(echoed?.type === "user" && echoed.text).toBe("Hello")
expect(echoed?.type === "user" && echoed.files?.[0]?.data).toBe("")
expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual(["msg_optimistic"])
expect(data.session.input.has(sessionID, "msg_optimistic")).toBe(true)
// Admission materializes attachments, so the echo must be replaced in place.
emitEvent(events, {
id: "evt_admitted",
created: 9,
type: "session.inbox.enqueued",
durable: durable(sessionID),
data: {
sessionID,
inboxID: "msg_optimistic",
item: {
type: "user",
payload: {
text: "Hello",
files: [{ data: "QUJD", mime: "text/plain", source: { type: "uri", uri: "file:///tmp/a.ts" }, name: "a.ts" }],
},
delivery: "steer",
},
},
})
await wait(() => {
const message = data.session.message.get(sessionID, "msg_optimistic")
return message?.type === "user" && message.files?.[0]?.data === "QUJD"
})
expect(data.session.message.list(sessionID)).toHaveLength(1)
expect(data.session.pending.list(sessionID)).toHaveLength(1)
expect(data.session.pending.list(sessionID)[0]?.timeCreated).toBe(9)
} finally {
app.renderer.destroy()
}
})
test("rolls back a failed optimistic prompt", async () => {
const events = createEventStream()
const sessionID = "session-optimistic-rollback"
const calls = createFetch(undefined, events)
const { app, data } = await renderData(calls.fetch)
try {
data.session.optimistic.prompt({
sessionID,
messageID: "msg_failed",
delivery: "queue",
text: "Will fail",
})
expect(data.session.message.get(sessionID, "msg_failed")).toBeDefined()
data.session.optimistic.rollback(sessionID, "msg_failed")
expect(data.session.message.get(sessionID, "msg_failed")).toBeUndefined()
expect(data.session.pending.list(sessionID)).toHaveLength(0)
expect(data.session.input.has(sessionID, "msg_failed")).toBe(false)
// Rollback of an unknown or already-settled echo is a no-op.
data.session.optimistic.rollback(sessionID, "msg_failed")
expect(data.session.message.list(sessionID)).toHaveLength(0)
} finally {
app.renderer.destroy()
}
})
test("optimistic prompts survive sync replaces until the server confirms them", async () => {
const events = createEventStream()
const sessionID = "session-optimistic-sync"
let serverMessages: SessionMessageInfo[] = []
const calls = createFetch((url) => {
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: serverMessages, cursor: {} })
if (url.pathname === `/api/session/${sessionID}/inbox`) return json({ data: [] })
}, events)
const { app, data } = await renderData(calls.fetch)
try {
data.session.optimistic.prompt({
sessionID,
messageID: "msg_pending",
delivery: "steer",
text: "Survive the sync",
})
// A wholesale replace from an empty server page keeps the unconfirmed echo.
await data.session.message.sync(sessionID)
expect(data.session.message.get(sessionID, "msg_pending")).toBeDefined()
await data.session.pending.sync(sessionID)
expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual(["msg_pending"])
expect(data.session.input.has(sessionID, "msg_pending")).toBe(true)
// A fetched page containing the ID is server confirmation: the projected copy
// wins and later rollback attempts become no-ops.
serverMessages = [{ id: "msg_pending", type: "user", text: "Survive the sync", time: { created: 5 } }]
data.session.message.invalidate(sessionID)
await data.session.message.sync(sessionID)
const confirmed = data.session.message.get(sessionID, "msg_pending")
expect(confirmed?.type === "user" && confirmed.time.created).toBe(5)
expect(data.session.message.list(sessionID)).toHaveLength(1)
data.session.optimistic.rollback(sessionID, "msg_pending")
expect(data.session.message.get(sessionID, "msg_pending")).toBeDefined()
} finally {
app.renderer.destroy()
}
})
test("cancellation clears the optimistic echo for good", async () => {
const events = createEventStream()
const sessionID = "session-optimistic-cancel"
const calls = createFetch((url) => {
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
}, events)
const { app, data } = await renderData(calls.fetch)
try {
data.session.optimistic.prompt({
sessionID,
messageID: "msg_cancelled",
delivery: "queue",
text: "Cancel me",
})
emitEvent(events, {
id: "evt_cancelled",
created: 2,
type: "session.inbox.cancelled",
durable: durable(sessionID),
data: { sessionID, inboxID: "msg_cancelled" },
})
await wait(() => data.session.message.get(sessionID, "msg_cancelled") === undefined)
expect(data.session.pending.list(sessionID)).toHaveLength(0)
// The ledger entry is gone too: a sync replace must not resurrect the echo.
await data.session.message.sync(sessionID)
expect(data.session.message.get(sessionID, "msg_cancelled")).toBeUndefined()
} finally {
app.renderer.destroy()
}
})
test("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,6 +27,7 @@
justify-content: center;
align-items: center;
padding: 0;
padding-inline: 4px;
gap: 4px;
min-width: 14px;
height: 14px;
@@ -48,7 +49,6 @@
flex-direction: row;
justify-content: center;
align-items: center;
min-width: 14px;
height: 11px;
padding: 0;
flex: none;
@@ -58,6 +58,8 @@ 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>
),
}
@@ -70,12 +72,14 @@ 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>
),