mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 22:26:40 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f543a66be7 | ||
|
|
d4bd41c4ba |
@@ -86,7 +86,7 @@ async function writeProtocolStream(session: CDPSession, handle: string, file: st
|
||||
try {
|
||||
while (true) {
|
||||
const chunk = await session.send("IO.read", { handle })
|
||||
await (chunk.base64Encoded ? output.write(Buffer.from(chunk.data, "base64")) : output.write(chunk.data))
|
||||
await output.write(chunk.base64Encoded ? Buffer.from(chunk.data, "base64") : chunk.data)
|
||||
if (chunk.eof) break
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -125,20 +125,17 @@ export async function installTimelineStreamProbe(
|
||||
const scrollTo = Element.prototype.scrollTo
|
||||
const scrollTop = Object.getOwnPropertyDescriptor(Element.prototype, "scrollTop")!
|
||||
if (profileVisual) {
|
||||
function measuredScrollTo(this: Element, options?: ScrollToOptions): void
|
||||
function measuredScrollTo(this: Element, x: number, y: number): void
|
||||
function measuredScrollTo(this: Element, first?: number | ScrollToOptions, second?: number) {
|
||||
Element.prototype.scrollTo = function (...args) {
|
||||
state.scroll.calls += 1
|
||||
const top = typeof first === "object" ? first?.top : second
|
||||
const top = typeof args[0] === "object" ? args[0]?.top : args[1]
|
||||
if (typeof top === "number") {
|
||||
const target = Math.min(top, this.scrollHeight - this.clientHeight)
|
||||
if (Math.abs(this.scrollTop - target) < 1) state.scroll.callNoops += 1
|
||||
}
|
||||
if (state.scroll.lastCallFrame === state.scroll.frame) state.scroll.sameFrameCalls += 1
|
||||
state.scroll.lastCallFrame = state.scroll.frame
|
||||
Reflect.apply(scrollTo, this, typeof first === "number" ? [first, second] : [first])
|
||||
return scrollTo.apply(this, args)
|
||||
}
|
||||
Element.prototype.scrollTo = measuredScrollTo
|
||||
Object.defineProperty(Element.prototype, "scrollTop", {
|
||||
configurable: true,
|
||||
get: scrollTop.get,
|
||||
|
||||
@@ -267,19 +267,18 @@ const childMessages = Array.from({ length: 4 }, (_, index) => [
|
||||
userMessage(childID, index + 2000, 120),
|
||||
assistantMessage(childID, index + 2000, id("msg_user", index + 2000), [textPart(index + 2000, 0, 240)]),
|
||||
]).flat()
|
||||
const messages: Record<string, Message[]> = {
|
||||
[sourceID]: sourceMessages,
|
||||
[targetID]: targetMessages,
|
||||
[childID]: childMessages,
|
||||
}
|
||||
|
||||
function renderable(part: MessagePart) {
|
||||
if (part.type === "tool" && part.tool === "todowrite") return false
|
||||
if (part.type === "text") return !!part.text?.trim()
|
||||
if (part.type === "reasoning") return !!part.text?.trim()
|
||||
if (part.type === "text") return !!part.text.trim()
|
||||
if (part.type === "reasoning") return !!part.text.trim()
|
||||
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
|
||||
}
|
||||
|
||||
function orderedParts(message: Message) {
|
||||
return message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
|
||||
}
|
||||
|
||||
export const fixture = {
|
||||
directory,
|
||||
project: {
|
||||
@@ -334,7 +333,7 @@ export const fixture = {
|
||||
sourceID,
|
||||
targetID,
|
||||
childID,
|
||||
messages,
|
||||
messages: { [sourceID]: sourceMessages, [targetID]: targetMessages, [childID]: childMessages },
|
||||
expected: {
|
||||
sourceTitle: "Uncommitted changes inquiry",
|
||||
targetTitle: "Example Game: sample jump movement & sample physics analysis",
|
||||
@@ -346,12 +345,16 @@ export const fixture = {
|
||||
.filter((message) => message.info.role === "user")
|
||||
.map((message) => message.info.id),
|
||||
childMessageIDs: childMessages.filter((message) => message.info.role === "user").map((message) => message.info.id),
|
||||
targetPartIDs: targetMessages.flatMap((message) => message.parts.filter(renderable).map((part) => part.id)),
|
||||
targetPartIDs: targetMessages.flatMap((message) =>
|
||||
orderedParts(message)
|
||||
.filter(renderable)
|
||||
.map((part) => part.id),
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
export function pageMessages(sessionID: string, limit: number, before?: string) {
|
||||
const messages = fixture.messages[sessionID] ?? []
|
||||
const messages = fixture.messages[sessionID as keyof typeof fixture.messages] ?? []
|
||||
const end = before
|
||||
? Math.max(
|
||||
0,
|
||||
@@ -361,6 +364,6 @@ export function pageMessages(sessionID: string, limit: number, before?: string)
|
||||
const start = Math.max(0, end - limit)
|
||||
return {
|
||||
items: messages.slice(start, end),
|
||||
cursor: start > 0 ? messages[start].info.id : undefined,
|
||||
cursor: start > 0 ? messages[start]!.info.id : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,8 +220,7 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
|
||||
}
|
||||
if (url.pathname === "/api/project/current")
|
||||
return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory })
|
||||
if (url.pathname === "/api/session")
|
||||
return json(route, { data: sessions.map((session) => currentSession(session)), cursor: {} })
|
||||
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
|
||||
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
||||
const currentSessionInfo = sessions.find((session) => url.pathname === `/api/session/${session.id}`)
|
||||
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
|
||||
|
||||
@@ -16,8 +16,9 @@ import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const messagePageSize = 200
|
||||
const messages = Array.from({ length: messagePageSize / 2 + 1 }, (_, index) => {
|
||||
const initialPageSize = 20
|
||||
const historyPageSize = 200
|
||||
const messages = Array.from({ length: initialPageSize + 1 }, (_, index) => {
|
||||
const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user`
|
||||
return [
|
||||
userMessage(undefined, { id, created: 1700000000000 + index * 2_000 }),
|
||||
@@ -25,7 +26,7 @@ const messages = Array.from({ length: messagePageSize / 2 + 1 }, (_, index) => {
|
||||
id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
|
||||
parentID: id,
|
||||
created: 1700000001000 + index * 2_000,
|
||||
completed: index < messagePageSize / 2,
|
||||
completed: index < initialPageSize,
|
||||
}),
|
||||
]
|
||||
}).flat()
|
||||
@@ -159,18 +160,21 @@ for (const scenario of scenarios) {
|
||||
await expect(page.locator(`[data-timeline-part-id="${userPartID}"]`)).toBeVisible()
|
||||
const viewport = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
await viewport.hover()
|
||||
const deadline = Date.now() + 30_000
|
||||
const deadline = Date.now() + 10_000
|
||||
while (requests.filter((request) => request.phase === "start").length < 2) {
|
||||
if (Date.now() >= deadline) throw new Error("Timed out scrolling to the history boundary")
|
||||
await page.mouse.wheel(0, -1_200)
|
||||
await page.mouse.wheel(0, -240)
|
||||
await page.waitForTimeout(20)
|
||||
}
|
||||
expect(requests.filter((request) => request.phase === "end")).toHaveLength(1)
|
||||
expect(sequence.slice(0, 3)).toEqual([
|
||||
"messages:start:latest",
|
||||
"messages:end:latest",
|
||||
`messages:start:${messages.at(-messagePageSize)!.info.id}`,
|
||||
`messages:start:${messages.at(-initialPageSize)!.info.id}`,
|
||||
])
|
||||
await expect(page.locator('[data-timeline-part-id*="_history_root_assistant:text:0"]')).toHaveCount(
|
||||
initialPageSize / 2,
|
||||
)
|
||||
await page.evaluate(() => {
|
||||
;(
|
||||
window as Window & {
|
||||
@@ -182,12 +186,15 @@ for (const scenario of scenarios) {
|
||||
expect(await visibleContentHidden(page)).toBe(false)
|
||||
const beforeHistory = await probeSamples(page)
|
||||
history.resolve()
|
||||
await expect
|
||||
.poll(() => page.locator('[data-timeline-part-id*="_history_root_assistant:text:0"]').count())
|
||||
.toBeGreaterThan(initialPageSize / 2)
|
||||
await expect.poll(() => requests.filter((request) => request.phase === "end").length).toBe(2)
|
||||
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
||||
await waitForProbeSamples(page, beforeHistory)
|
||||
expect(pages).toEqual([
|
||||
{ before: undefined, limit: messagePageSize },
|
||||
{ before: messages.at(-messagePageSize)!.info.id, limit: messagePageSize },
|
||||
{ before: undefined, limit: initialPageSize },
|
||||
{ before: messages.at(-initialPageSize)!.info.id, limit: historyPageSize },
|
||||
])
|
||||
expect(roots).toEqual([])
|
||||
|
||||
|
||||
@@ -82,7 +82,6 @@ test("moves busy through retry and recovery to final idle content", async ({ pag
|
||||
file: "src/retry.ts",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "modified",
|
||||
patch: "@@ -1 +1 @@\n-export const retry = false\n+export const retry = true",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { session, sessionID, setupTimeline } from "../performance/timeline-stability/fixture"
|
||||
|
||||
const user = { id: "msg_user", type: "user", text: "Run it", time: { created: 1 } } satisfies SessionMessageInfo
|
||||
|
||||
const assistant = (completed: boolean, tool = false, childID?: string): SessionMessageAssistant => ({
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: tool
|
||||
? [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_subagent",
|
||||
name: "subagent",
|
||||
state: { status: "running", input: {}, metadata: childID ? { sessionID: childID } : {} },
|
||||
time: { created: 2 },
|
||||
},
|
||||
]
|
||||
: [{ type: "text", text: "Working" }],
|
||||
time: { created: 2, ...(completed ? { completed: 3 } : {}) },
|
||||
})
|
||||
const assistant = (completed: boolean, tool = false, childID?: string) =>
|
||||
({
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: tool
|
||||
? [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_subagent",
|
||||
name: "subagent",
|
||||
state: { status: "running", input: {}, metadata: childID ? { sessionID: childID } : {} },
|
||||
time: { created: 2 },
|
||||
},
|
||||
]
|
||||
: [{ type: "text", text: "Working" }],
|
||||
time: { created: 2, ...(completed ? { completed: 3 } : {}) },
|
||||
}) satisfies SessionMessageInfo
|
||||
|
||||
test("renders current protocol notices in CLI order", async ({ page }) => {
|
||||
const ownerWarnings: string[] = []
|
||||
|
||||
@@ -280,7 +280,6 @@ function summaryDiff(index: number) {
|
||||
file: `src/diff-${index}.ts`,
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "modified" as const,
|
||||
patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,10 +23,11 @@ test("groups singleton and separated context operations at correct boundaries",
|
||||
]
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
||||
|
||||
await expect(page.locator('[data-timeline-part-ids="prt_boundary_01_read"]')).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-part-ids="prt_boundary_03_glob,prt_boundary_04_grep"]')).toBeVisible()
|
||||
await expect(
|
||||
page.locator('[data-timeline-part-ids="prt_boundary_01_read,prt_boundary_03_glob,prt_boundary_04_grep"]'),
|
||||
).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-part-ids="prt_boundary_06_list"]')).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(5)
|
||||
await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(4)
|
||||
})
|
||||
|
||||
test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => {
|
||||
|
||||
@@ -131,7 +131,6 @@ test("allows paint rounding for every framed row but not fixed turn gaps", async
|
||||
file: "src/summary.ts",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "modified",
|
||||
patch: "@@ -1 +1 @@\n-export const value = 1\n+export const value = 2",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -83,40 +83,6 @@ test("labels all web search provider variants", async ({ page }) => {
|
||||
await expect(page.getByRole("button", { name: /^Web Search/ })).toBeVisible()
|
||||
})
|
||||
|
||||
test("labels V2 read tools from their path input", async ({ page }) => {
|
||||
const id = "prt_read_path"
|
||||
await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([toolPart(id, "read", "completed", { path: "src/a.ts" })])],
|
||||
})
|
||||
|
||||
const group = page.locator(`[data-timeline-part-ids="${id}"]`)
|
||||
await group.locator('[data-slot="collapsible-trigger"]').click()
|
||||
await expect(group.locator('[data-slot="basic-tool-tool-subtitle"]')).toHaveText("a.ts")
|
||||
})
|
||||
|
||||
test("labels V2 skill tools from IDs and result metadata", async ({ page }) => {
|
||||
const pending = "prt_skill_id"
|
||||
const completed = "prt_skill_name"
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(pending, "skill", "running", { id: "sample-skill" }),
|
||||
toolPart(completed, "skill", "completed", { id: "opencode" }, { metadata: { name: "OpenCode" } }),
|
||||
]),
|
||||
],
|
||||
})
|
||||
|
||||
await expect(page.locator(`[data-timeline-part-id="${pending}"] [data-component="text-shimmer"]`)).toHaveAttribute(
|
||||
"aria-label",
|
||||
"sample-skill",
|
||||
)
|
||||
await expect(page.locator(`[data-timeline-part-id="${completed}"] [data-component="text-shimmer"]`)).toHaveAttribute(
|
||||
"aria-label",
|
||||
"OpenCode",
|
||||
)
|
||||
})
|
||||
|
||||
function questionInput() {
|
||||
return { questions: [{ header: "Stability", question: "Keep it stable?", options: [] }] }
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ type EventPayload = {
|
||||
payload: Record<string, unknown>
|
||||
}
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
test.use({ viewport: { width: 1440, height: 900 }, reducedMotion: "no-preference" })
|
||||
|
||||
test("animates todo opening without replaying it across session tabs", async ({ page }) => {
|
||||
test.setTimeout(90_000)
|
||||
@@ -57,6 +57,7 @@ test("animates todo opening without replaying it across session tabs", async ({
|
||||
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
||||
},
|
||||
sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)],
|
||||
sessionStatus: { [sourceID]: { type: "busy" } },
|
||||
pageMessages: () => ({ items: [] }),
|
||||
events: () => events.splice(0, 1),
|
||||
eventRetry: 16,
|
||||
|
||||
@@ -90,8 +90,7 @@ async function mockServer(page: Page) {
|
||||
if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname))
|
||||
return new Promise(() => {})
|
||||
if (url.pathname === "/api/event") return sse(route)
|
||||
if (url.pathname === "/api/session")
|
||||
return json(route, { data: sessions.map((session) => currentSession(session)), cursor: {} })
|
||||
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
|
||||
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
||||
const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`)
|
||||
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
|
||||
|
||||
@@ -222,29 +222,30 @@ function turn(index: number): Message[] {
|
||||
return [user, assistantMessage(targetID, index, user.info.id, parts)]
|
||||
}
|
||||
|
||||
const targetMessages = Array.from({ length: 101 }, (_, index) => turn(index)).flat()
|
||||
const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat()
|
||||
const sourceMessages = Array.from({ length: 12 }, (_, index) => [
|
||||
userMessage(sourceID, index + 1000, 120),
|
||||
assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [textPart(index + 1000, 0, 240)]),
|
||||
]).flat()
|
||||
const messages: Record<string, Message[]> = { [sourceID]: sourceMessages, [targetID]: targetMessages }
|
||||
|
||||
function renderable(part: MessagePart) {
|
||||
if (part.type === "tool" && part.tool === "todowrite") return false
|
||||
if (part.type === "text") return !!part.text?.trim()
|
||||
if (part.type === "reasoning") return !!part.text?.trim()
|
||||
if (part.type === "text") return !!part.text.trim()
|
||||
if (part.type === "reasoning") return !!part.text.trim()
|
||||
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
|
||||
}
|
||||
|
||||
function currentPartIDs(message: Message) {
|
||||
const ordinals = { text: 0, reasoning: 0 }
|
||||
return message.parts.flatMap((part) => {
|
||||
if (!renderable(part)) return []
|
||||
if (part.type === "text") return [`${message.info.id}:text:${ordinals.text++}`]
|
||||
if (part.type === "reasoning") return [`${message.info.id}:reasoning:${ordinals.reasoning++}`]
|
||||
if (part.type === "tool") return [typeof part.callID === "string" ? part.callID : part.id]
|
||||
return []
|
||||
})
|
||||
return message.parts
|
||||
.flatMap((part) => {
|
||||
if (!renderable(part)) return []
|
||||
if (part.type === "text") return [`${message.info.id}:text:${ordinals.text++}`]
|
||||
if (part.type === "reasoning") return [`${message.info.id}:reasoning:${ordinals.reasoning++}`]
|
||||
if (part.type === "tool") return [typeof part.callID === "string" ? part.callID : part.id]
|
||||
return []
|
||||
})
|
||||
.sort()
|
||||
}
|
||||
|
||||
export const fixture = {
|
||||
@@ -291,7 +292,7 @@ export const fixture = {
|
||||
],
|
||||
sourceID,
|
||||
targetID,
|
||||
messages,
|
||||
messages: { [sourceID]: sourceMessages, [targetID]: targetMessages },
|
||||
expected: {
|
||||
sourceTitle: "Uncommitted changes inquiry",
|
||||
targetTitle: "Example Game: sample jump movement & sample physics analysis",
|
||||
@@ -305,7 +306,7 @@ export const fixture = {
|
||||
}
|
||||
|
||||
export function pageMessages(sessionID: string, limit: number, before?: string) {
|
||||
const messages = fixture.messages[sessionID] ?? []
|
||||
const messages = fixture.messages[sessionID as keyof typeof fixture.messages] ?? []
|
||||
const end = before
|
||||
? Math.max(
|
||||
0,
|
||||
@@ -315,6 +316,6 @@ export function pageMessages(sessionID: string, limit: number, before?: string)
|
||||
const start = Math.max(0, end - limit)
|
||||
return {
|
||||
items: messages.slice(start, end),
|
||||
cursor: start > 0 ? messages[start].info.id : undefined,
|
||||
cursor: start > 0 ? messages[start]!.info.id : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ test.describe("smoke: session timeline", () => {
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID] ?? [] }),
|
||||
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }),
|
||||
})
|
||||
await configureSmokePage(page, fixture.directory)
|
||||
await page.addInitScript(
|
||||
@@ -188,11 +188,7 @@ test.describe("smoke: session timeline", () => {
|
||||
const bottom = root
|
||||
.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
|
||||
?.getBoundingClientRect()
|
||||
samples.push({
|
||||
ids: visible,
|
||||
last: visible.includes(last),
|
||||
bottomError: bottom ? bottom.bottom - view.bottom : undefined,
|
||||
})
|
||||
samples.push({ ids: visible, last: visible.includes(last), bottomError: bottom?.bottom - view.bottom })
|
||||
if (
|
||||
!firstPaint &&
|
||||
visible.includes(last) &&
|
||||
@@ -267,7 +263,7 @@ test.describe("smoke: session timeline", () => {
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID] ?? [] }),
|
||||
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }),
|
||||
})
|
||||
await configureSmokePage(page, fixture.directory)
|
||||
await page.addInitScript(
|
||||
@@ -727,7 +723,7 @@ function expectCompleteScroll(
|
||||
).toEqual([])
|
||||
expect(new Set(expectedPartIDs).size).toBe(expectedPartIDs.length)
|
||||
expect(new Set(expectedMessageIDs).size).toBe(expectedMessageIDs.length)
|
||||
expect(expectedPartIDs.length).toBe(465)
|
||||
expect(expectedPartIDs.length).toBe(331)
|
||||
}
|
||||
|
||||
async function selectHomeProject(page: Page, projectName: string) {
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": false,
|
||||
"emitDeclarationOnly": false,
|
||||
"noEmit": true,
|
||||
"rootDir": "..",
|
||||
"types": ["node", "bun"]
|
||||
},
|
||||
"include": ["./**/*.ts", "./**/*.tsx", "../src/types.ts"]
|
||||
"include": [
|
||||
"./performance/timeline-stability/**/*.spec.ts",
|
||||
"./performance/timeline-stability/fixture.test.ts",
|
||||
"./performance/timeline-stability/fixture.ts",
|
||||
"./performance/unit/visual-stability.test.ts",
|
||||
"./reproduction/timeline-suspense/**/*.ts",
|
||||
"./reproduction/timeline-suspense/**/*.tsx",
|
||||
"../src/types.ts",
|
||||
"../src/pages/session/timeline/observe-element-offset.ts",
|
||||
"./regression/new-session-panel-corner.spec.ts",
|
||||
"./regression/session-timeline-context-resize.spec.ts",
|
||||
"./utils/**/*.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Prompt } from "@/context/prompt"
|
||||
import { buildPromptRequest } from "./build-prompt-request"
|
||||
|
||||
describe("buildPromptRequest", () => {
|
||||
test("builds text, files, and agents from the prompt", () => {
|
||||
const prompt: Prompt = [
|
||||
{ type: "text", content: "hello", start: 0, end: 5 },
|
||||
{
|
||||
type: "file",
|
||||
path: "src/foo.ts",
|
||||
content: "@src/foo.ts",
|
||||
start: 5,
|
||||
end: 16,
|
||||
selection: { startLine: 4, startChar: 1, endLine: 6, endChar: 1 },
|
||||
},
|
||||
{ type: "agent", name: "planner", content: "@planner", start: 16, end: 24 },
|
||||
]
|
||||
|
||||
const result = buildPromptRequest({
|
||||
prompt,
|
||||
context: [{ key: "ctx:1", type: "file", path: "src/bar.ts", comment: "check this" }],
|
||||
images: [
|
||||
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
|
||||
],
|
||||
text: "hello @src/foo.ts @planner",
|
||||
sessionDirectory: "/repo",
|
||||
})
|
||||
|
||||
expect(result.text).toContain("hello @src/foo.ts @planner")
|
||||
expect(result.text).toContain("check this")
|
||||
expect(result.displayText).toBe("hello @src/foo.ts @planner")
|
||||
expect(result.comments).toMatchObject([{ path: "src/bar.ts", comment: "check this" }])
|
||||
expect(result.agents).toEqual([{ name: "planner", mention: { start: 16, end: 24, text: "@planner" } }])
|
||||
expect(result.files.some((file) => file.uri.startsWith("file:///repo/src/foo.ts"))).toBe(true)
|
||||
expect(result.files.find((file) => file.uri.startsWith("file:///repo/src/foo.ts"))?.mention).toEqual({
|
||||
start: 5,
|
||||
end: 16,
|
||||
text: "@src/foo.ts",
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps multiple uploaded attachments in order", () => {
|
||||
const result = buildPromptRequest({
|
||||
prompt: [{ type: "text", content: "check these", start: 0, end: 11 }],
|
||||
context: [],
|
||||
images: [
|
||||
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
|
||||
{
|
||||
type: "image",
|
||||
id: "img_2",
|
||||
filename: "b.pdf",
|
||||
mime: "application/pdf",
|
||||
dataUrl: "data:application/pdf;base64,BBB",
|
||||
},
|
||||
],
|
||||
text: "check these",
|
||||
sessionDirectory: "/repo",
|
||||
})
|
||||
|
||||
const uploads = result.files.filter((file) => file.uri.startsWith("data:"))
|
||||
|
||||
expect(uploads).toHaveLength(2)
|
||||
expect(uploads.map((file) => file.name)).toEqual(["a.png", "b.pdf"])
|
||||
})
|
||||
|
||||
test("preserves an external attachment source path for the model", () => {
|
||||
const result = buildPromptRequest({
|
||||
prompt: [],
|
||||
context: [],
|
||||
images: [
|
||||
{
|
||||
type: "image",
|
||||
id: "img_external",
|
||||
filename: "opencode.global.dat",
|
||||
sourcePath: "C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
|
||||
mime: "text/plain",
|
||||
dataUrl: "data:text/plain;base64,AAA",
|
||||
},
|
||||
],
|
||||
text: "inspect this",
|
||||
sessionDirectory: "C:\\Repos\\sst\\opencode",
|
||||
})
|
||||
|
||||
expect(result.files[0]?.name).toBe(
|
||||
"C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
|
||||
)
|
||||
})
|
||||
|
||||
test("preserves reference aliases as directory files", () => {
|
||||
const result = buildPromptRequest({
|
||||
prompt: [
|
||||
{
|
||||
type: "file",
|
||||
path: "/repo/../docs",
|
||||
content: "@docs",
|
||||
start: 0,
|
||||
end: 5,
|
||||
mime: "application/x-directory",
|
||||
filename: "docs",
|
||||
},
|
||||
],
|
||||
context: [],
|
||||
images: [],
|
||||
text: "@docs",
|
||||
sessionDirectory: "/repo/app",
|
||||
})
|
||||
|
||||
expect(result.files[0]).toEqual({
|
||||
uri: "file:///repo/../docs",
|
||||
mime: "application/x-directory",
|
||||
name: "docs",
|
||||
mention: { start: 0, end: 5, text: "@docs" },
|
||||
})
|
||||
})
|
||||
|
||||
test("deduplicates context files when prompt already includes same path", () => {
|
||||
const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
|
||||
|
||||
const result = buildPromptRequest({
|
||||
prompt,
|
||||
context: [
|
||||
{ key: "ctx:dup", type: "file", path: "src/foo.ts" },
|
||||
{ key: "ctx:comment", type: "file", path: "src/foo.ts", comment: "focus here" },
|
||||
],
|
||||
images: [],
|
||||
text: "@src/foo.ts",
|
||||
sessionDirectory: "/repo",
|
||||
})
|
||||
|
||||
const fooFiles = result.files.filter((file) => file.uri.startsWith("file:///repo/src/foo.ts"))
|
||||
|
||||
expect(fooFiles).toHaveLength(2)
|
||||
expect(result.text).toContain("focus here")
|
||||
})
|
||||
|
||||
test("adds files for @mentions inside comment text", () => {
|
||||
const result = buildPromptRequest({
|
||||
prompt: [{ type: "text", content: "look", start: 0, end: 4 }],
|
||||
context: [
|
||||
{
|
||||
key: "ctx:comment-mention",
|
||||
type: "file",
|
||||
path: "src/review.ts",
|
||||
comment: "Compare with @src/shared.ts and @src/review.ts.",
|
||||
},
|
||||
],
|
||||
images: [],
|
||||
text: "look",
|
||||
sessionDirectory: "/repo",
|
||||
})
|
||||
|
||||
expect(result.files).toHaveLength(2)
|
||||
expect(result.files.some((file) => file.uri === "file:///repo/src/review.ts")).toBe(true)
|
||||
expect(result.files.some((file) => file.uri === "file:///repo/src/shared.ts")).toBe(true)
|
||||
})
|
||||
|
||||
test("handles Windows paths correctly (simulated on macOS)", () => {
|
||||
const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }]
|
||||
|
||||
const result = buildPromptRequest({
|
||||
prompt,
|
||||
context: [],
|
||||
images: [],
|
||||
text: "@src\\foo.ts",
|
||||
sessionDirectory: "D:\\projects\\myapp", // Windows path
|
||||
})
|
||||
|
||||
const file = result.files[0]
|
||||
expect(file).toBeDefined()
|
||||
// URL should be parseable
|
||||
expect(() => new URL(file!.uri)).not.toThrow()
|
||||
// Should not have encoded backslashes in wrong place
|
||||
expect(file!.uri).not.toContain("%5C")
|
||||
// Should have normalized to forward slashes
|
||||
expect(file!.uri).toContain("/src/foo.ts")
|
||||
})
|
||||
|
||||
test("handles Windows absolute path with special characters", () => {
|
||||
const prompt: Prompt = [{ type: "file", path: "file#name.txt", content: "@file#name.txt", start: 0, end: 14 }]
|
||||
|
||||
const result = buildPromptRequest({
|
||||
prompt,
|
||||
context: [],
|
||||
images: [],
|
||||
text: "@file#name.txt",
|
||||
sessionDirectory: "C:\\Users\\test\\Documents", // Windows path
|
||||
})
|
||||
|
||||
const file = result.files[0]
|
||||
expect(file).toBeDefined()
|
||||
// URL should be parseable
|
||||
expect(() => new URL(file!.uri)).not.toThrow()
|
||||
// Special chars should be encoded
|
||||
expect(file!.uri).toContain("file%23name.txt")
|
||||
// Should have Windows drive letter properly encoded
|
||||
expect(file!.uri).toMatch(/file:\/\/\/[A-Z]:/)
|
||||
})
|
||||
|
||||
test("handles Linux absolute paths correctly", () => {
|
||||
const prompt: Prompt = [{ type: "file", path: "src/app.ts", content: "@src/app.ts", start: 0, end: 10 }]
|
||||
|
||||
const result = buildPromptRequest({
|
||||
prompt,
|
||||
context: [],
|
||||
images: [],
|
||||
text: "@src/app.ts",
|
||||
sessionDirectory: "/home/user/project",
|
||||
})
|
||||
|
||||
expect(result.files[0]?.uri).toBe("file:///home/user/project/src/app.ts")
|
||||
})
|
||||
|
||||
test("handles macOS paths correctly", () => {
|
||||
const prompt: Prompt = [{ type: "file", path: "README.md", content: "@README.md", start: 0, end: 9 }]
|
||||
|
||||
const result = buildPromptRequest({
|
||||
prompt,
|
||||
context: [],
|
||||
images: [],
|
||||
text: "@README.md",
|
||||
sessionDirectory: "/Users/kelvin/Projects/opencode",
|
||||
})
|
||||
|
||||
expect(result.files[0]?.uri).toBe("file:///Users/kelvin/Projects/opencode/README.md")
|
||||
})
|
||||
|
||||
test("handles context files with Windows paths", () => {
|
||||
const result = buildPromptRequest({
|
||||
prompt: [],
|
||||
context: [
|
||||
{ key: "ctx:1", type: "file", path: "src\\utils\\helper.ts" },
|
||||
{ key: "ctx:2", type: "file", path: "test\\unit.test.ts", comment: "check tests" },
|
||||
],
|
||||
images: [],
|
||||
text: "test",
|
||||
sessionDirectory: "D:\\workspace\\app",
|
||||
})
|
||||
|
||||
expect(result.files).toHaveLength(2)
|
||||
|
||||
// All file URLs should be valid
|
||||
result.files.forEach((file) => {
|
||||
expect(() => new URL(file.uri)).not.toThrow()
|
||||
expect(file.uri).not.toContain("%5C") // No encoded backslashes
|
||||
})
|
||||
})
|
||||
|
||||
test("handles absolute Windows paths (user manually specifies full path)", () => {
|
||||
const prompt: Prompt = [
|
||||
{ type: "file", path: "D:\\other\\project\\file.ts", content: "@D:\\other\\project\\file.ts", start: 0, end: 25 },
|
||||
]
|
||||
|
||||
const result = buildPromptRequest({
|
||||
prompt,
|
||||
context: [],
|
||||
images: [],
|
||||
text: "@D:\\other\\project\\file.ts",
|
||||
sessionDirectory: "C:\\current\\project",
|
||||
})
|
||||
|
||||
const file = result.files[0]
|
||||
expect(file).toBeDefined()
|
||||
// Should handle absolute path that differs from sessionDirectory
|
||||
expect(() => new URL(file!.uri)).not.toThrow()
|
||||
expect(file!.uri).toContain("/D:/other/project/file.ts")
|
||||
})
|
||||
|
||||
test("handles selection with query parameters on Windows", () => {
|
||||
const prompt: Prompt = [
|
||||
{
|
||||
type: "file",
|
||||
path: "src\\App.tsx",
|
||||
content: "@src\\App.tsx",
|
||||
start: 0,
|
||||
end: 11,
|
||||
selection: { startLine: 10, startChar: 0, endLine: 20, endChar: 5 },
|
||||
},
|
||||
]
|
||||
|
||||
const result = buildPromptRequest({
|
||||
prompt,
|
||||
context: [],
|
||||
images: [],
|
||||
text: "@src\\App.tsx",
|
||||
sessionDirectory: "C:\\project",
|
||||
})
|
||||
|
||||
const file = result.files[0]
|
||||
expect(file).toBeDefined()
|
||||
// Should have query parameters
|
||||
expect(file!.uri).toContain("?start=10&end=20")
|
||||
// Should be valid URL
|
||||
expect(() => new URL(file!.uri)).not.toThrow()
|
||||
// Query params should parse correctly
|
||||
const url = new URL(file!.uri)
|
||||
expect(url.searchParams.get("start")).toBe("10")
|
||||
expect(url.searchParams.get("end")).toBe("20")
|
||||
})
|
||||
|
||||
test("handles file paths with dots and special segments on Windows", () => {
|
||||
const prompt: Prompt = [
|
||||
{ type: "file", path: "..\\..\\shared\\util.ts", content: "@..\\..\\shared\\util.ts", start: 0, end: 21 },
|
||||
]
|
||||
|
||||
const result = buildPromptRequest({
|
||||
prompt,
|
||||
context: [],
|
||||
images: [],
|
||||
text: "@..\\..\\shared\\util.ts",
|
||||
sessionDirectory: "C:\\projects\\myapp\\src",
|
||||
})
|
||||
|
||||
const file = result.files[0]
|
||||
expect(file).toBeDefined()
|
||||
// Should be valid URL
|
||||
expect(() => new URL(file!.uri)).not.toThrow()
|
||||
// Should preserve .. segments (backend normalizes)
|
||||
expect(file!.uri).toContain("/..")
|
||||
})
|
||||
})
|
||||
@@ -1,115 +0,0 @@
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
import { encodeFilePath } from "@/context/file/path"
|
||||
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
|
||||
import { formatCommentNote, type PromptComment } from "@/utils/comment-note"
|
||||
|
||||
// Network fields feed both boundaries; display fields keep desktop-only rendering details in the local echo.
|
||||
type PromptRequest = {
|
||||
text: string
|
||||
displayText: string
|
||||
files: { uri: string; mime: string; name?: string; mention?: { start: number; end: number; text: string } }[]
|
||||
agents: { name: string; mention?: { start: number; end: number; text: string } }[]
|
||||
comments: PromptComment[]
|
||||
}
|
||||
|
||||
type ContextFile = {
|
||||
key: string
|
||||
type: "file"
|
||||
path: string
|
||||
selection?: FileSelection
|
||||
comment?: string
|
||||
commentID?: string
|
||||
commentOrigin?: "review" | "file"
|
||||
preview?: string
|
||||
}
|
||||
|
||||
type BuildPromptRequestInput = {
|
||||
prompt: Prompt
|
||||
context: ContextFile[]
|
||||
images: (Omit<ImageAttachmentPart, "blob"> & { dataUrl: string })[]
|
||||
text: string
|
||||
sessionDirectory: string
|
||||
}
|
||||
|
||||
const absolute = (directory: string, path: string) => {
|
||||
if (path.startsWith("/")) return path
|
||||
if (/^[A-Za-z]:[\\/]/.test(path) || /^[A-Za-z]:$/.test(path)) return path
|
||||
if (path.startsWith("\\\\") || path.startsWith("//")) return path
|
||||
return `${directory.replace(/[\\/]+$/, "")}/${path}`
|
||||
}
|
||||
|
||||
const fileQuery = (selection: FileSelection | undefined) =>
|
||||
selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
|
||||
|
||||
const mention = /(^|[\s([{"'])@(\S+)/g
|
||||
|
||||
const parseCommentMentions = (comment: string) => {
|
||||
return Array.from(comment.matchAll(mention)).flatMap((match) => {
|
||||
const path = (match[2] ?? "").replace(/[.,!?;:)}\]"']+$/, "")
|
||||
if (!path) return []
|
||||
return [path]
|
||||
})
|
||||
}
|
||||
|
||||
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
|
||||
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
|
||||
|
||||
export function buildPromptRequest(input: BuildPromptRequestInput): PromptRequest {
|
||||
const files = input.prompt.filter(isFileAttachment).map((attachment) => {
|
||||
const path = absolute(input.sessionDirectory, attachment.path)
|
||||
return {
|
||||
uri: attachment.url ?? `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`,
|
||||
mime: attachment.mime ?? "text/plain",
|
||||
name: attachment.filename ?? getFilename(attachment.path),
|
||||
mention: { start: attachment.start, end: attachment.end, text: attachment.content },
|
||||
}
|
||||
})
|
||||
|
||||
const agents = input.prompt.filter(isAgentAttachment).map((attachment) => ({
|
||||
name: attachment.name,
|
||||
mention: { start: attachment.start, end: attachment.end, text: attachment.content },
|
||||
}))
|
||||
|
||||
const used = new Set(files.map((file) => file.uri))
|
||||
const comments: PromptComment[] = []
|
||||
const context = input.context.flatMap((item) => {
|
||||
const path = absolute(input.sessionDirectory, item.path)
|
||||
const uri = `file://${encodeFilePath(path)}${fileQuery(item.selection)}`
|
||||
const comment = item.comment?.trim()
|
||||
if (!comment && used.has(uri)) return []
|
||||
used.add(uri)
|
||||
|
||||
const file = { uri, mime: "text/plain", name: getFilename(item.path) }
|
||||
if (!comment) return [file]
|
||||
|
||||
comments.push({
|
||||
path: item.path,
|
||||
selection: item.selection,
|
||||
comment,
|
||||
preview: item.preview,
|
||||
origin: item.commentOrigin,
|
||||
})
|
||||
const mentions = parseCommentMentions(comment).flatMap((path) => {
|
||||
const uri = `file://${encodeFilePath(absolute(input.sessionDirectory, path))}`
|
||||
if (used.has(uri)) return []
|
||||
used.add(uri)
|
||||
return [{ uri, mime: "text/plain", name: getFilename(path) }]
|
||||
})
|
||||
return [file, ...mentions]
|
||||
})
|
||||
|
||||
const images = input.images.map((attachment) => ({
|
||||
uri: attachment.dataUrl,
|
||||
mime: attachment.mime,
|
||||
name: attachment.sourcePath ?? attachment.filename,
|
||||
}))
|
||||
|
||||
return {
|
||||
text: [...(input.text.trim() ? [input.text] : []), ...comments.map(formatCommentNote)].join("\n"),
|
||||
displayText: input.text,
|
||||
files: [...files, ...context, ...images],
|
||||
agents,
|
||||
comments,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Prompt } from "@/context/prompt"
|
||||
import { buildRequestParts } from "./build-request-parts"
|
||||
|
||||
describe("buildRequestParts", () => {
|
||||
test("builds typed request and optimistic parts without cast path", () => {
|
||||
const prompt: Prompt = [
|
||||
{ type: "text", content: "hello", start: 0, end: 5 },
|
||||
{
|
||||
type: "file",
|
||||
path: "src/foo.ts",
|
||||
content: "@src/foo.ts",
|
||||
start: 5,
|
||||
end: 16,
|
||||
selection: { startLine: 4, startChar: 1, endLine: 6, endChar: 1 },
|
||||
},
|
||||
{ type: "agent", name: "planner", content: "@planner", start: 16, end: 24 },
|
||||
]
|
||||
|
||||
const result = buildRequestParts({
|
||||
prompt,
|
||||
context: [{ key: "ctx:1", type: "file", path: "src/bar.ts", comment: "check this" }],
|
||||
images: [
|
||||
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
|
||||
],
|
||||
text: "hello @src/foo.ts @planner",
|
||||
messageID: "msg_1",
|
||||
sessionID: "ses_1",
|
||||
sessionDirectory: "/repo",
|
||||
})
|
||||
|
||||
expect(result.requestParts[0]?.type).toBe("text")
|
||||
expect(result.requestParts.some((part) => part.type === "agent")).toBe(true)
|
||||
expect(
|
||||
result.requestParts.some((part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts")),
|
||||
).toBe(true)
|
||||
expect(result.requestParts.some((part) => part.type === "text" && part.synthetic)).toBe(true)
|
||||
expect(
|
||||
result.requestParts.some(
|
||||
(part) =>
|
||||
part.type === "text" &&
|
||||
part.synthetic &&
|
||||
part.metadata?.opencodeComment &&
|
||||
(part.metadata.opencodeComment as { comment?: string }).comment === "check this",
|
||||
),
|
||||
).toBe(true)
|
||||
|
||||
expect(result.optimisticParts).toHaveLength(result.requestParts.length)
|
||||
expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
|
||||
})
|
||||
|
||||
test("keeps multiple uploaded attachments in order", () => {
|
||||
const result = buildRequestParts({
|
||||
prompt: [{ type: "text", content: "check these", start: 0, end: 11 }],
|
||||
context: [],
|
||||
images: [
|
||||
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
|
||||
{
|
||||
type: "image",
|
||||
id: "img_2",
|
||||
filename: "b.pdf",
|
||||
mime: "application/pdf",
|
||||
dataUrl: "data:application/pdf;base64,BBB",
|
||||
},
|
||||
],
|
||||
text: "check these",
|
||||
messageID: "msg_multi",
|
||||
sessionID: "ses_multi",
|
||||
sessionDirectory: "/repo",
|
||||
})
|
||||
|
||||
const files = result.requestParts.filter((part) => part.type === "file" && part.url.startsWith("data:"))
|
||||
|
||||
expect(files).toHaveLength(2)
|
||||
expect(files.map((part) => (part.type === "file" ? part.filename : ""))).toEqual(["a.png", "b.pdf"])
|
||||
})
|
||||
|
||||
test("preserves an external attachment source path for the model", () => {
|
||||
const result = buildRequestParts({
|
||||
prompt: [],
|
||||
context: [],
|
||||
images: [
|
||||
{
|
||||
type: "image",
|
||||
id: "img_external",
|
||||
filename: "opencode.global.dat",
|
||||
sourcePath: "C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
|
||||
mime: "text/plain",
|
||||
dataUrl: "data:text/plain;base64,AAA",
|
||||
},
|
||||
],
|
||||
text: "inspect this",
|
||||
messageID: "msg_external",
|
||||
sessionID: "ses_external",
|
||||
sessionDirectory: "C:\\Repos\\sst\\opencode",
|
||||
})
|
||||
|
||||
expect(result.requestParts.find((part) => part.type === "file")?.filename).toBe(
|
||||
"C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
|
||||
)
|
||||
})
|
||||
|
||||
test("preserves reference aliases as directory file parts", () => {
|
||||
const result = buildRequestParts({
|
||||
prompt: [
|
||||
{
|
||||
type: "file",
|
||||
path: "/repo/../docs",
|
||||
content: "@docs",
|
||||
start: 0,
|
||||
end: 5,
|
||||
mime: "application/x-directory",
|
||||
filename: "docs",
|
||||
},
|
||||
],
|
||||
context: [],
|
||||
images: [],
|
||||
text: "@docs",
|
||||
messageID: "msg_reference",
|
||||
sessionID: "ses_reference",
|
||||
sessionDirectory: "/repo/app",
|
||||
})
|
||||
|
||||
const filePart = result.requestParts.find((part) => part.type === "file")
|
||||
expect(filePart).toBeDefined()
|
||||
if (filePart?.type === "file") {
|
||||
expect(filePart.mime).toBe("application/x-directory")
|
||||
expect(filePart.filename).toBe("docs")
|
||||
expect(filePart.url).toBe("file:///repo/../docs")
|
||||
expect(filePart.source?.type).toBe("file")
|
||||
if (filePart.source?.type === "file") {
|
||||
expect(filePart.source.path).toBe("/repo/../docs")
|
||||
expect(filePart.source.text.value).toBe("@docs")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("deduplicates context files when prompt already includes same path", () => {
|
||||
const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
|
||||
|
||||
const result = buildRequestParts({
|
||||
prompt,
|
||||
context: [
|
||||
{ key: "ctx:dup", type: "file", path: "src/foo.ts" },
|
||||
{ key: "ctx:comment", type: "file", path: "src/foo.ts", comment: "focus here" },
|
||||
],
|
||||
images: [],
|
||||
text: "@src/foo.ts",
|
||||
messageID: "msg_2",
|
||||
sessionID: "ses_2",
|
||||
sessionDirectory: "/repo",
|
||||
})
|
||||
|
||||
const fooFiles = result.requestParts.filter(
|
||||
(part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts"),
|
||||
)
|
||||
const synthetic = result.requestParts.filter((part) => part.type === "text" && part.synthetic)
|
||||
|
||||
expect(fooFiles).toHaveLength(2)
|
||||
expect(synthetic).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("adds file parts for @mentions inside comment text", () => {
|
||||
const result = buildRequestParts({
|
||||
prompt: [{ type: "text", content: "look", start: 0, end: 4 }],
|
||||
context: [
|
||||
{
|
||||
key: "ctx:comment-mention",
|
||||
type: "file",
|
||||
path: "src/review.ts",
|
||||
comment: "Compare with @src/shared.ts and @src/review.ts.",
|
||||
},
|
||||
],
|
||||
images: [],
|
||||
text: "look",
|
||||
messageID: "msg_comment_mentions",
|
||||
sessionID: "ses_comment_mentions",
|
||||
sessionDirectory: "/repo",
|
||||
})
|
||||
|
||||
const files = result.requestParts.filter((part) => part.type === "file")
|
||||
expect(files).toHaveLength(2)
|
||||
expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/review.ts")).toBe(true)
|
||||
expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/shared.ts")).toBe(true)
|
||||
})
|
||||
|
||||
test("handles Windows paths correctly (simulated on macOS)", () => {
|
||||
const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }]
|
||||
|
||||
const result = buildRequestParts({
|
||||
prompt,
|
||||
context: [],
|
||||
images: [],
|
||||
text: "@src\\foo.ts",
|
||||
messageID: "msg_win_1",
|
||||
sessionID: "ses_win_1",
|
||||
sessionDirectory: "D:\\projects\\myapp", // Windows path
|
||||
})
|
||||
|
||||
// Should create valid file URLs
|
||||
const filePart = result.requestParts.find((part) => part.type === "file")
|
||||
expect(filePart).toBeDefined()
|
||||
if (filePart?.type === "file") {
|
||||
// URL should be parseable
|
||||
expect(() => new URL(filePart.url)).not.toThrow()
|
||||
// Should not have encoded backslashes in wrong place
|
||||
expect(filePart.url).not.toContain("%5C")
|
||||
// Should have normalized to forward slashes
|
||||
expect(filePart.url).toContain("/src/foo.ts")
|
||||
}
|
||||
})
|
||||
|
||||
test("handles Windows absolute path with special characters", () => {
|
||||
const prompt: Prompt = [{ type: "file", path: "file#name.txt", content: "@file#name.txt", start: 0, end: 14 }]
|
||||
|
||||
const result = buildRequestParts({
|
||||
prompt,
|
||||
context: [],
|
||||
images: [],
|
||||
text: "@file#name.txt",
|
||||
messageID: "msg_win_2",
|
||||
sessionID: "ses_win_2",
|
||||
sessionDirectory: "C:\\Users\\test\\Documents", // Windows path
|
||||
})
|
||||
|
||||
const filePart = result.requestParts.find((part) => part.type === "file")
|
||||
expect(filePart).toBeDefined()
|
||||
if (filePart?.type === "file") {
|
||||
// URL should be parseable
|
||||
expect(() => new URL(filePart.url)).not.toThrow()
|
||||
// Special chars should be encoded
|
||||
expect(filePart.url).toContain("file%23name.txt")
|
||||
// Should have Windows drive letter properly encoded
|
||||
expect(filePart.url).toMatch(/file:\/\/\/[A-Z]:/)
|
||||
}
|
||||
})
|
||||
|
||||
test("handles Linux absolute paths correctly", () => {
|
||||
const prompt: Prompt = [{ type: "file", path: "src/app.ts", content: "@src/app.ts", start: 0, end: 10 }]
|
||||
|
||||
const result = buildRequestParts({
|
||||
prompt,
|
||||
context: [],
|
||||
images: [],
|
||||
text: "@src/app.ts",
|
||||
messageID: "msg_linux_1",
|
||||
sessionID: "ses_linux_1",
|
||||
sessionDirectory: "/home/user/project",
|
||||
})
|
||||
|
||||
const filePart = result.requestParts.find((part) => part.type === "file")
|
||||
expect(filePart).toBeDefined()
|
||||
if (filePart?.type === "file") {
|
||||
// URL should be parseable
|
||||
expect(() => new URL(filePart.url)).not.toThrow()
|
||||
// Should be a normal Unix path
|
||||
expect(filePart.url).toBe("file:///home/user/project/src/app.ts")
|
||||
}
|
||||
})
|
||||
|
||||
test("handles macOS paths correctly", () => {
|
||||
const prompt: Prompt = [{ type: "file", path: "README.md", content: "@README.md", start: 0, end: 9 }]
|
||||
|
||||
const result = buildRequestParts({
|
||||
prompt,
|
||||
context: [],
|
||||
images: [],
|
||||
text: "@README.md",
|
||||
messageID: "msg_mac_1",
|
||||
sessionID: "ses_mac_1",
|
||||
sessionDirectory: "/Users/kelvin/Projects/opencode",
|
||||
})
|
||||
|
||||
const filePart = result.requestParts.find((part) => part.type === "file")
|
||||
expect(filePart).toBeDefined()
|
||||
if (filePart?.type === "file") {
|
||||
// URL should be parseable
|
||||
expect(() => new URL(filePart.url)).not.toThrow()
|
||||
// Should be a normal Unix path
|
||||
expect(filePart.url).toBe("file:///Users/kelvin/Projects/opencode/README.md")
|
||||
}
|
||||
})
|
||||
|
||||
test("handles context files with Windows paths", () => {
|
||||
const prompt: Prompt = []
|
||||
|
||||
const result = buildRequestParts({
|
||||
prompt,
|
||||
context: [
|
||||
{ key: "ctx:1", type: "file", path: "src\\utils\\helper.ts" },
|
||||
{ key: "ctx:2", type: "file", path: "test\\unit.test.ts", comment: "check tests" },
|
||||
],
|
||||
images: [],
|
||||
text: "test",
|
||||
messageID: "msg_win_ctx",
|
||||
sessionID: "ses_win_ctx",
|
||||
sessionDirectory: "D:\\workspace\\app",
|
||||
})
|
||||
|
||||
const fileParts = result.requestParts.filter((part) => part.type === "file")
|
||||
expect(fileParts).toHaveLength(2)
|
||||
|
||||
// All file URLs should be valid
|
||||
fileParts.forEach((part) => {
|
||||
if (part.type === "file") {
|
||||
expect(() => new URL(part.url)).not.toThrow()
|
||||
expect(part.url).not.toContain("%5C") // No encoded backslashes
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test("handles absolute Windows paths (user manually specifies full path)", () => {
|
||||
const prompt: Prompt = [
|
||||
{ type: "file", path: "D:\\other\\project\\file.ts", content: "@D:\\other\\project\\file.ts", start: 0, end: 25 },
|
||||
]
|
||||
|
||||
const result = buildRequestParts({
|
||||
prompt,
|
||||
context: [],
|
||||
images: [],
|
||||
text: "@D:\\other\\project\\file.ts",
|
||||
messageID: "msg_abs",
|
||||
sessionID: "ses_abs",
|
||||
sessionDirectory: "C:\\current\\project",
|
||||
})
|
||||
|
||||
const filePart = result.requestParts.find((part) => part.type === "file")
|
||||
expect(filePart).toBeDefined()
|
||||
if (filePart?.type === "file") {
|
||||
// Should handle absolute path that differs from sessionDirectory
|
||||
expect(() => new URL(filePart.url)).not.toThrow()
|
||||
expect(filePart.url).toContain("/D:/other/project/file.ts")
|
||||
}
|
||||
})
|
||||
|
||||
test("handles selection with query parameters on Windows", () => {
|
||||
const prompt: Prompt = [
|
||||
{
|
||||
type: "file",
|
||||
path: "src\\App.tsx",
|
||||
content: "@src\\App.tsx",
|
||||
start: 0,
|
||||
end: 11,
|
||||
selection: { startLine: 10, startChar: 0, endLine: 20, endChar: 5 },
|
||||
},
|
||||
]
|
||||
|
||||
const result = buildRequestParts({
|
||||
prompt,
|
||||
context: [],
|
||||
images: [],
|
||||
text: "@src\\App.tsx",
|
||||
messageID: "msg_sel",
|
||||
sessionID: "ses_sel",
|
||||
sessionDirectory: "C:\\project",
|
||||
})
|
||||
|
||||
const filePart = result.requestParts.find((part) => part.type === "file")
|
||||
expect(filePart).toBeDefined()
|
||||
if (filePart?.type === "file") {
|
||||
// Should have query parameters
|
||||
expect(filePart.url).toContain("?start=10&end=20")
|
||||
// Should be valid URL
|
||||
expect(() => new URL(filePart.url)).not.toThrow()
|
||||
// Query params should parse correctly
|
||||
const url = new URL(filePart.url)
|
||||
expect(url.searchParams.get("start")).toBe("10")
|
||||
expect(url.searchParams.get("end")).toBe("20")
|
||||
}
|
||||
})
|
||||
|
||||
test("handles file paths with dots and special segments on Windows", () => {
|
||||
const prompt: Prompt = [
|
||||
{ type: "file", path: "..\\..\\shared\\util.ts", content: "@..\\..\\shared\\util.ts", start: 0, end: 21 },
|
||||
]
|
||||
|
||||
const result = buildRequestParts({
|
||||
prompt,
|
||||
context: [],
|
||||
images: [],
|
||||
text: "@..\\..\\shared\\util.ts",
|
||||
messageID: "msg_dots",
|
||||
sessionID: "ses_dots",
|
||||
sessionDirectory: "C:\\projects\\myapp\\src",
|
||||
})
|
||||
|
||||
const filePart = result.requestParts.find((part) => part.type === "file")
|
||||
expect(filePart).toBeDefined()
|
||||
if (filePart?.type === "file") {
|
||||
// Should be valid URL
|
||||
expect(() => new URL(filePart.url)).not.toThrow()
|
||||
// Should preserve .. segments (backend normalizes)
|
||||
expect(filePart.url).toContain("/..")
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,216 @@
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import type { AgentPart as MessageAgentPart, FilePart, Part, TextPart } from "@/types"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
import { encodeFilePath } from "@/context/file/path"
|
||||
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
|
||||
import { Identifier } from "@/utils/id"
|
||||
import { createCommentMetadata, formatCommentNote } from "@/utils/comment-note"
|
||||
|
||||
type PromptRequestPart =
|
||||
| (Omit<TextPart, "id" | "sessionID" | "messageID"> & { id: string })
|
||||
| (Omit<FilePart, "id" | "sessionID" | "messageID"> & { id: string })
|
||||
| (Omit<MessageAgentPart, "id" | "sessionID" | "messageID"> & { id: string })
|
||||
|
||||
type ContextFile = {
|
||||
key: string
|
||||
type: "file"
|
||||
path: string
|
||||
selection?: FileSelection
|
||||
comment?: string
|
||||
commentID?: string
|
||||
commentOrigin?: "review" | "file"
|
||||
preview?: string
|
||||
}
|
||||
|
||||
type BuildRequestPartsInput = {
|
||||
prompt: Prompt
|
||||
context: ContextFile[]
|
||||
images: (Omit<ImageAttachmentPart, "blob"> & { dataUrl: string })[]
|
||||
text: string
|
||||
messageID: string
|
||||
sessionID: string
|
||||
sessionDirectory: string
|
||||
}
|
||||
|
||||
const absolute = (directory: string, path: string) => {
|
||||
if (path.startsWith("/")) return path
|
||||
if (/^[A-Za-z]:[\\/]/.test(path) || /^[A-Za-z]:$/.test(path)) return path
|
||||
if (path.startsWith("\\\\") || path.startsWith("//")) return path
|
||||
return `${directory.replace(/[\\/]+$/, "")}/${path}`
|
||||
}
|
||||
|
||||
const fileQuery = (selection: FileSelection | undefined) =>
|
||||
selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
|
||||
|
||||
const mention = /(^|[\s([{"'])@(\S+)/g
|
||||
|
||||
const parseCommentMentions = (comment: string) => {
|
||||
return Array.from(comment.matchAll(mention)).flatMap((match) => {
|
||||
const path = (match[2] ?? "").replace(/[.,!?;:)}\]"']+$/, "")
|
||||
if (!path) return []
|
||||
return [path]
|
||||
})
|
||||
}
|
||||
|
||||
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
|
||||
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
|
||||
|
||||
const toOptimisticPart = (part: PromptRequestPart, sessionID: string, messageID: string): Part => {
|
||||
if (part.type === "text") {
|
||||
return {
|
||||
id: part.id,
|
||||
type: "text",
|
||||
text: part.text,
|
||||
synthetic: part.synthetic,
|
||||
ignored: part.ignored,
|
||||
time: part.time,
|
||||
metadata: part.metadata,
|
||||
sessionID,
|
||||
messageID,
|
||||
}
|
||||
}
|
||||
if (part.type === "file") {
|
||||
return {
|
||||
id: part.id,
|
||||
type: "file",
|
||||
mime: part.mime,
|
||||
filename: part.filename,
|
||||
url: part.url,
|
||||
source: part.source,
|
||||
sessionID,
|
||||
messageID,
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: part.id,
|
||||
type: "agent",
|
||||
name: part.name,
|
||||
source: part.source,
|
||||
sessionID,
|
||||
messageID,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildRequestParts(input: BuildRequestPartsInput) {
|
||||
const requestParts: PromptRequestPart[] = input.text.trim()
|
||||
? [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
type: "text",
|
||||
text: input.text,
|
||||
},
|
||||
]
|
||||
: []
|
||||
|
||||
const files = input.prompt.filter(isFileAttachment).map((attachment) => {
|
||||
const path = absolute(input.sessionDirectory, attachment.path)
|
||||
const source = attachment.source
|
||||
? {
|
||||
...attachment.source,
|
||||
text: {
|
||||
value: attachment.content,
|
||||
start: attachment.start,
|
||||
end: attachment.end,
|
||||
},
|
||||
}
|
||||
: {
|
||||
type: "file" as const,
|
||||
text: {
|
||||
value: attachment.content,
|
||||
start: attachment.start,
|
||||
end: attachment.end,
|
||||
},
|
||||
path,
|
||||
}
|
||||
return {
|
||||
id: Identifier.ascending("part"),
|
||||
type: "file",
|
||||
mime: attachment.mime ?? "text/plain",
|
||||
url: attachment.url ?? `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`,
|
||||
filename: attachment.filename ?? getFilename(attachment.path),
|
||||
source,
|
||||
} satisfies PromptRequestPart
|
||||
})
|
||||
|
||||
const agents = input.prompt.filter(isAgentAttachment).map((attachment) => {
|
||||
return {
|
||||
id: Identifier.ascending("part"),
|
||||
type: "agent",
|
||||
name: attachment.name,
|
||||
source: {
|
||||
value: attachment.content,
|
||||
start: attachment.start,
|
||||
end: attachment.end,
|
||||
},
|
||||
} satisfies PromptRequestPart
|
||||
})
|
||||
|
||||
const used = new Set(files.map((part) => part.url))
|
||||
const context = input.context.flatMap((item) => {
|
||||
const path = absolute(input.sessionDirectory, item.path)
|
||||
const url = `file://${encodeFilePath(path)}${fileQuery(item.selection)}`
|
||||
const comment = item.comment?.trim()
|
||||
if (!comment && used.has(url)) return []
|
||||
used.add(url)
|
||||
|
||||
const filePart = {
|
||||
id: Identifier.ascending("part"),
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
url,
|
||||
filename: getFilename(item.path),
|
||||
} satisfies PromptRequestPart
|
||||
|
||||
if (!comment) return [filePart]
|
||||
|
||||
const mentions = parseCommentMentions(comment).flatMap((path) => {
|
||||
const url = `file://${encodeFilePath(absolute(input.sessionDirectory, path))}`
|
||||
if (used.has(url)) return []
|
||||
used.add(url)
|
||||
return [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
url,
|
||||
filename: getFilename(path),
|
||||
} satisfies PromptRequestPart,
|
||||
]
|
||||
})
|
||||
|
||||
return [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
type: "text",
|
||||
text: formatCommentNote({ path: item.path, selection: item.selection, comment }),
|
||||
synthetic: true,
|
||||
metadata: createCommentMetadata({
|
||||
path: item.path,
|
||||
selection: item.selection,
|
||||
comment,
|
||||
preview: item.preview,
|
||||
origin: item.commentOrigin,
|
||||
}),
|
||||
} satisfies PromptRequestPart,
|
||||
filePart,
|
||||
...mentions,
|
||||
]
|
||||
})
|
||||
|
||||
const images = input.images.map((attachment) => {
|
||||
return {
|
||||
id: Identifier.ascending("part"),
|
||||
type: "file",
|
||||
mime: attachment.mime,
|
||||
url: attachment.dataUrl,
|
||||
filename: attachment.sourcePath ?? attachment.filename,
|
||||
} satisfies PromptRequestPart
|
||||
})
|
||||
|
||||
requestParts.push(...files, ...context, ...agents, ...images)
|
||||
|
||||
return {
|
||||
requestParts,
|
||||
optimisticParts: requestParts.map((part) => toOptimisticPart(part, input.sessionID, input.messageID)),
|
||||
}
|
||||
}
|
||||
@@ -11,17 +11,15 @@ type SessionCreateInput = {
|
||||
model?: { id: string; providerID: string; variant?: string }
|
||||
location?: { directory: string }
|
||||
}
|
||||
const admitted: Array<{
|
||||
const optimistic: Array<{
|
||||
directory?: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
text: string
|
||||
displayText: string
|
||||
agent: string
|
||||
model: { providerID: string; modelID: string; variant?: string }
|
||||
comments: unknown[]
|
||||
sessionID?: string
|
||||
message: {
|
||||
agent: string
|
||||
model: { providerID: string; modelID: string }
|
||||
variant?: string
|
||||
}
|
||||
}> = []
|
||||
const confirmed: unknown[] = []
|
||||
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
|
||||
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
|
||||
const sentShellDirectories: string[] = []
|
||||
@@ -37,11 +35,9 @@ const switchedModels: Array<{
|
||||
const sessionRequestOrder: string[] = []
|
||||
const updatedDrafts: Array<{ draftID: string; worktree?: string }> = []
|
||||
const syncedServers: string[] = []
|
||||
const admittedServers: string[] = []
|
||||
const optimisticServers: string[] = []
|
||||
const promptCaptures: Array<{ scope?: unknown; target?: unknown }> = []
|
||||
let serverSessionSyncs = 0
|
||||
let restoredPrompts = 0
|
||||
let clearEchoCalls = 0
|
||||
|
||||
let params: { id?: string } = {}
|
||||
let search: { draftId?: string } = {}
|
||||
@@ -51,8 +47,6 @@ let createSessionGate: Promise<void> | undefined
|
||||
let createWorktreeGate: Promise<void> | undefined
|
||||
let worktreeFailure: Error | undefined
|
||||
let locationFailure: Error | undefined
|
||||
let promptFailure: Error | undefined
|
||||
let clearEchoResult = true
|
||||
let worktreeCreates = 0
|
||||
let activeSDK = "server-a"
|
||||
let activeServerSync = "server-a"
|
||||
@@ -80,7 +74,7 @@ const prompt = {
|
||||
set: () => undefined,
|
||||
},
|
||||
reset: () => undefined,
|
||||
set: () => restoredPrompts++,
|
||||
set: () => undefined,
|
||||
context: {
|
||||
add: () => undefined,
|
||||
remove: () => undefined,
|
||||
@@ -122,16 +116,7 @@ const clientFor = (directory: string) => {
|
||||
sessionRequestOrder.push("prompt")
|
||||
sentPrompts.push(sessionDirectories[(input as { sessionID: string }).sessionID] ?? directory)
|
||||
promptInputs.push(input)
|
||||
if (promptFailure) throw promptFailure
|
||||
const prompt = input as { sessionID: string; id: string; text: string }
|
||||
return {
|
||||
id: prompt.id,
|
||||
sessionID: prompt.sessionID,
|
||||
timeCreated: 1,
|
||||
type: "user" as const,
|
||||
delivery: "steer" as const,
|
||||
payload: { text: prompt.text },
|
||||
}
|
||||
return { data: undefined }
|
||||
},
|
||||
switchAgent: async (input: { sessionID: string; agent: string }) => {
|
||||
sessionRequestOrder.push("agent")
|
||||
@@ -250,27 +235,16 @@ beforeAll(async () => {
|
||||
return {
|
||||
data: { command: commands, project: "project" },
|
||||
session: {
|
||||
inbox: {
|
||||
echo: (value: {
|
||||
optimistic: {
|
||||
add: (value: {
|
||||
directory?: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
text: string
|
||||
displayText: string
|
||||
agent: string
|
||||
model: { providerID: string; modelID: string; variant?: string }
|
||||
comments: unknown[]
|
||||
sessionID?: string
|
||||
message: { agent: string; model: { providerID: string; modelID: string; variant?: string } }
|
||||
}) => {
|
||||
admittedServers.push(server)
|
||||
admitted.push(value)
|
||||
},
|
||||
confirm: (value: unknown) => {
|
||||
confirmed.push(value)
|
||||
},
|
||||
clearEcho: () => {
|
||||
clearEchoCalls++
|
||||
return clearEchoResult
|
||||
optimisticServers.push(server)
|
||||
optimistic.push(value)
|
||||
},
|
||||
remove: () => undefined,
|
||||
},
|
||||
},
|
||||
set: () => undefined,
|
||||
@@ -330,8 +304,7 @@ beforeAll(async () => {
|
||||
|
||||
beforeEach(() => {
|
||||
createdSessions.length = 0
|
||||
admitted.length = 0
|
||||
confirmed.length = 0
|
||||
optimistic.length = 0
|
||||
promotedDrafts.length = 0
|
||||
updatedDrafts.length = 0
|
||||
sentCommands.length = 0
|
||||
@@ -341,10 +314,8 @@ beforeEach(() => {
|
||||
switchedModels.length = 0
|
||||
sessionRequestOrder.length = 0
|
||||
syncedServers.length = 0
|
||||
admittedServers.length = 0
|
||||
optimisticServers.length = 0
|
||||
promptCaptures.length = 0
|
||||
restoredPrompts = 0
|
||||
clearEchoCalls = 0
|
||||
params = {}
|
||||
search = {}
|
||||
sentShell.length = 0
|
||||
@@ -362,8 +333,6 @@ beforeEach(() => {
|
||||
createWorktreeGate = undefined
|
||||
worktreeFailure = undefined
|
||||
locationFailure = undefined
|
||||
promptFailure = undefined
|
||||
clearEchoResult = true
|
||||
worktreeCreates = 0
|
||||
for (const key of Object.keys(draftServers)) delete draftServers[key]
|
||||
for (const key of Object.keys(sessionDirectories)) delete sessionDirectories[key]
|
||||
@@ -452,7 +421,7 @@ describe("prompt submit worktree selection", () => {
|
||||
expect(updatedDrafts).toEqual([{ draftID: "draft-1", worktree: undefined }])
|
||||
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server-a", sessionId: "session-1" }])
|
||||
expect(syncedServers.every((server) => server === "server-a")).toBe(true)
|
||||
expect(admittedServers).toEqual(["server-a"])
|
||||
expect(optimisticServers).toEqual(["server-a"])
|
||||
expect(promptCaptures.at(-1)?.target).toEqual({ server: "project-server-a", scope: ServerScope.local })
|
||||
expect(submitted).toBe(0)
|
||||
})
|
||||
@@ -472,15 +441,13 @@ describe("prompt submit worktree selection", () => {
|
||||
await submit.handleSubmit(event)
|
||||
await Bun.sleep(0)
|
||||
|
||||
expect(admitted).toHaveLength(1)
|
||||
expect(admitted[0]).toMatchObject({
|
||||
sessionID: "session-1",
|
||||
text: "ls",
|
||||
agent: "agent",
|
||||
model: { providerID: "provider", modelID: "model", variant: "high" },
|
||||
expect(optimistic).toHaveLength(1)
|
||||
expect(optimistic[0]).toMatchObject({
|
||||
message: {
|
||||
agent: "agent",
|
||||
model: { providerID: "provider", modelID: "model", variant: "high" },
|
||||
},
|
||||
})
|
||||
expect(admitted[0]?.messageID).toStartWith("msg_")
|
||||
expect(confirmed).toMatchObject([{ id: admitted[0]?.messageID, sessionID: "session-1" }])
|
||||
expect(sentPrompts).toEqual(["/repo/main"])
|
||||
expect(switchedAgents).toEqual([{ sessionID: "session-1", agent: "agent" }])
|
||||
expect(switchedModels).toEqual([
|
||||
@@ -499,22 +466,6 @@ describe("prompt submit worktree selection", () => {
|
||||
expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_")
|
||||
})
|
||||
|
||||
test("keeps a confirmed echo when the prompt response is lost", async () => {
|
||||
params = { id: "session-1" }
|
||||
promptFailure = new Error("connection lost")
|
||||
clearEchoResult = false
|
||||
const submit = makeSubmit({
|
||||
info: () => ({ id: "session-1", agent: "agent", model: { id: "model", providerID: "provider" } }),
|
||||
})
|
||||
|
||||
await submit.handleSubmit(event)
|
||||
await settle()
|
||||
|
||||
expect(admitted).toHaveLength(1)
|
||||
expect(clearEchoCalls).toBe(1)
|
||||
expect(restoredPrompts).toBe(0)
|
||||
})
|
||||
|
||||
test("submits slash commands through the current session API", async () => {
|
||||
params = { id: "session-1" }
|
||||
variant = "high"
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { Message } from "@/types"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||
import { startTransition, type Accessor } from "solid-js"
|
||||
import { batch, startTransition, type Accessor } from "solid-js"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { useServerSync, type ServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -14,7 +15,7 @@ import { useSDK, type DirectorySDK } from "@/context/sdk"
|
||||
import { useSync, type DirectorySync } from "@/context/sync"
|
||||
import { Identifier } from "@/utils/id"
|
||||
import { getDirectory } from "@opencode-ai/core/util/path"
|
||||
import { buildPromptRequest } from "./build-prompt-request"
|
||||
import { buildRequestParts } from "./build-request-parts"
|
||||
import { setCursorPosition } from "./editor-dom"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { ScopedKey } from "@/utils/server-scope"
|
||||
@@ -99,22 +100,43 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
dataUrl: await blobDataUrl(attachment.blob, attachment.mime),
|
||||
})),
|
||||
)
|
||||
const request = buildPromptRequest({
|
||||
const { requestParts, optimisticParts } = buildRequestParts({
|
||||
prompt: input.draft.prompt,
|
||||
context: input.draft.context,
|
||||
images: encodedImages,
|
||||
text,
|
||||
sessionID: input.draft.sessionID,
|
||||
messageID,
|
||||
sessionDirectory: input.draft.sessionDirectory,
|
||||
})
|
||||
|
||||
setBusy()
|
||||
input.sync.session.inbox.echo({
|
||||
directory: input.draft.sessionDirectory,
|
||||
const message: Message = {
|
||||
id: messageID,
|
||||
sessionID: input.draft.sessionID,
|
||||
messageID,
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
agent: input.draft.agent,
|
||||
model: { ...input.draft.model, variant: input.draft.variant },
|
||||
...request,
|
||||
}
|
||||
|
||||
const add = () =>
|
||||
input.sync.session.optimistic.add({
|
||||
directory: input.draft.sessionDirectory,
|
||||
sessionID: input.draft.sessionID,
|
||||
message,
|
||||
parts: optimisticParts,
|
||||
})
|
||||
|
||||
const remove = () =>
|
||||
input.sync.session.optimistic.remove({
|
||||
directory: input.draft.sessionDirectory,
|
||||
sessionID: input.draft.sessionID,
|
||||
messageID,
|
||||
})
|
||||
|
||||
batch(() => {
|
||||
setBusy()
|
||||
add()
|
||||
})
|
||||
|
||||
try {
|
||||
@@ -137,23 +159,40 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
})
|
||||
}
|
||||
|
||||
const admitted = await input.api.prompt({
|
||||
await input.api.prompt({
|
||||
sessionID: input.draft.sessionID,
|
||||
id: messageID,
|
||||
text: request.text,
|
||||
files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
|
||||
agents: request.agents,
|
||||
text: requestParts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
|
||||
files: requestParts.flatMap((part) => {
|
||||
if (part.type !== "file") return []
|
||||
const text = part.source?.text
|
||||
return [
|
||||
{
|
||||
uri: part.url,
|
||||
name: part.filename,
|
||||
mention: text ? { start: text.start, end: text.end, text: text.value } : undefined,
|
||||
},
|
||||
]
|
||||
}),
|
||||
agents: requestParts.flatMap((part) =>
|
||||
part.type === "agent"
|
||||
? [
|
||||
{
|
||||
name: part.name,
|
||||
mention: part.source
|
||||
? { start: part.source.start, end: part.source.end, text: part.source.value }
|
||||
: undefined,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
})
|
||||
input.sync.session.inbox.confirm(admitted)
|
||||
return true
|
||||
} catch (err) {
|
||||
const failed = input.sync.session.inbox.clearEcho({
|
||||
directory: input.draft.sessionDirectory,
|
||||
sessionID: input.draft.sessionID,
|
||||
messageID,
|
||||
batch(() => {
|
||||
setIdle()
|
||||
remove()
|
||||
})
|
||||
if (!failed) return true
|
||||
setIdle()
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -499,6 +538,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
|
||||
const messageID = Identifier.ascending("message")
|
||||
|
||||
const removeOptimisticMessage = () => {
|
||||
submissionSync.session.optimistic.remove({
|
||||
directory: sessionDirectory,
|
||||
sessionID: session.id,
|
||||
messageID,
|
||||
})
|
||||
}
|
||||
|
||||
for (const item of commentItems) submission.target().context.remove(item.key)
|
||||
clearInput()
|
||||
|
||||
@@ -518,6 +565,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
removeOptimisticMessage()
|
||||
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
||||
})
|
||||
} finally {
|
||||
|
||||
@@ -119,11 +119,9 @@ export function createProviderConnectionController(options: {
|
||||
const finish = async () => {
|
||||
cancelPolling()
|
||||
const directory = options.directory()
|
||||
const key = directory ? pathKey(directory) : null
|
||||
await Promise.all([
|
||||
queryClient.refetchQueries(serverSync.queryOptions.providers(key)).catch(() => undefined),
|
||||
queryClient.refetchQueries(serverSync.queryOptions.integrations(key)).catch(() => undefined),
|
||||
])
|
||||
await queryClient
|
||||
.refetchQueries(serverSync.queryOptions.providers(directory ? pathKey(directory) : null))
|
||||
.catch(() => undefined)
|
||||
if (polling.disposed) return
|
||||
options.onComplete()
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { useIntegrations } from "@/hooks/use-integrations"
|
||||
import { createMemo, type Component, For, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
@@ -41,9 +40,7 @@ export const SettingsProvidersV2: Component<{
|
||||
const serverSdk = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const providers = useProviders(() => props.directory)
|
||||
const integrations = useIntegrations(() => props.directory)
|
||||
const providerConnect = useProviderConnectController({ onBack: props.onBack })
|
||||
const integration = (providerID: string) => integrations.list().find((item) => item.id === providerID)
|
||||
|
||||
const connect = (provider?: string) => {
|
||||
providerConnect.select(provider)
|
||||
@@ -76,14 +73,7 @@ export const SettingsProvidersV2: Component<{
|
||||
return items
|
||||
})
|
||||
|
||||
// Connection state comes from the integration list like the TUI: credential
|
||||
// connections mean an API key or OAuth grant, env connections mean detected
|
||||
// environment variables, and a connectionless integration is config-provided.
|
||||
const source = (item: ProviderItem): ProviderSource | undefined => {
|
||||
const current = integration(item.id)
|
||||
if (current?.connections.some((connection) => connection.type === "credential")) return "api"
|
||||
if (current?.connections.some((connection) => connection.type === "env")) return "env"
|
||||
if (current) return "config"
|
||||
if (!("source" in item)) return
|
||||
const value = item.source
|
||||
if (value === "env" || value === "api" || value === "config" || value === "custom") return value
|
||||
@@ -102,11 +92,7 @@ export const SettingsProvidersV2: Component<{
|
||||
return language.t("settings.providers.tag.other")
|
||||
}
|
||||
|
||||
const canDisconnect = (item: ProviderItem) => {
|
||||
const current = integration(item.id)
|
||||
if (current) return current.connections.some((connection) => connection.type === "credential")
|
||||
return source(item) !== "env" && !isConfigCustom(item.id)
|
||||
}
|
||||
const canDisconnect = (item: ProviderItem) => source(item) !== "env" && !isConfigCustom(item.id)
|
||||
|
||||
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
||||
|
||||
|
||||
@@ -68,8 +68,7 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-link"],
|
||||
[data-titlebar-tab]:is(:hover, [data-active="true"]):not([data-editing="true"]) [data-slot="tab-link"] {
|
||||
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-link"] {
|
||||
--tab-title-fade-offset: 4px;
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to right,
|
||||
@@ -87,8 +86,7 @@
|
||||
);
|
||||
}
|
||||
|
||||
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]):dir(rtl) [data-slot="tab-link"],
|
||||
[data-titlebar-tab]:is(:hover, [data-active="true"]):not([data-editing="true"]):dir(rtl) [data-slot="tab-link"] {
|
||||
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]):dir(rtl) [data-slot="tab-link"] {
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to left,
|
||||
black 0,
|
||||
@@ -105,7 +103,8 @@
|
||||
);
|
||||
}
|
||||
|
||||
[data-titlebar-tab]:is(:hover, [data-active="true"]):not([data-editing="true"]) [data-slot="tab-link"] {
|
||||
[data-titlebar-tab][data-title-overflow="true"]:is(:hover, [data-active="true"]):not([data-editing="true"])
|
||||
[data-slot="tab-link"] {
|
||||
--tab-title-fade-offset: 24px;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import type { SessionInboxInfo, SessionInfo } from "@opencode-ai/client/promise"
|
||||
import type { Message, Part } from "@/types"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { createMemo } from "solid-js"
|
||||
import { produce, reconcile, type SetStoreFunction } from "solid-js/store"
|
||||
import type { createServerSdkContext } from "./server-sdk"
|
||||
import type { createServerSyncContextInner } from "./server-sync"
|
||||
import type { PromptEcho } from "./server-session"
|
||||
import type { State } from "./global-sync/types"
|
||||
|
||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
@@ -82,17 +82,35 @@ export const createDirSyncContext = (
|
||||
const session = serverSync.session.get(sessionID)
|
||||
if (session?.location.directory === directory) return session
|
||||
},
|
||||
inbox: {
|
||||
echo(input: PromptEcho & { directory?: string }) {
|
||||
serverSync.session.inbox.echo(input)
|
||||
optimistic: {
|
||||
add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
|
||||
serverSync.session.optimistic.add(input)
|
||||
},
|
||||
confirm(input: SessionInboxInfo) {
|
||||
return serverSync.session.inbox.confirm(input)
|
||||
},
|
||||
clearEcho(input: { directory?: string; sessionID: string; messageID: string }) {
|
||||
return serverSync.session.inbox.clearEcho(input)
|
||||
remove(input: { directory?: string; sessionID: string; messageID: string }) {
|
||||
serverSync.session.optimistic.remove(input)
|
||||
},
|
||||
},
|
||||
addOptimisticMessage(input: {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
parts: Part[]
|
||||
agent: string
|
||||
model: { providerID: string; modelID: string }
|
||||
variant?: string
|
||||
}) {
|
||||
serverSync.session.optimistic.add({
|
||||
sessionID: input.sessionID,
|
||||
message: {
|
||||
id: input.messageID,
|
||||
sessionID: input.sessionID,
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
agent: input.agent,
|
||||
model: { ...input.model, variant: input.variant },
|
||||
},
|
||||
parts: input.parts,
|
||||
})
|
||||
},
|
||||
async sync(sessionID: string, options?: { force?: boolean }) {
|
||||
await serverSync.session.sync(sessionID, options)
|
||||
index(sessionID)
|
||||
|
||||
@@ -143,7 +143,7 @@ describe("encodeFilePath", () => {
|
||||
})
|
||||
|
||||
test("should handle mixed separator path (Windows + Unix)", () => {
|
||||
// This is what happens in build-prompt-request.ts when concatenating paths
|
||||
// This is what happens in build-request-parts.ts when concatenating paths
|
||||
const mixedPath = "D:\\dev\\projects\\opencode/README.bs.md"
|
||||
const result = encodeFilePath(mixedPath)
|
||||
const fileUrl = `file://${result}`
|
||||
|
||||
@@ -287,8 +287,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; sync: Ser
|
||||
)
|
||||
return
|
||||
|
||||
const directory = event.current?.location?.directory
|
||||
if (!directory) return
|
||||
const directory = e.name
|
||||
const time = Date.now()
|
||||
if (event.type === "session.execution.failed") {
|
||||
handleSessionError(directory, event, time)
|
||||
|
||||
@@ -194,7 +194,7 @@ export function createServerPermissionState(input: { sdk: ServerSDK; sync: Serve
|
||||
const handlePermission = (e: PermissionEvent) => {
|
||||
const event = e.details
|
||||
if (event?.type !== "permission.asked") return
|
||||
void respondPending(event.properties, event.current?.location?.directory)
|
||||
void respondPending(event.properties, e.name)
|
||||
}
|
||||
|
||||
const unsubscribe = input.sdk.event.listen((event) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { adaptServerEvent, coalesceServerEvents, resumeStreamAfterPageShow } from "./server-sdk"
|
||||
import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
|
||||
|
||||
describe("resumeStreamAfterPageShow", () => {
|
||||
test("restarts a stream only after a back-forward cache restore", () => {
|
||||
@@ -45,21 +45,23 @@ describe("adaptServerEvent", () => {
|
||||
})
|
||||
|
||||
describe("current event buffering", () => {
|
||||
const delta = (id: string, value: string, ordinal = 0) =>
|
||||
adaptServerEvent({
|
||||
const delta = (id: string, value: string, ordinal = 0) => ({
|
||||
directory: "/repo",
|
||||
payload: adaptServerEvent({
|
||||
id,
|
||||
created: 1,
|
||||
type: "session.text.delta",
|
||||
location: { directory: "/repo" },
|
||||
data: { sessionID: "ses", assistantMessageID: "msg", ordinal, delta: value },
|
||||
} as OpenCodeEvent)
|
||||
} as OpenCodeEvent),
|
||||
})
|
||||
|
||||
test("merges adjacent text deltas for the same message and ordinal", () => {
|
||||
const result = coalesceServerEvents([delta("evt_1", "hello "), delta("evt_2", "world")])
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]?.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
|
||||
expect(result[0]?.properties).toMatchObject({ delta: "hello world" })
|
||||
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
|
||||
expect(result[0]?.payload.properties).toMatchObject({ delta: "hello world" })
|
||||
})
|
||||
|
||||
test("coalesces current tool input deltas by tool ID", () => {
|
||||
@@ -72,19 +74,26 @@ describe("current event buffering", () => {
|
||||
data: { sessionID: "ses", assistantMessageID: "msg", id, delta },
|
||||
} as OpenCodeEvent)
|
||||
const result = coalesceServerEvents([
|
||||
current("evt_1", "call_1", "{"),
|
||||
current("evt_2", "call_1", "}"),
|
||||
current("evt_3", "call_2", "[]"),
|
||||
{ directory: "/repo", payload: current("evt_1", "call_1", "{") },
|
||||
{ directory: "/repo", payload: current("evt_2", "call_1", "}") },
|
||||
{ directory: "/repo", payload: current("evt_3", "call_2", "[]") },
|
||||
])
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0]?.current).toMatchObject({ id: "evt_2", data: { id: "call_1", delta: "{}" } })
|
||||
expect(result[1]?.current).toMatchObject({ id: "evt_3", data: { id: "call_2", delta: "[]" } })
|
||||
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { id: "call_1", delta: "{}" } })
|
||||
expect(result[1]?.payload.current).toMatchObject({ id: "evt_3", data: { id: "call_2", delta: "[]" } })
|
||||
})
|
||||
|
||||
test("preserves boundaries between distinct delta streams", () => {
|
||||
const events = [delta("evt_1", "a"), delta("evt_2", "b", 1), delta("evt_3", "c")]
|
||||
|
||||
expect(coalesceServerEvents(events).map((event) => event.current?.id)).toEqual(["evt_1", "evt_2", "evt_3"])
|
||||
expect(coalesceServerEvents(events).map((event) => event.payload.current?.id)).toEqual(["evt_1", "evt_2", "evt_3"])
|
||||
})
|
||||
|
||||
test("preserves current event order when enqueuing", () => {
|
||||
const events: Parameters<typeof enqueueServerEvent>[0] = []
|
||||
;[delta("evt_1", "a"), delta("evt_2", "b", 1)].forEach((event) => enqueueServerEvent(events, event))
|
||||
|
||||
expect(events.map((event) => event.payload.current?.id)).toEqual(["evt_1", "evt_2"])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,7 +12,7 @@ import { ServerScope } from "@/utils/server-scope"
|
||||
import { useServer } from "./server"
|
||||
|
||||
export type ServerEvent = Event & { id?: string; current?: OpenCodeEvent }
|
||||
type ServerEventMap = { [Type in ServerEvent["type"]]: Extract<ServerEvent, { type: Type }> }
|
||||
type QueuedServerEvent = { directory: string; payload: ServerEvent }
|
||||
type CurrentDelta = Extract<
|
||||
OpenCodeEvent,
|
||||
{ type: "session.text.delta" | "session.reasoning.delta" | "session.tool.input.delta" | "session.compaction.delta" }
|
||||
@@ -22,17 +22,22 @@ export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
|
||||
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
|
||||
}
|
||||
|
||||
export function coalesceServerEvents(events: ServerEvent[]) {
|
||||
const output: ServerEvent[] = []
|
||||
export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServerEvent) {
|
||||
queue.push(event)
|
||||
return true
|
||||
}
|
||||
|
||||
export function coalesceServerEvents(events: QueuedServerEvent[]) {
|
||||
const output: QueuedServerEvent[] = []
|
||||
events.forEach((event) => {
|
||||
const current = currentDelta(event.current)
|
||||
const current = currentDelta(event.payload.current)
|
||||
if (current) {
|
||||
const previous = output[output.length - 1]
|
||||
const prior = currentDelta(previous?.current)
|
||||
const prior = currentDelta(previous?.payload.current)
|
||||
if (
|
||||
previous &&
|
||||
prior &&
|
||||
prior.location?.directory === current.location?.directory &&
|
||||
previous.directory === event.directory &&
|
||||
currentDeltaKey(prior) === currentDeltaKey(current)
|
||||
) {
|
||||
const fragment = currentDeltaFragment(prior) + currentDeltaFragment(current)
|
||||
@@ -41,10 +46,13 @@ export function coalesceServerEvents(events: ServerEvent[]) {
|
||||
? { ...current.data, text: fragment }
|
||||
: { ...current.data, delta: fragment }
|
||||
output[output.length - 1] = {
|
||||
...event,
|
||||
properties: data,
|
||||
current: { ...current, data } as CurrentDelta,
|
||||
} as ServerEvent
|
||||
directory: event.directory,
|
||||
payload: {
|
||||
...event.payload,
|
||||
properties: data,
|
||||
current: { ...current, data } as CurrentDelta,
|
||||
} as ServerEvent,
|
||||
}
|
||||
return
|
||||
}
|
||||
output.push(event)
|
||||
@@ -81,8 +89,7 @@ export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: ()
|
||||
start()
|
||||
}
|
||||
|
||||
type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<ServerEventMap>>
|
||||
type ServerLocationEventEmitter = ReturnType<typeof createGlobalEmitter<{ [directory: string]: ServerEvent }>>
|
||||
type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<{ [key: string]: ServerEvent }>>
|
||||
export type ServerConnectionStatus = "connecting" | "connected" | "reconnecting"
|
||||
type ServerSDKBase = {
|
||||
server: ServerConnection.Any
|
||||
@@ -97,9 +104,6 @@ type ServerSDKBase = {
|
||||
event: {
|
||||
on: ServerEventEmitter["on"]
|
||||
listen: ServerEventEmitter["listen"]
|
||||
location: {
|
||||
on: ServerLocationEventEmitter["on"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,16 +123,18 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
})()
|
||||
|
||||
const eventApi = createApiForServer({ server: server.http, fetch: eventFetch })
|
||||
const emitter = createGlobalEmitter<ServerEventMap>()
|
||||
const locations = createGlobalEmitter<{ [directory: string]: ServerEvent }>()
|
||||
const emitter = createGlobalEmitter<{
|
||||
[key: string]: ServerEvent
|
||||
}>()
|
||||
|
||||
type Queued = QueuedServerEvent
|
||||
const FLUSH_FRAME_MS = 16
|
||||
const STREAM_YIELD_MS = 8
|
||||
const CONNECT_TIMEOUT_MS = 2_000
|
||||
const RECONNECT_DELAY_MS = 1_000
|
||||
|
||||
let queue: ServerEvent[] = []
|
||||
let buffer: ServerEvent[] = []
|
||||
let queue: Queued[] = []
|
||||
let buffer: Queued[] = []
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let last = 0
|
||||
|
||||
@@ -146,11 +152,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
last = Date.now()
|
||||
const output = coalesceServerEvents(events)
|
||||
batch(() => {
|
||||
output.forEach((event) => {
|
||||
emitter.emit(event.type, event)
|
||||
const directory = event.current?.location?.directory
|
||||
if (directory) locations.emit(directory, event)
|
||||
})
|
||||
output.forEach((event) => emitter.emit(event.directory, event.payload))
|
||||
})
|
||||
|
||||
buffer.length = 0
|
||||
@@ -163,8 +165,8 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
}
|
||||
|
||||
function publish(event: OpenCodeEvent) {
|
||||
queue.push(adaptServerEvent(event))
|
||||
schedule()
|
||||
const directory = event.location?.directory ?? "global"
|
||||
if (enqueueServerEvent(queue, { directory, payload: adaptServerEvent(event) })) schedule()
|
||||
}
|
||||
|
||||
function wait(delay: number, signal: AbortSignal) {
|
||||
@@ -311,7 +313,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
queue = []
|
||||
buffer = []
|
||||
emitter.clear()
|
||||
locations.clear()
|
||||
})
|
||||
|
||||
const api = createApiForServer({ server: server.http, fetch: platform.fetch })
|
||||
@@ -329,9 +330,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
event: {
|
||||
on: emitter.on.bind(emitter),
|
||||
listen: emitter.listen.bind(emitter),
|
||||
location: {
|
||||
on: locations.on.bind(locations),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -367,7 +365,7 @@ export type DirectorySDK = {
|
||||
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): DirectorySDK {
|
||||
const emitter = createGlobalEmitter<SDKEventMap>()
|
||||
|
||||
const unsub = serverSDK.event.location.on(directory, (event) => {
|
||||
const unsub = serverSDK.event.on(directory, (event) => {
|
||||
emitter.emit(event.type, event)
|
||||
})
|
||||
onCleanup(unsub)
|
||||
|
||||
@@ -6,32 +6,6 @@ const event = (input: object) => input as OpenCodeEvent
|
||||
const base = { created: 1, location: { directory: "/repo" }, durable: { aggregateID: "ses_1", seq: 1, version: 1 } }
|
||||
|
||||
describe("v2 session reducer", () => {
|
||||
test("moves a repeated inbox payload to the current event position", () => {
|
||||
const reducer = createV2SessionReducer()
|
||||
const result = reducer.reduce(
|
||||
[
|
||||
{ id: "msg_user", type: "user", text: "local", time: { created: 0 } },
|
||||
{ id: "msg_agent", type: "agent-switched", agent: "review", time: { created: 1 } },
|
||||
],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_admitted",
|
||||
type: "session.inbox.enqueued",
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
inboxID: "msg_user",
|
||||
item: { type: "user", delivery: "steer", payload: { text: "durable" } },
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result?.messages).toEqual([
|
||||
{ id: "msg_agent", type: "agent-switched", agent: "review", time: { created: 1 } },
|
||||
{ id: "msg_user", type: "user", text: "durable", time: { created: 1 } },
|
||||
])
|
||||
expect(result?.touched).toEqual(["msg_user"])
|
||||
})
|
||||
|
||||
test("projects promoted input and streaming assistant content", () => {
|
||||
const reducer = createV2SessionReducer()
|
||||
let messages: SessionMessageInfo[] = []
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import type {
|
||||
OpenCodeEvent,
|
||||
SessionInboxInfo,
|
||||
SessionInboxItem,
|
||||
SessionInfo,
|
||||
SessionMessageInfo,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import type { OpenCodeEvent, SessionInboxItem, SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
|
||||
type Assistant = Extract<SessionMessageInfo, { type: "assistant" }>
|
||||
type Compaction = Extract<SessionMessageInfo, { type: "compaction" }>
|
||||
@@ -35,14 +29,12 @@ export function createV2SessionReducer() {
|
||||
})
|
||||
const append = (message: SessionMessageInfo) =>
|
||||
result(source.some((item) => item.id === message.id) ? [...source] : [...source, message], [message.id])
|
||||
const replace = (message: SessionMessageInfo) =>
|
||||
result([...source.filter((item) => item.id !== message.id), message], [message.id])
|
||||
|
||||
switch (event.type) {
|
||||
case "session.inbox.enqueued":
|
||||
pending.set(key(sessionID, event.data.inboxID), event.data.item)
|
||||
if (event.data.item.type === "user")
|
||||
return replace({
|
||||
return append({
|
||||
id: event.data.inboxID,
|
||||
type: "user",
|
||||
metadata: event.data.item.payload.metadata,
|
||||
@@ -52,7 +44,7 @@ export function createV2SessionReducer() {
|
||||
time: { created: event.created },
|
||||
})
|
||||
if (event.data.item.type !== "synthetic") return result([...source])
|
||||
return replace({
|
||||
return append({
|
||||
id: event.data.inboxID,
|
||||
type: "synthetic",
|
||||
metadata: event.data.item.payload.metadata,
|
||||
@@ -488,9 +480,6 @@ export function createV2SessionReducer() {
|
||||
|
||||
return {
|
||||
reduce,
|
||||
confirm(item: SessionInboxInfo) {
|
||||
pending.set(key(item.sessionID, item.id), item)
|
||||
},
|
||||
clear(sessionID: string) {
|
||||
for (const id of pending.keys()) {
|
||||
if (id.startsWith(`${sessionID}:`)) pending.delete(id)
|
||||
|
||||
@@ -185,16 +185,6 @@ const textPart = (messageID: string, input: Partial<TextPart> = {}): TextPart =>
|
||||
id: `${messageID}:text:${input.id === "pending" ? 1 : 0}`,
|
||||
})
|
||||
|
||||
const promptEcho = (messageID: string, text = "hello") => ({
|
||||
sessionID: "child",
|
||||
messageID,
|
||||
text,
|
||||
displayText: text,
|
||||
agent: "build",
|
||||
model: { providerID: "provider", modelID: "model" },
|
||||
comments: [],
|
||||
})
|
||||
|
||||
const response = (data: MessageResponse["data"] = [], cursor?: string): MessageResponse => ({
|
||||
data,
|
||||
response: { headers: new Headers(cursor ? { "x-next-cursor": cursor } : undefined) },
|
||||
@@ -309,26 +299,6 @@ function setup(sessions: Record<string, SessionInfo>) {
|
||||
}
|
||||
|
||||
describe("server session", () => {
|
||||
test("hydrates session info after a native session.created event", async () => {
|
||||
const ctx = setup({ created: session("created") })
|
||||
|
||||
ctx.store.apply({
|
||||
type: "session.created",
|
||||
properties: {
|
||||
sessionID: "created",
|
||||
projectID: "project",
|
||||
location: { directory: "/repo" },
|
||||
slug: "created",
|
||||
version: "test",
|
||||
},
|
||||
})
|
||||
|
||||
expect(ctx.store.get("created")).toBeUndefined()
|
||||
await ctx.store.resolve("created")
|
||||
expect(ctx.store.get("created")?.location.directory).toBe("/repo")
|
||||
expect(ctx.get).toEqual([{ sessionID: "created" }])
|
||||
})
|
||||
|
||||
test("projects V2 session events into current and legacy message state", () => {
|
||||
const ctx = setup({ child: session("child") })
|
||||
ctx.store.remember(session("child"))
|
||||
@@ -370,38 +340,14 @@ describe("server session", () => {
|
||||
location: { directory: "/repo" },
|
||||
data: { sessionID: "child", assistantMessageID: "msg_2_assistant", ordinal: 0, delta: "world" },
|
||||
})
|
||||
apply({
|
||||
id: "evt_tool_z",
|
||||
created: 5,
|
||||
type: "session.tool.input.started",
|
||||
durable: { aggregateID: "child", seq: 3, version: 1 },
|
||||
location: { directory: "/repo" },
|
||||
data: { sessionID: "child", assistantMessageID: "msg_2_assistant", id: "call_z", name: "shell" },
|
||||
})
|
||||
apply({
|
||||
id: "evt_tool_a",
|
||||
created: 6,
|
||||
type: "session.tool.input.started",
|
||||
durable: { aggregateID: "child", seq: 4, version: 1 },
|
||||
location: { directory: "/repo" },
|
||||
data: { sessionID: "child", assistantMessageID: "msg_2_assistant", id: "call_a", name: "shell" },
|
||||
})
|
||||
|
||||
expect(ctx.store.data.session_message.child?.at(-1)).toMatchObject({
|
||||
id: "msg_2_assistant",
|
||||
type: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "world" },
|
||||
{ type: "tool", id: "call_z" },
|
||||
{ type: "tool", id: "call_a" },
|
||||
],
|
||||
content: [{ type: "text", text: "world" }],
|
||||
})
|
||||
expect(ctx.store.data.message.child?.map((message) => message.id)).toEqual(["msg_1_user", "msg_2_assistant"])
|
||||
expect(ctx.store.data.part.msg_2_assistant?.map((part) => part.id)).toEqual([
|
||||
"msg_2_assistant:text:0",
|
||||
"call_z",
|
||||
"call_a",
|
||||
])
|
||||
expect(ctx.store.data.part.msg_2_assistant).toMatchObject([{ type: "text", text: "world" }])
|
||||
})
|
||||
|
||||
test("projects V2 pending inputs and forms", () => {
|
||||
@@ -618,7 +564,7 @@ describe("server session", () => {
|
||||
await ctx.store.sync("root")
|
||||
|
||||
expect(ctx.get).toEqual([{ sessionID: "root" }])
|
||||
expect(ctx.messages).toEqual([{ sessionID: "root", limit: 200, order: "desc" }])
|
||||
expect(ctx.messages).toEqual([{ sessionID: "root", limit: 20, order: "desc" }])
|
||||
expect(ctx.store.data.message.root).toEqual([])
|
||||
})
|
||||
|
||||
@@ -629,32 +575,8 @@ describe("server session", () => {
|
||||
ctx.store.invalidate()
|
||||
await ctx.store.sync("root")
|
||||
|
||||
expect(ctx.store.data.message.root).toEqual([])
|
||||
expect(ctx.get).toHaveLength(2)
|
||||
expect(ctx.messages).toEqual([
|
||||
{ sessionID: "root", limit: 200, order: "desc" },
|
||||
{ sessionID: "root", limit: 200, order: "desc" },
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps a fixed page size after the local message cache exceeds the API limit", async () => {
|
||||
const client = messageClient(response(), response())
|
||||
const store = createServerSession(client)
|
||||
await store.sync("child")
|
||||
Array.from({ length: 428 }, (_, index) =>
|
||||
store.apply({
|
||||
type: "message.updated",
|
||||
properties: { info: userMessage(`message-${index}`, { time: { created: index } }) },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(store.data.message.child).toHaveLength(428)
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(client.requests).toEqual([
|
||||
{ sessionID: "child", limit: 200, order: "desc" },
|
||||
{ sessionID: "child", limit: 200, order: "desc" },
|
||||
])
|
||||
expect(ctx.messages).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("loads current session content through the current message API", async () => {
|
||||
@@ -679,50 +601,11 @@ describe("server session", () => {
|
||||
|
||||
await store.sync("root")
|
||||
|
||||
expect(requests).toEqual([{ sessionID: "root", limit: 200, order: "desc" }])
|
||||
expect(requests).toEqual([{ sessionID: "root", limit: 20, order: "desc" }])
|
||||
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
|
||||
expect(store.data.message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
|
||||
})
|
||||
|
||||
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) => ({
|
||||
@@ -755,8 +638,8 @@ describe("server session", () => {
|
||||
await store.sync("root")
|
||||
|
||||
expect(requests).toEqual([
|
||||
{ sessionID: "root", limit: 200, order: "desc" },
|
||||
{ sessionID: "root", limit: 200, cursor: "older" },
|
||||
{ sessionID: "root", limit: 20, order: "desc" },
|
||||
{ sessionID: "root", limit: 20, cursor: "older" },
|
||||
])
|
||||
expect(store.data.message.root.map((message) => message.id)).toEqual([
|
||||
user.id,
|
||||
@@ -765,26 +648,6 @@ describe("server session", () => {
|
||||
expect(assistants.map((item) => store.data.part[item.id]?.[0]?.type)).toEqual(["text", "text", "text"])
|
||||
})
|
||||
|
||||
test("loads older messages by cursor with the fixed page size", async () => {
|
||||
const older = userMessage("message-1")
|
||||
const latest = userMessage("message-2", { time: { created: 2 } })
|
||||
const client = messageClient(
|
||||
response([{ info: latest, parts: [] }], "older"),
|
||||
response([{ info: older, parts: [] }]),
|
||||
)
|
||||
const store = createServerSession(client)
|
||||
await store.sync("child")
|
||||
|
||||
await store.history.loadMore("child")
|
||||
|
||||
expect(client.requests).toEqual([
|
||||
{ sessionID: "child", limit: 200, order: "desc" },
|
||||
{ sessionID: "child", limit: 200, cursor: "older" },
|
||||
])
|
||||
expect(store.data.message.child).toEqual([older, latest])
|
||||
expect(store.history.more("child")).toBe(false)
|
||||
})
|
||||
|
||||
// V2 messages are ordered projections and do not expose V1 assistant parent IDs.
|
||||
describe.skip("V1 assistant parent projections", () => {
|
||||
test("backfills an assistant-only initial page through its user root", async () => {
|
||||
@@ -798,7 +661,7 @@ describe("server session", () => {
|
||||
|
||||
await store.sync("child")
|
||||
|
||||
expect(client.requests).toEqual([{ sessionID: "child", limit: 200, order: "desc" }])
|
||||
expect(client.requests).toEqual([{ sessionID: "child", limit: 20, order: "desc" }])
|
||||
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }])
|
||||
expect(store.data.message.child).toEqual([user, ...assistants])
|
||||
expect(store.history.more("child")).toBe(false)
|
||||
@@ -847,17 +710,19 @@ describe("server session", () => {
|
||||
expect(store.data.part[parent.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("does not let an admitted user suppress initial root backfill", async () => {
|
||||
test("does not let an optimistic user suppress initial root backfill", async () => {
|
||||
const user = userMessage("message-1")
|
||||
const part = textPart(user.id)
|
||||
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
|
||||
const client = rootMessageClient(
|
||||
[response(assistants.map((info) => ({ info, parts: [] })))],
|
||||
[singleResponse(user)],
|
||||
)
|
||||
const store = createServerSession(client)
|
||||
store.inbox.echo(promptEcho(user.id, "text"))
|
||||
store.optimistic.add({ sessionID: "child", message: user, parts: [part] })
|
||||
|
||||
await store.sync("child")
|
||||
store.optimistic.remove({ sessionID: "child", messageID: user.id })
|
||||
|
||||
expect(client.requests).toHaveLength(1)
|
||||
expect(client.rootRequests).toHaveLength(1)
|
||||
@@ -918,6 +783,28 @@ describe("server session", () => {
|
||||
expect(store.data.part[stale.id]).toEqual([freshPart])
|
||||
})
|
||||
|
||||
test("refreshes a confirmed optimistic parent while preserving pending parts", async () => {
|
||||
const stale = userMessage("message-1", { summary: { title: "stale", diffs: [] } })
|
||||
const fresh = { ...stale, summary: { title: "fresh", diffs: [] } }
|
||||
const confirmed = textPart(stale.id, { id: "confirmed", text: "stale" })
|
||||
const refreshed = { ...confirmed, text: "fresh" }
|
||||
const pending = textPart(stale.id, { id: "pending", text: "pending" })
|
||||
const assistant = assistantMessage("message-2", stale.id)
|
||||
const client = rootMessageClient(
|
||||
[response([{ info: stale, parts: [confirmed] }]), response([{ info: assistant, parts: [] }])],
|
||||
[singleResponse(fresh, [refreshed])],
|
||||
)
|
||||
const store = createServerSession(client)
|
||||
store.optimistic.add({ sessionID: "child", message: stale, parts: [confirmed, pending] })
|
||||
await store.sync("child")
|
||||
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }])
|
||||
expect(store.data.message.child).toEqual([fresh, assistant])
|
||||
expect(store.data.part[stale.id]).toEqual([refreshed, pending])
|
||||
})
|
||||
|
||||
test("uses a parent received by SSE during the replacement load", async () => {
|
||||
const pending = deferredResponse()
|
||||
const user = userMessage("message-1")
|
||||
@@ -1153,6 +1040,30 @@ describe("server session", () => {
|
||||
expect(store.data.part[message.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("preserves optimistic parts re-added after removal during a refresh", async () => {
|
||||
const pending = deferredResponse()
|
||||
const message = userMessage("message")
|
||||
const stale = textPart(message.id, { id: "stale", text: "stale" })
|
||||
const part = textPart(message.id, { id: "optimistic", text: "optimistic" })
|
||||
const store = createServerSession(
|
||||
messageClient(response([{ info: message, parts: [] }]), pending.promise, response()),
|
||||
)
|
||||
await store.sync("child")
|
||||
const refreshing = store.sync("child", { force: true })
|
||||
|
||||
store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [part] })
|
||||
pending.resolve(response([{ info: message, parts: [stale] }]))
|
||||
await refreshing
|
||||
|
||||
expect(store.data.message.child).toEqual([message])
|
||||
expect(store.data.part[message.id]).toEqual([part])
|
||||
|
||||
await store.sync("child", { force: true })
|
||||
expect(store.data.message.child).toEqual([message])
|
||||
expect(store.data.part[message.id]).toEqual([part])
|
||||
})
|
||||
|
||||
test("drops stale event content omitted by a complete initial page", async () => {
|
||||
const stale = userMessage("stale")
|
||||
const store = createServerSession(messageClient(response()))
|
||||
@@ -1174,309 +1085,170 @@ describe("server session", () => {
|
||||
expect(store.data.message.child).toEqual([live, fetched])
|
||||
})
|
||||
|
||||
test("echoes a prompt without changing durable message order", () => {
|
||||
const store = setup({ child: session("child") }).store
|
||||
test("does not restore removed optimistic content on refresh", async () => {
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id, { text: "removed" })
|
||||
const kept = { ...message, id: "kept" }
|
||||
const keptPart = { ...part, id: "kept-part", messageID: kept.id }
|
||||
const store = createServerSession(messageClient(response([{ info: kept, parts: [] }])))
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [part] })
|
||||
store.optimistic.add({ sessionID: "child", message: kept, parts: [keptPart] })
|
||||
|
||||
store.inbox.echo({
|
||||
...promptEcho("msg_prompt"),
|
||||
text: "hello\nThe user made the following comment regarding line 4 of src/foo.ts: check this",
|
||||
files: [{ uri: "file:///repo/src/foo.ts", mime: "text/plain", name: "foo.ts" }],
|
||||
agents: [{ name: "explore" }],
|
||||
comments: [
|
||||
{
|
||||
path: "src/foo.ts",
|
||||
selection: { startLine: 4, startChar: 1, endLine: 4, endChar: 5 },
|
||||
comment: "check this",
|
||||
preview: "const value = 1",
|
||||
origin: "review",
|
||||
},
|
||||
],
|
||||
store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
|
||||
store.apply({
|
||||
type: "message.part.removed",
|
||||
properties: { sessionID: "child", messageID: kept.id, partID: keptPart.id },
|
||||
})
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(store.data.pending.child).toMatchObject([{ id: "msg_prompt", type: "user", delivery: "steer" }])
|
||||
expect(store.data.input.child).toEqual(["msg_prompt"])
|
||||
expect(store.data.session_message.child).toBeUndefined()
|
||||
expect(store.data.message.child?.map((message) => message.id)).toEqual(["msg_prompt"])
|
||||
expect(store.data.part.msg_prompt).toMatchObject([
|
||||
{ id: "msg_prompt:text:0", type: "text", text: "hello" },
|
||||
{ id: "msg_prompt:file:0", type: "file", filename: "foo.ts" },
|
||||
{ id: "msg_prompt:agent:0", type: "agent", name: "explore" },
|
||||
{
|
||||
id: "msg_prompt:comment:0",
|
||||
type: "text",
|
||||
synthetic: true,
|
||||
metadata: {
|
||||
opencodeComment: {
|
||||
path: "src/foo.ts",
|
||||
selection: { startLine: 4, startChar: 1, endLine: 4, endChar: 5 },
|
||||
comment: "check this",
|
||||
preview: "const value = 1",
|
||||
origin: "review",
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
store.applyV2({
|
||||
id: "evt_prompt",
|
||||
created: 2,
|
||||
type: "session.inbox.enqueued",
|
||||
durable: { aggregateID: "child", seq: 1, version: 1 },
|
||||
data: {
|
||||
sessionID: "child",
|
||||
inboxID: "msg_prompt",
|
||||
item: {
|
||||
type: "user",
|
||||
delivery: "steer",
|
||||
payload: {
|
||||
text: "hello\nThe user made the following comment regarding line 4 of src/foo.ts: check this",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenCodeEvent)
|
||||
|
||||
expect(store.data.part.msg_prompt).toMatchObject([
|
||||
{ id: "msg_prompt:text:0", type: "text", text: "hello" },
|
||||
{ id: "msg_prompt:comment:0", type: "text", synthetic: true },
|
||||
])
|
||||
expect(store.data.message.child).toEqual([kept])
|
||||
expect(store.data.part[message.id]).toBeUndefined()
|
||||
expect(store.data.part[kept.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("preserves a local echo while message history omits pending input", async () => {
|
||||
const store = createServerSession(messageClient(response()))
|
||||
store.inbox.echo(promptEcho("msg_prompt"))
|
||||
store.inbox.confirm({
|
||||
id: "msg_prompt",
|
||||
sessionID: "child",
|
||||
timeCreated: 1,
|
||||
type: "user",
|
||||
delivery: "steer",
|
||||
payload: { text: "hello" },
|
||||
})
|
||||
test("replaces confirmed optimistic content with the initial page", async () => {
|
||||
const optimistic = userMessage("message")
|
||||
const fetched = { ...optimistic, time: { created: 2 } }
|
||||
const store = createServerSession(messageClient(response([{ info: fetched, parts: [] }])))
|
||||
store.optimistic.add({ sessionID: "child", message: optimistic, parts: [] })
|
||||
|
||||
await store.sync("child")
|
||||
|
||||
expect(store.data.message.child?.map((message) => message.id)).toEqual(["msg_prompt"])
|
||||
expect(store.data.part.msg_prompt).toMatchObject([{ type: "text", text: "hello" }])
|
||||
expect(store.data.message.child).toEqual([fetched])
|
||||
})
|
||||
|
||||
test("preserves local comment presentation through message refresh", async () => {
|
||||
const note = "The user made the following comment regarding line 4 of src/foo.ts: check this"
|
||||
const message = userMessage("msg_prompt")
|
||||
test("replaces a confirmed optimistic part with fetched content", async () => {
|
||||
const pending = deferredResponse()
|
||||
const message = userMessage("message")
|
||||
const optimistic = textPart(message.id, { text: "optimistic" })
|
||||
const fetched = { ...optimistic, text: "fetched" }
|
||||
const store = createServerSession(messageClient(pending.promise))
|
||||
const loading = store.sync("child")
|
||||
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [optimistic] })
|
||||
pending.resolve(response([{ info: message, parts: [fetched] }]))
|
||||
await loading
|
||||
|
||||
expect(store.data.part[message.id]).toEqual([fetched])
|
||||
})
|
||||
|
||||
test("rolls back only unconfirmed optimistic parts", async () => {
|
||||
const pending = deferredResponse()
|
||||
const message = userMessage("message")
|
||||
const confirmed = textPart(message.id, { id: "confirmed", text: "confirmed" })
|
||||
const pendingPart = textPart(message.id, { id: "pending", text: "pending" })
|
||||
const store = createServerSession(messageClient(pending.promise))
|
||||
const loading = store.sync("child")
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [confirmed, pendingPart] })
|
||||
|
||||
pending.resolve(response([{ info: message, parts: [confirmed] }]))
|
||||
await loading
|
||||
store.optimistic.remove({ sessionID: "child", messageID: message.id })
|
||||
|
||||
expect(store.data.message.child).toEqual([message])
|
||||
expect(store.data.part[message.id]).toEqual([confirmed])
|
||||
})
|
||||
|
||||
test("updates confirmed optimistic parts from later pages", async () => {
|
||||
const message = userMessage("message")
|
||||
const confirmed = textPart(message.id, { id: "confirmed", text: "first" })
|
||||
const updated = { ...confirmed, text: "updated" }
|
||||
const pendingPart = textPart(message.id, { id: "pending", text: "pending" })
|
||||
const store = createServerSession(
|
||||
messageClient(response([{ info: message, parts: [textPart(message.id, { text: note })] }])),
|
||||
messageClient(response([{ info: message, parts: [confirmed] }]), response([{ info: message, parts: [updated] }])),
|
||||
)
|
||||
store.inbox.echo({
|
||||
...promptEcho(message.id),
|
||||
text: `hello\n${note}`,
|
||||
comments: [
|
||||
{
|
||||
path: "src/foo.ts",
|
||||
selection: { startLine: 4, startChar: 1, endLine: 4, endChar: 5 },
|
||||
comment: "check this",
|
||||
origin: "review",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [confirmed, pendingPart] })
|
||||
await store.sync("child")
|
||||
|
||||
expect(store.data.part.msg_prompt).toMatchObject([
|
||||
{ id: "msg_prompt:text:0", type: "text", text: "hello" },
|
||||
{ id: "msg_prompt:comment:0", type: "text", synthetic: true },
|
||||
])
|
||||
await store.sync("child", { force: true })
|
||||
store.optimistic.remove({ sessionID: "child", messageID: message.id })
|
||||
|
||||
expect(store.data.part[message.id]).toEqual([updated])
|
||||
})
|
||||
|
||||
test("retires an admitted echo absent from authoritative reconnect state", async () => {
|
||||
const store = createServerSession(messageClient(response()))
|
||||
store.inbox.echo(promptEcho("msg_prompt"))
|
||||
store.inbox.confirm({
|
||||
id: "msg_prompt",
|
||||
sessionID: "child",
|
||||
timeCreated: 1,
|
||||
type: "user",
|
||||
delivery: "steer",
|
||||
payload: { text: "hello" },
|
||||
test("does not restore a confirmed optimistic part after its removal event", async () => {
|
||||
const message = userMessage("message")
|
||||
const confirmed = textPart(message.id, { id: "confirmed", text: "confirmed" })
|
||||
const pendingPart = textPart(message.id, { id: "pending", text: "pending" })
|
||||
const store = createServerSession(
|
||||
messageClient(response([{ info: message, parts: [confirmed] }]), response([{ info: message, parts: [] }])),
|
||||
)
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [confirmed, pendingPart] })
|
||||
await store.sync("child")
|
||||
store.apply({
|
||||
type: "message.part.removed",
|
||||
properties: { sessionID: "child", messageID: message.id, partID: confirmed.id },
|
||||
})
|
||||
|
||||
await Promise.all([store.sync("child"), store.hydrateTransient("child", async () => ({ pending: [], forms: [] }))])
|
||||
store.inbox.reconcile("child")
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(store.data.pending.child).toEqual([])
|
||||
expect(store.data.message.child).toEqual([])
|
||||
expect(store.data.part.msg_prompt).toBeUndefined()
|
||||
expect(store.data.part[message.id]).toEqual([pendingPart])
|
||||
})
|
||||
|
||||
test("retires a stale enqueued message when inbox hydration finishes after history", async () => {
|
||||
const store = createServerSession(messageClient(response()))
|
||||
store.applyV2({
|
||||
id: "evt_prompt",
|
||||
created: 1,
|
||||
type: "session.inbox.enqueued",
|
||||
durable: { aggregateID: "child", seq: 1, version: 1 },
|
||||
data: {
|
||||
sessionID: "child",
|
||||
inboxID: "msg_prompt",
|
||||
item: { type: "user", delivery: "steer", payload: { text: "hello" } },
|
||||
},
|
||||
} as OpenCodeEvent)
|
||||
test("clears delta buffers when removing optimistic content", () => {
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id, { text: "optimistic" })
|
||||
const store = setup({ child: session("child") }).store
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [part] })
|
||||
store.apply({
|
||||
type: "message.part.delta",
|
||||
properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: " delta" },
|
||||
})
|
||||
|
||||
await store.sync("child")
|
||||
await store.hydrateTransient("child", async () => ({ pending: [], forms: [] }))
|
||||
store.inbox.reconcile("child")
|
||||
store.optimistic.remove({ sessionID: "child", messageID: message.id })
|
||||
|
||||
expect(store.data.part[message.id]).toBeUndefined()
|
||||
expect(store.data.part_text_accum_delta[part.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("removes projected messages when rolling back optimistic content", () => {
|
||||
const message = userMessage("message")
|
||||
const store = setup({ child: session("child") }).store
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [] })
|
||||
|
||||
store.optimistic.remove({ sessionID: "child", messageID: message.id })
|
||||
|
||||
expect(store.data.pending.child).toEqual([])
|
||||
expect(store.data.session_message.child).toEqual([])
|
||||
expect(store.data.message.child).toEqual([])
|
||||
expect(store.data.part.msg_prompt).toBeUndefined()
|
||||
})
|
||||
|
||||
test("deduplicates the durable admission event against its local echo", () => {
|
||||
test("does not remove content confirmed by a message event", () => {
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id)
|
||||
const store = setup({ child: session("child") }).store
|
||||
store.inbox.echo(promptEcho("msg_prompt"))
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [part] })
|
||||
store.apply({ type: "message.updated", properties: { sessionID: "child", info: message } })
|
||||
|
||||
store.applyV2({
|
||||
id: "evt_prompt",
|
||||
created: 2,
|
||||
type: "session.inbox.enqueued",
|
||||
durable: { aggregateID: "child", seq: 1, version: 1 },
|
||||
data: {
|
||||
sessionID: "child",
|
||||
inboxID: "msg_prompt",
|
||||
item: { type: "user", delivery: "steer", payload: { text: "hello" } },
|
||||
},
|
||||
} as OpenCodeEvent)
|
||||
store.optimistic.remove({ sessionID: "child", messageID: message.id })
|
||||
|
||||
expect(store.data.pending.child).toHaveLength(1)
|
||||
expect(store.data.input.child).toEqual(["msg_prompt"])
|
||||
expect(store.data.session_message.child?.filter((message) => message.id === "msg_prompt")).toHaveLength(1)
|
||||
expect(store.data.message.child?.filter((message) => message.id === "msg_prompt")).toHaveLength(1)
|
||||
expect(store.data.part.msg_prompt).toMatchObject([{ type: "text", text: "hello" }])
|
||||
expect(store.data.message.child).toEqual([message])
|
||||
expect(store.data.part[message.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("uses the prompt response when the admission event was missed", () => {
|
||||
test("does not remove parts confirmed by part events", () => {
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id)
|
||||
const store = setup({ child: session("child") }).store
|
||||
store.inbox.echo(promptEcho("msg_prompt"))
|
||||
store.inbox.confirm({
|
||||
id: "msg_prompt",
|
||||
sessionID: "child",
|
||||
timeCreated: 2,
|
||||
type: "user",
|
||||
delivery: "steer",
|
||||
payload: { text: "hello" },
|
||||
})
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [part] })
|
||||
store.apply({ type: "message.updated", properties: { sessionID: "child", info: message } })
|
||||
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } })
|
||||
|
||||
store.applyV2({
|
||||
id: "evt_delivered",
|
||||
created: Date.now() + 1,
|
||||
type: "session.inbox.delivered",
|
||||
durable: { aggregateID: "child", seq: 2, version: 1 },
|
||||
data: { sessionID: "child", inboxID: "msg_prompt" },
|
||||
} as OpenCodeEvent)
|
||||
store.optimistic.remove({ sessionID: "child", messageID: message.id })
|
||||
|
||||
expect(store.data.pending.child).toEqual([])
|
||||
expect(store.data.input.child).toEqual([])
|
||||
expect(store.data.session_message.child).toMatchObject([{ id: "msg_prompt", type: "user", text: "hello" }])
|
||||
expect(store.data.message.child?.filter((message) => message.id === "msg_prompt")).toHaveLength(1)
|
||||
expect(store.data.part.msg_prompt).toMatchObject([{ type: "text", text: "hello" }])
|
||||
expect(store.data.message.child).toEqual([message])
|
||||
expect(store.data.part[message.id]).toEqual([part])
|
||||
})
|
||||
|
||||
test("keeps a durable admission when the HTTP request later fails", () => {
|
||||
test("treats a part event as confirmation when it precedes the message event", () => {
|
||||
const message = userMessage("message")
|
||||
const part = textPart(message.id)
|
||||
const store = setup({ child: session("child") }).store
|
||||
store.inbox.echo(promptEcho("msg_prompt"))
|
||||
store.applyV2({
|
||||
id: "evt_prompt",
|
||||
created: 2,
|
||||
type: "session.inbox.enqueued",
|
||||
durable: { aggregateID: "child", seq: 1, version: 1 },
|
||||
data: {
|
||||
sessionID: "child",
|
||||
inboxID: "msg_prompt",
|
||||
item: { type: "user", delivery: "steer", payload: { text: "hello" } },
|
||||
},
|
||||
} as OpenCodeEvent)
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [part] })
|
||||
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } })
|
||||
|
||||
expect(store.inbox.clearEcho({ sessionID: "child", messageID: "msg_prompt" })).toBe(false)
|
||||
expect(store.data.pending.child).toHaveLength(1)
|
||||
expect(store.data.message.child?.map((message) => message.id)).toEqual(["msg_prompt"])
|
||||
})
|
||||
store.optimistic.remove({ sessionID: "child", messageID: message.id })
|
||||
|
||||
test("places durable admission after delayed selection events", () => {
|
||||
const store = setup({ child: session("child") }).store
|
||||
store.remember(session("child"))
|
||||
store.inbox.echo(promptEcho("msg_prompt"))
|
||||
store.applyV2({
|
||||
id: "evt_agent",
|
||||
created: 1,
|
||||
type: "session.agent.selected",
|
||||
durable: { aggregateID: "child", seq: 1, version: 1 },
|
||||
data: { sessionID: "child", agent: "review" },
|
||||
} as OpenCodeEvent)
|
||||
store.applyV2({
|
||||
id: "evt_model",
|
||||
created: 2,
|
||||
type: "session.model.selected",
|
||||
durable: { aggregateID: "child", seq: 2, version: 1 },
|
||||
data: { sessionID: "child", model: { id: "new-model", providerID: "new-provider" } },
|
||||
} as OpenCodeEvent)
|
||||
store.applyV2({
|
||||
id: "evt_prompt",
|
||||
created: 3,
|
||||
type: "session.inbox.enqueued",
|
||||
durable: { aggregateID: "child", seq: 3, version: 1 },
|
||||
data: {
|
||||
sessionID: "child",
|
||||
inboxID: "msg_prompt",
|
||||
item: { type: "user", delivery: "steer", payload: { text: "hello" } },
|
||||
},
|
||||
} as OpenCodeEvent)
|
||||
|
||||
expect(store.data.session_message.child?.map((message) => message.type)).toEqual([
|
||||
"agent-switched",
|
||||
"model-switched",
|
||||
"user",
|
||||
])
|
||||
expect(store.data.message.child?.find((message) => message.id === "msg_prompt")).toMatchObject({
|
||||
agent: "review",
|
||||
model: { providerID: "new-provider", modelID: "new-model" },
|
||||
})
|
||||
})
|
||||
|
||||
test("removes an echoed prompt when submission fails", () => {
|
||||
const store = setup({ child: session("child") }).store
|
||||
store.inbox.echo(promptEcho("msg_prompt"))
|
||||
|
||||
expect(store.inbox.clearEcho({ sessionID: "child", messageID: "msg_prompt" })).toBe(true)
|
||||
|
||||
expect(store.data.pending.child).toEqual([])
|
||||
expect(store.data.input.child).toEqual([])
|
||||
expect(store.data.session_message.child).toBeUndefined()
|
||||
expect(store.data.message.child).toEqual([])
|
||||
expect(store.data.part.msg_prompt).toBeUndefined()
|
||||
})
|
||||
|
||||
test("removes a response-confirmed echo when the server cancels it", () => {
|
||||
const store = setup({ child: session("child") }).store
|
||||
store.inbox.echo(promptEcho("msg_prompt"))
|
||||
store.inbox.confirm({
|
||||
id: "msg_prompt",
|
||||
sessionID: "child",
|
||||
timeCreated: 1,
|
||||
type: "user",
|
||||
delivery: "steer",
|
||||
payload: { text: "hello" },
|
||||
})
|
||||
|
||||
store.applyV2({
|
||||
id: "evt_cancelled",
|
||||
created: 2,
|
||||
type: "session.inbox.cancelled",
|
||||
durable: { aggregateID: "child", seq: 2, version: 1 },
|
||||
data: { sessionID: "child", inboxID: "msg_prompt" },
|
||||
} as OpenCodeEvent)
|
||||
|
||||
expect(store.data.pending.child).toEqual([])
|
||||
expect(store.data.message.child).toEqual([])
|
||||
expect(store.data.part.msg_prompt).toBeUndefined()
|
||||
expect(store.data.message.child).toEqual([message])
|
||||
expect(store.data.part[message.id]).toEqual([part])
|
||||
})
|
||||
|
||||
test("clears stale parts when the initial page has none", async () => {
|
||||
@@ -1697,6 +1469,28 @@ describe("server session", () => {
|
||||
expect(store.data.part[message.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("preserves optimistic re-adds across message retries", async () => {
|
||||
const failed = Promise.withResolvers<MessageResponse>()
|
||||
const retried = Promise.withResolvers<MessageResponse>()
|
||||
const message = userMessage("message")
|
||||
const stale = textPart(message.id, { id: "stale", text: "stale" })
|
||||
const optimistic = textPart(message.id, { id: "optimistic", text: "optimistic" })
|
||||
const client = messageClient(response([{ info: message, parts: [stale] }]), failed.promise, retried.promise)
|
||||
const store = createServerSession(client, { retry: retryImmediately })
|
||||
await store.sync("child")
|
||||
const loading = store.sync("child", { force: true })
|
||||
|
||||
store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [optimistic] })
|
||||
failed.reject(new Error("failed to fetch"))
|
||||
await client.requested(3)
|
||||
retried.resolve(response([{ info: message, parts: [stale] }]))
|
||||
await loading
|
||||
|
||||
expect(store.data.message.child).toEqual([message])
|
||||
expect(store.data.part[message.id]).toEqual([optimistic])
|
||||
})
|
||||
|
||||
test("accepts part omission from a successful retry after an earlier delta", async () => {
|
||||
const failed = Promise.withResolvers<MessageResponse>()
|
||||
const retried = Promise.withResolvers<MessageResponse>()
|
||||
@@ -1860,6 +1654,33 @@ describe("server session", () => {
|
||||
expect(store.data.part[message.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("does not cache skipped optimistic parts", () => {
|
||||
const message = userMessage("message")
|
||||
const part = { id: "part", sessionID: "child", messageID: message.id, type: "step-start" as const }
|
||||
const store = setup({ child: session("child") }).store
|
||||
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [part] })
|
||||
|
||||
expect(store.data.part[message.id]).toEqual([])
|
||||
})
|
||||
|
||||
test("clears stale delta buffers when replacing optimistic parts", () => {
|
||||
const message = userMessage("message")
|
||||
const stale = textPart(message.id, { id: "stale", text: "stale" })
|
||||
const optimistic = textPart(message.id, { id: "optimistic", text: "optimistic" })
|
||||
const store = setup({ child: session("child") }).store
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [stale] })
|
||||
store.apply({
|
||||
type: "message.part.delta",
|
||||
properties: { sessionID: "child", messageID: message.id, partID: stale.id, field: "text", delta: " delta" },
|
||||
})
|
||||
|
||||
store.optimistic.add({ sessionID: "child", message, parts: [optimistic] })
|
||||
|
||||
expect(store.data.part_text_accum_delta[stale.id]).toBeUndefined()
|
||||
expect(store.data.part_text_accum_delta[optimistic.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("preserves removals during history prepend", async () => {
|
||||
const pending = deferredResponse()
|
||||
const latest = userMessage("message-2", { time: { created: 2 } })
|
||||
@@ -2085,7 +1906,24 @@ describe("server session", () => {
|
||||
test("preserves pinned session content under server-wide cache pressure", () => {
|
||||
const ctx = setup({})
|
||||
ctx.store.pin("active")
|
||||
ctx.store.inbox.echo({ ...promptEcho("message", "keep"), sessionID: "active" })
|
||||
ctx.store.optimistic.add({
|
||||
sessionID: "active",
|
||||
message: {
|
||||
id: "message",
|
||||
sessionID: "active",
|
||||
role: "assistant",
|
||||
time: { created: 1 },
|
||||
parentID: "parent",
|
||||
modelID: "model",
|
||||
providerID: "provider",
|
||||
mode: "build",
|
||||
agent: "agent",
|
||||
path: { cwd: "/repo", root: "/repo" },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
},
|
||||
parts: [],
|
||||
})
|
||||
|
||||
for (let index = 0; index < 50; index++) {
|
||||
ctx.store.remember(session(`session-${index}`))
|
||||
|
||||
@@ -18,22 +18,41 @@ 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"]
|
||||
|
||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||
const messagePageSize = 200
|
||||
const initialMessagePageSize = 20
|
||||
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) =>
|
||||
@@ -45,6 +64,13 @@ function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) {
|
||||
return boundary?.type === "assistant"
|
||||
}
|
||||
|
||||
type OptimisticItem = {
|
||||
message: Message
|
||||
parts: Part[]
|
||||
confirmedParts?: Part[]
|
||||
confirmedMessage?: boolean
|
||||
}
|
||||
|
||||
type MessagePage = {
|
||||
session: Message[]
|
||||
part: { id: string; part: Part[] }[]
|
||||
@@ -55,18 +81,6 @@ type MessagePage = {
|
||||
complete: boolean
|
||||
}
|
||||
|
||||
export type PromptEcho = {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
text: string
|
||||
displayText: string
|
||||
agent: string
|
||||
model: { providerID: string; modelID: string; variant?: string }
|
||||
files?: { uri: string; mime: string; name?: string; mention?: { start: number; end: number; text: string } }[]
|
||||
agents?: { name: string; mention?: { start: number; end: number; text: string } }[]
|
||||
comments: PromptComment[]
|
||||
}
|
||||
|
||||
// Most markers describe the current HTTP attempt; deltaParts persists non-durable stream state across retries.
|
||||
type MessageLoadState = {
|
||||
touchedMessages: Set<string>
|
||||
@@ -76,6 +90,7 @@ type MessageLoadState = {
|
||||
deltaParts: Map<string, Set<string>>
|
||||
carriedDeltaParts: Map<string, Set<string>>
|
||||
removedParts: Map<string, Set<string>>
|
||||
optimisticParts: Map<string, Set<string>>
|
||||
orphanParents: Set<string>
|
||||
clearedMessageParts: Set<string>
|
||||
touchedSource: Set<string>
|
||||
@@ -86,6 +101,34 @@ type MessageLoadBaseline = Pick<
|
||||
"touchedMessages" | "retainedMessages" | "touchedParts" | "clearedMessageParts"
|
||||
>
|
||||
|
||||
function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
|
||||
if (items.length === 0) return { ...page, observed: [] as { messageID: string; parts: Part[] }[] }
|
||||
const session = [...page.session]
|
||||
const part = new Map(page.part.map((item) => [item.id, item.part]))
|
||||
const observed: { messageID: string; parts: Part[] }[] = []
|
||||
for (const item of items) {
|
||||
const result = Binary.search(session, messageKey(item.message), messageKey)
|
||||
const found = result.found
|
||||
if (!found) session.splice(result.index, 0, item.message)
|
||||
const current = part.get(item.message.id)
|
||||
const confirmed = found ? item.parts.filter((part) => current?.some((value) => value.id === part.id)) : []
|
||||
if (found) observed.push({ messageID: item.message.id, parts: confirmed })
|
||||
part.set(
|
||||
item.message.id,
|
||||
merge(
|
||||
found ? (current ?? []) : merge(item.confirmedParts ?? [], current ?? []),
|
||||
item.parts.filter((part) => !confirmed.includes(part)),
|
||||
),
|
||||
)
|
||||
}
|
||||
return {
|
||||
...page,
|
||||
session,
|
||||
part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, parts]) => ({ id, part: parts })),
|
||||
observed,
|
||||
}
|
||||
}
|
||||
|
||||
function runInflight(map: Map<string, Promise<void>>, key: string, task: () => Promise<void>) {
|
||||
const pending = map.get(key)
|
||||
if (pending) return pending
|
||||
@@ -160,7 +203,6 @@ export function createServerSession(
|
||||
input: {} as Record<string, string[]>,
|
||||
message: {} as Record<string, Message[]>,
|
||||
session_message: {} as Record<string, SessionMessageInfo[]>,
|
||||
// Part order is semantic and follows SessionMessageAssistant.content; IDs identify parts only.
|
||||
part: {} as Record<string, Part[]>,
|
||||
part_text_accum_delta: {} as Record<string, string>,
|
||||
session_working(id: string) {
|
||||
@@ -170,6 +212,7 @@ export function createServerSession(
|
||||
const requests = new Map<string, Promise<SessionInfo>>()
|
||||
const inflight = new Map<string, Promise<void>>()
|
||||
const inflightTodo = new Map<string, Promise<void>>()
|
||||
const optimistic = new Map<string, Map<string, OptimisticItem>>()
|
||||
const v2 = createV2SessionReducer()
|
||||
const pendingRevision = new Map<string, number>()
|
||||
const formRevision = new Map<string, number>()
|
||||
@@ -180,45 +223,7 @@ export function createServerSession(
|
||||
const pendingParts = new Map<string, Map<string, Set<string>>>()
|
||||
const orphanParts = new Map<string, Set<string>>()
|
||||
const removedMessages = new Map<string, Set<string>>()
|
||||
const echoes = new Map<string, Map<string, "sending" | "admitted">>()
|
||||
const messageSnapshots = new Map<string, Set<string>>()
|
||||
const settledInputs = new Map<string, Set<string>>()
|
||||
const deltaBases = new Map<string, { base: string; sessionID: string }>()
|
||||
const markEcho = (sessionID: string, messageID: string) => {
|
||||
const messages = echoes.get(sessionID) ?? new Map<string, "sending" | "admitted">()
|
||||
messages.set(messageID, "sending")
|
||||
echoes.set(sessionID, messages)
|
||||
}
|
||||
const confirmEcho = (sessionID: string, messageID: string) => {
|
||||
const messages = echoes.get(sessionID)
|
||||
if (!messages?.has(messageID)) return false
|
||||
messages.set(messageID, "admitted")
|
||||
return true
|
||||
}
|
||||
const releaseEcho = (sessionID: string, messageID: string) => {
|
||||
const messages = echoes.get(sessionID)
|
||||
const state = messages?.get(messageID)
|
||||
if (!messages || !state) return
|
||||
messages.delete(messageID)
|
||||
if (messages.size === 0) echoes.delete(sessionID)
|
||||
return state
|
||||
}
|
||||
const present = (messageID: string, parts: Part[]) => {
|
||||
const local = data.part[messageID] ?? []
|
||||
const comments = local.filter(
|
||||
(part) =>
|
||||
part.type === "text" &&
|
||||
part.synthetic &&
|
||||
(readCommentMetadata(part.metadata) !== undefined || parseCommentNote(part.text) !== undefined),
|
||||
)
|
||||
if (!comments.length) return parts
|
||||
const text = local.find((part) => part.type === "text" && !part.synthetic)
|
||||
const projected = parts.flatMap((part) => {
|
||||
if (part.id !== `${messageID}:text:0` || part.type !== "text") return [part]
|
||||
return text?.type === "text" && text.text ? [{ ...part, text: text.text }] : []
|
||||
})
|
||||
return [...projected, ...comments]
|
||||
}
|
||||
const deleteMessageParts = (
|
||||
cache: { part: Record<string, Part[] | undefined>; part_text_accum_delta: Record<string, string | undefined> },
|
||||
messageID: string,
|
||||
@@ -241,12 +246,25 @@ export function createServerSession(
|
||||
return created
|
||||
}
|
||||
const [meta, setMeta] = createStore({
|
||||
limit: {} as Record<string, number | undefined>,
|
||||
cursor: {} as Record<string, string | undefined>,
|
||||
complete: {} as Record<string, boolean | undefined>,
|
||||
loading: {} as Record<string, boolean | undefined>,
|
||||
at: {} as Record<string, number | undefined>,
|
||||
})
|
||||
|
||||
const indexProjectedMessage = (message: Message) => {
|
||||
const current = data.session_message[message.sessionID] ?? []
|
||||
if (current.some((item) => item.id === message.id)) return
|
||||
const projected = projectMessageSource(message)
|
||||
const projectedIDs = new Set(projected.map((item) => item.id))
|
||||
setData(
|
||||
"session_message",
|
||||
message.sessionID,
|
||||
reconcile([...current.filter((item) => !projectedIDs.has(item.id)), ...projected]),
|
||||
)
|
||||
}
|
||||
|
||||
const remember = (session: SessionInfo) => {
|
||||
setData("info", session.id, reconcile(session))
|
||||
infoSeen.delete(session.id)
|
||||
@@ -258,7 +276,7 @@ export function createServerSession(
|
||||
...inflight.keys(),
|
||||
...inflightTodo.keys(),
|
||||
...messageLoads.keys(),
|
||||
...echoes.keys(),
|
||||
...optimistic.keys(),
|
||||
...Object.entries(data.permission)
|
||||
.filter(([, items]) => items.length > 0)
|
||||
.map(([sessionID]) => sessionID),
|
||||
@@ -334,6 +352,65 @@ export function createServerSession(
|
||||
return { session, root }
|
||||
}
|
||||
|
||||
const clearOptimistic = (sessionID: string, messageID?: string) => {
|
||||
if (!messageID) {
|
||||
optimistic.delete(sessionID)
|
||||
return
|
||||
}
|
||||
const items = optimistic.get(sessionID)
|
||||
if (!items) return
|
||||
items.delete(messageID)
|
||||
if (items.size === 0) optimistic.delete(sessionID)
|
||||
}
|
||||
|
||||
const clearOptimisticPart = (sessionID: string, messageID: string, partID: string) => {
|
||||
const items = optimistic.get(sessionID)
|
||||
const item = items?.get(messageID)
|
||||
if (!items || !item) return
|
||||
const parts = item.parts.filter((part) => part.id !== partID)
|
||||
const confirmedParts = item.confirmedParts?.filter((part) => part.id !== partID)
|
||||
if (parts.length === 0) {
|
||||
clearOptimistic(sessionID, messageID)
|
||||
return
|
||||
}
|
||||
items.set(messageID, { ...item, parts, confirmedParts, confirmedMessage: true })
|
||||
}
|
||||
|
||||
const confirmOptimisticPart = (sessionID: string, messageID: string, part: Part) => {
|
||||
const items = optimistic.get(sessionID)
|
||||
const item = items?.get(messageID)
|
||||
if (!items || !item) return
|
||||
const parts = item.parts.filter((value) => value.id !== part.id)
|
||||
if (parts.length === 0) {
|
||||
clearOptimistic(sessionID, messageID)
|
||||
return
|
||||
}
|
||||
items.set(messageID, {
|
||||
...item,
|
||||
parts,
|
||||
confirmedParts: merge(item.confirmedParts ?? [], [part]),
|
||||
confirmedMessage: true,
|
||||
})
|
||||
}
|
||||
|
||||
const confirmOptimistic = (sessionID: string, messageID: string, confirmedParts: Part[]) => {
|
||||
const items = optimistic.get(sessionID)
|
||||
const item = items?.get(messageID)
|
||||
if (!items || !item) return
|
||||
const confirmed = new Set(confirmedParts.map((part) => part.id))
|
||||
const parts = item.parts.filter((part) => !confirmed.has(part.id))
|
||||
if (parts.length === 0) {
|
||||
clearOptimistic(sessionID, messageID)
|
||||
return
|
||||
}
|
||||
items.set(messageID, {
|
||||
...item,
|
||||
parts,
|
||||
confirmedParts: merge(item.confirmedParts ?? [], confirmedParts),
|
||||
confirmedMessage: true,
|
||||
})
|
||||
}
|
||||
|
||||
const trackPartChange = (sessionID: string, messageID: string, partID: string) => {
|
||||
const load = messageLoads.get(sessionID)
|
||||
if (!load) return
|
||||
@@ -371,6 +448,14 @@ export function createServerSession(
|
||||
const messages = data.message[sessionID]
|
||||
if (messages?.some((message) => message.id === messageID)) load.retainedMessages.add(messageID)
|
||||
}
|
||||
for (const [messageID, parts] of load.optimisticParts) {
|
||||
load.removedMessages.delete(messageID)
|
||||
load.clearedMessageParts.add(messageID)
|
||||
load.touchedMessages.add(messageID)
|
||||
const touched = load.touchedParts.get(messageID) ?? new Set<string>()
|
||||
parts.forEach((partID) => touched.add(partID))
|
||||
load.touchedParts.set(messageID, touched)
|
||||
}
|
||||
baseline?.touchedMessages.forEach((messageID) => load.touchedMessages.add(messageID))
|
||||
baseline?.retainedMessages.forEach((messageID) => load.retainedMessages.add(messageID))
|
||||
baseline?.clearedMessageParts.forEach((messageID) => load.clearedMessageParts.add(messageID))
|
||||
@@ -401,9 +486,7 @@ export function createServerSession(
|
||||
sessionIDs.forEach((sessionID) => {
|
||||
messageHydrationRevision.set(sessionID, (messageHydrationRevision.get(sessionID) ?? 0) + 1)
|
||||
generations.delete(sessionID)
|
||||
echoes.delete(sessionID)
|
||||
messageSnapshots.delete(sessionID)
|
||||
settledInputs.delete(sessionID)
|
||||
clearOptimistic(sessionID)
|
||||
requests.delete(sessionID)
|
||||
inflight.delete(sessionID)
|
||||
inflightTodo.delete(sessionID)
|
||||
@@ -421,6 +504,7 @@ export function createServerSession(
|
||||
setMeta(
|
||||
produce((draft) => {
|
||||
for (const sessionID of sessionIDs) {
|
||||
delete draft.limit[sessionID]
|
||||
delete draft.cursor[sessionID]
|
||||
delete draft.complete[sessionID]
|
||||
delete draft.loading[sessionID]
|
||||
@@ -437,7 +521,7 @@ export function createServerSession(
|
||||
...inflight.keys(),
|
||||
...inflightTodo.keys(),
|
||||
...messageLoads.keys(),
|
||||
...echoes.keys(),
|
||||
...optimistic.keys(),
|
||||
...Object.entries(data.permission)
|
||||
.filter(([, items]) => items.length > 0)
|
||||
.map(([sessionID]) => sessionID),
|
||||
@@ -454,13 +538,11 @@ export function createServerSession(
|
||||
pickSessionCacheEvictions({ seen, keep: sessionID, limit: SESSION_CACHE_LIMIT, preserve: protectedSessions() }),
|
||||
)
|
||||
|
||||
const fetchMessages = async (sessionID: string, before?: string, onAttempt?: () => void) => {
|
||||
const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => {
|
||||
const request = (cursor?: string) =>
|
||||
(options?.retry ?? retry)(() => {
|
||||
onAttempt?.()
|
||||
return messageApi.list(
|
||||
cursor ? { sessionID, limit: messagePageSize, cursor } : { sessionID, limit: messagePageSize, order: "desc" },
|
||||
)
|
||||
return messageApi.list(cursor ? { sessionID, limit, cursor } : { sessionID, limit, order: "desc" })
|
||||
})
|
||||
const first = await request(before)
|
||||
const pages = [first]
|
||||
@@ -474,7 +556,9 @@ export function createServerSession(
|
||||
const normalized = normalizeSessionMessages(sessionID, source)
|
||||
return {
|
||||
session: normalized.messages.sort(compareMessages),
|
||||
part: [...normalized.parts.entries()].map(([id, part]) => ({ id, part })).sort((a, b) => cmp(a.id, b.id)),
|
||||
part: [...normalized.parts.entries()]
|
||||
.map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) }))
|
||||
.sort((a, b) => cmp(a.id, b.id)),
|
||||
source,
|
||||
sourceMode: before ? ("older" as const) : ("latest" as const),
|
||||
projectSource: true,
|
||||
@@ -514,10 +598,9 @@ export function createServerSession(
|
||||
) => {
|
||||
for (const item of items) {
|
||||
if (!messageIDs.has(item.id)) continue
|
||||
const fetched = present(
|
||||
item.id,
|
||||
load?.clearedMessageParts.has(item.id) ? [] : item.part.filter((part) => !SKIP_PARTS.has(part.type)),
|
||||
)
|
||||
const fetched = load?.clearedMessageParts.has(item.id)
|
||||
? []
|
||||
: item.part.filter((part) => !SKIP_PARTS.has(part.type))
|
||||
const fetchedIDs = new Set(fetched.map((part) => part.id))
|
||||
const pending = pendingParts.get(sessionID)?.get(item.id)
|
||||
const touched = new Set([...(load?.touchedParts.get(item.id) ?? []), ...(pending ?? [])])
|
||||
@@ -568,56 +651,47 @@ export function createServerSession(
|
||||
preserveUnfetched: boolean | ((message: Message) => boolean),
|
||||
cleanupOrphans: boolean,
|
||||
) => {
|
||||
if (page.sourceMode === "latest")
|
||||
messageSnapshots.set(sessionID, new Set((page.source ?? []).map((message) => message.id)))
|
||||
page.source?.forEach((message) => releaseEcho(sessionID, message.id))
|
||||
const source = page.source
|
||||
? (() => {
|
||||
const incoming = new Map(page.source.map((message) => [message.id, message]))
|
||||
const existing = data.session_message[sessionID] ?? []
|
||||
const boundary = Math.min(...page.source.map((message) => message.time.created))
|
||||
const inbox = new Set(data.input[sessionID] ?? [])
|
||||
const current = existing.filter(
|
||||
(message) =>
|
||||
!incoming.has(message.id) &&
|
||||
!inbox.has(message.id) &&
|
||||
(page.sourceMode === "older" ||
|
||||
load?.touchedSource.has(message.id) ||
|
||||
(!page.complete && message.time.created < boundary)),
|
||||
)
|
||||
// message.list never returns admitted-but-undelivered inbox entries; keep them after the
|
||||
// fetched history until a delivered or cancelled event resolves them.
|
||||
const admitted = existing.filter((message) => !incoming.has(message.id) && inbox.has(message.id))
|
||||
const combined =
|
||||
page.sourceMode === "older"
|
||||
? [...page.source, ...current, ...admitted]
|
||||
: [...current, ...page.source, ...admitted]
|
||||
const live = new Map(existing.map((message) => [message.id, message]))
|
||||
return combined.map((message) =>
|
||||
load?.touchedSource.has(message.id) ? (live.get(message.id) ?? message) : message,
|
||||
return (page.sourceMode === "older" ? [...page.source, ...current] : [...current, ...page.source]).map(
|
||||
(message) => (load?.touchedSource.has(message.id) ? (live.get(message.id) ?? message) : message),
|
||||
)
|
||||
})()
|
||||
: undefined
|
||||
const merged =
|
||||
const projected =
|
||||
page.projectSource && source
|
||||
? (() => {
|
||||
const normalized = normalizeSessionMessages(sessionID, source)
|
||||
return {
|
||||
...page,
|
||||
session: normalized.messages.sort(compareMessages),
|
||||
part: [...normalized.parts.entries()].map(([id, part]) => ({ id, part })).sort((a, b) => cmp(a.id, b.id)),
|
||||
part: [...normalized.parts.entries()]
|
||||
.map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) }))
|
||||
.sort((a, b) => cmp(a.id, b.id)),
|
||||
}
|
||||
})()
|
||||
: page
|
||||
const merged = mergeOptimisticPage(projected, [...(optimistic.get(sessionID)?.values() ?? [])])
|
||||
merged.observed.forEach((item) => {
|
||||
if (!load?.clearedMessageParts.has(item.messageID)) confirmOptimistic(sessionID, item.messageID, item.parts)
|
||||
})
|
||||
const touchedMessages = new Set([...(load?.touchedMessages ?? []), ...(removedMessages.get(sessionID) ?? [])])
|
||||
const messages = reconcileFetched(merged.session, data.message[sessionID] ?? [], {
|
||||
touched: touchedMessages,
|
||||
retained: load?.retainedMessages,
|
||||
removed: load?.removedMessages,
|
||||
preserveUnfetched: (message) =>
|
||||
echoes.get(sessionID)?.has(message.id) === true ||
|
||||
preserveUnfetched === true ||
|
||||
(typeof preserveUnfetched === "function" && preserveUnfetched(message)),
|
||||
preserveUnfetched,
|
||||
compare: compareMessages,
|
||||
})
|
||||
batch(() => {
|
||||
@@ -631,13 +705,14 @@ export function createServerSession(
|
||||
}
|
||||
orphanParts.delete(sessionID)
|
||||
}
|
||||
setMeta("limit", sessionID, messages.length)
|
||||
setMeta("cursor", sessionID, merged.cursor)
|
||||
setMeta("complete", sessionID, merged.complete)
|
||||
setMeta("at", sessionID, Date.now())
|
||||
})
|
||||
}
|
||||
|
||||
const loadMessages = async (sessionID: string, before?: string, mode?: "replace" | "prepend") => {
|
||||
const loadMessages = async (sessionID: string, limit: number, before?: string, mode?: "replace" | "prepend") => {
|
||||
if (meta.loading[sessionID]) return
|
||||
const active = generation(sessionID)
|
||||
const load: MessageLoadState = {
|
||||
@@ -648,6 +723,7 @@ export function createServerSession(
|
||||
deltaParts: new Map(),
|
||||
carriedDeltaParts: new Map(),
|
||||
removedParts: new Map(),
|
||||
optimisticParts: new Map(),
|
||||
orphanParents: new Set(),
|
||||
clearedMessageParts: new Set(),
|
||||
touchedSource: new Set(),
|
||||
@@ -656,7 +732,7 @@ export function createServerSession(
|
||||
setMeta("loading", sessionID, true)
|
||||
let applied = false
|
||||
try {
|
||||
const page = await fetchMessages(sessionID, before, () => resetMessageLoad(sessionID, load))
|
||||
const page = await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load))
|
||||
const first = page.session.reduce<Message | undefined>(
|
||||
(oldest, message) => (!oldest || compareMessages(message, oldest) < 0 ? message : oldest),
|
||||
undefined,
|
||||
@@ -668,7 +744,11 @@ export function createServerSession(
|
||||
const users = new Set([
|
||||
...page.session.filter((message) => message.role === "user").map((message) => message.id),
|
||||
...(data.message[sessionID] ?? [])
|
||||
.filter((message) => message.role === "user" && load.touchedMessages.has(message.id))
|
||||
.filter((message) => {
|
||||
if (message.role !== "user") return false
|
||||
const item = optimistic.get(sessionID)?.get(message.id)
|
||||
return load.touchedMessages.has(message.id) && (!item || item.confirmedMessage === true)
|
||||
})
|
||||
.map((message) => message.id),
|
||||
])
|
||||
const parentIDs = [
|
||||
@@ -735,30 +815,32 @@ export function createServerSession(
|
||||
}
|
||||
}
|
||||
|
||||
const sync = (sessionID: string, options?: { force?: boolean }) => {
|
||||
const sync = (sessionID: string, options?: { force?: boolean; messageLimit?: number }) => {
|
||||
touch(sessionID)
|
||||
return runInflight(inflight, sessionID, async () => {
|
||||
const cached = data.message[sessionID] !== undefined && meta.complete[sessionID] !== undefined
|
||||
const cached = data.message[sessionID] !== undefined && meta.limit[sessionID] !== undefined
|
||||
const invalid = invalidated.has(sessionID)
|
||||
const revision = invalidationRevision
|
||||
if (cached && data.info[sessionID] && !invalid && !options?.force) return
|
||||
await Promise.all([
|
||||
resolve(sessionID, invalid ? { ...options, force: true } : options),
|
||||
cached && !invalid && !options?.force ? Promise.resolve() : loadMessages(sessionID),
|
||||
cached && !invalid && !options?.force
|
||||
? Promise.resolve()
|
||||
: loadMessages(sessionID, options?.messageLimit ?? meta.limit[sessionID] ?? initialMessagePageSize),
|
||||
])
|
||||
if (invalid && invalidationRevision === revision) invalidated.delete(sessionID)
|
||||
})
|
||||
}
|
||||
|
||||
const prefetch = async (sessionID: string, messageCount: number) => {
|
||||
const prefetch = async (sessionID: string, limit: number) => {
|
||||
touch(sessionID)
|
||||
await inflight.get(sessionID)
|
||||
if (
|
||||
Date.now() - (meta.at[sessionID] ?? 0) <= 15_000 &&
|
||||
(meta.complete[sessionID] || (data.message[sessionID]?.length ?? 0) >= messageCount)
|
||||
(meta.complete[sessionID] || (data.message[sessionID]?.length ?? 0) >= limit)
|
||||
)
|
||||
return
|
||||
await runInflight(inflight, sessionID, () => loadMessages(sessionID))
|
||||
await runInflight(inflight, sessionID, () => loadMessages(sessionID, limit))
|
||||
}
|
||||
|
||||
const eventSessionID = (event: { type: string; properties?: unknown }) => {
|
||||
@@ -811,12 +893,12 @@ export function createServerSession(
|
||||
apply({ type: "message.updated", properties: { sessionID: reduction.sessionID, info: message } })
|
||||
}
|
||||
for (const messageID of touched) {
|
||||
const next = present(messageID, normalized.parts.get(messageID) ?? [])
|
||||
const next = normalized.parts.get(messageID) ?? []
|
||||
const nextIDs = new Set(next.map((part) => part.id))
|
||||
for (const part of next) {
|
||||
apply({ type: "message.part.updated", properties: { sessionID: reduction.sessionID, part } })
|
||||
}
|
||||
for (const part of [...(data.part[messageID] ?? [])]) {
|
||||
for (const part of data.part[messageID] ?? []) {
|
||||
if (nextIDs.has(part.id)) continue
|
||||
apply({
|
||||
type: "message.part.removed",
|
||||
@@ -844,67 +926,6 @@ export function createServerSession(
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
const removeEcho = (sessionID: string, messageID: string) => {
|
||||
if (!releaseEcho(sessionID, messageID)) return false
|
||||
pendingRevision.set(sessionID, (pendingRevision.get(sessionID) ?? 0) + 1)
|
||||
const load = messageLoads.get(sessionID)
|
||||
load?.touchedMessages.add(messageID)
|
||||
load?.removedMessages.add(messageID)
|
||||
load?.clearedMessageParts.add(messageID)
|
||||
batch(() => {
|
||||
setData("pending", sessionID, (items) => items?.filter((item) => item.id !== messageID))
|
||||
setData("input", sessionID, (items) => items?.filter((id) => id !== messageID))
|
||||
setData("message", sessionID, (messages) => messages?.filter((message) => message.id !== messageID))
|
||||
setData(produce((draft) => deleteMessageParts(draft, messageID)))
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
const confirmInbox = (item: SessionInboxInfo) => {
|
||||
if (!confirmEcho(item.sessionID, item.id)) return false
|
||||
v2.confirm(item)
|
||||
pendingRevision.set(item.sessionID, (pendingRevision.get(item.sessionID) ?? 0) + 1)
|
||||
const current = data.pending[item.sessionID] ?? []
|
||||
const index = current.findIndex((entry) => entry.id === item.id)
|
||||
if (index < 0) setData("pending", item.sessionID, [...current, item])
|
||||
if (index >= 0) setData("pending", item.sessionID, index, reconcile(item))
|
||||
return true
|
||||
}
|
||||
|
||||
const reconcileInbox = (sessionID: string) => {
|
||||
const pending = new Set((data.pending[sessionID] ?? []).map((item) => item.id))
|
||||
const fetched = messageSnapshots.get(sessionID) ?? new Set<string>()
|
||||
const removed = [...(settledInputs.get(sessionID) ?? [])].filter(
|
||||
(messageID) => !pending.has(messageID) && !fetched.has(messageID),
|
||||
)
|
||||
settledInputs.delete(sessionID)
|
||||
if (removed.length) {
|
||||
const ids = new Set(removed)
|
||||
const source = data.session_message[sessionID] ?? []
|
||||
projectV2({
|
||||
sessionID,
|
||||
messages: source.filter((message) => !ids.has(message.id)),
|
||||
touched: [],
|
||||
removed: source.filter((message) => ids.has(message.id)).map((message) => message.id),
|
||||
})
|
||||
}
|
||||
|
||||
const messages = echoes.get(sessionID)
|
||||
if (!messages) return
|
||||
const projected = new Set((data.session_message[sessionID] ?? []).map((message) => message.id))
|
||||
for (const [messageID, state] of messages) {
|
||||
if (projected.has(messageID)) {
|
||||
releaseEcho(sessionID, messageID)
|
||||
continue
|
||||
}
|
||||
if (pending.has(messageID)) {
|
||||
confirmEcho(sessionID, messageID)
|
||||
continue
|
||||
}
|
||||
if (state === "admitted") removeEcho(sessionID, messageID)
|
||||
}
|
||||
}
|
||||
|
||||
const applyV2 = (event: OpenCodeEvent) => {
|
||||
if (event.type === "form.created") {
|
||||
formRevision.set(event.data.form.sessionID, (formRevision.get(event.data.form.sessionID) ?? 0) + 1)
|
||||
@@ -928,9 +949,6 @@ export function createServerSession(
|
||||
}
|
||||
if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return
|
||||
const sessionID = event.data.sessionID
|
||||
if (event.type === "session.inbox.enqueued" || event.type === "session.inbox.delivered")
|
||||
releaseEcho(sessionID, event.data.inboxID)
|
||||
if (event.type === "session.inbox.cancelled") removeEcho(sessionID, event.data.inboxID)
|
||||
if (
|
||||
event.type === "session.inbox.enqueued" ||
|
||||
event.type === "session.inbox.delivery.changed" ||
|
||||
@@ -942,10 +960,11 @@ export function createServerSession(
|
||||
pendingRevision.set(sessionID, (pendingRevision.get(sessionID) ?? 0) + 1)
|
||||
if (event.type === "session.inbox.enqueued") {
|
||||
const current = data.pending[sessionID] ?? []
|
||||
const item = { id: event.data.inboxID, sessionID, timeCreated: event.created, ...event.data.item }
|
||||
const index = current.findIndex((entry) => entry.id === event.data.inboxID)
|
||||
if (index < 0) setData("pending", sessionID, [...current, item])
|
||||
if (index >= 0) setData("pending", sessionID, index, reconcile(item))
|
||||
if (!current.some((item) => item.id === event.data.inboxID))
|
||||
setData("pending", sessionID, [
|
||||
...current,
|
||||
{ id: event.data.inboxID, sessionID, timeCreated: event.created, ...event.data.item },
|
||||
])
|
||||
if (event.data.item.type !== "compaction" && !data.input[sessionID]?.includes(event.data.inboxID))
|
||||
setData("input", sessionID, [...(data.input[sessionID] ?? []), event.data.inboxID])
|
||||
}
|
||||
@@ -1082,9 +1101,16 @@ export function createServerSession(
|
||||
}
|
||||
case "message.updated": {
|
||||
const info = (event.properties as { info: Message }).info
|
||||
indexProjectedMessage(info)
|
||||
const load = messageLoads.get(info.sessionID)
|
||||
load?.touchedMessages.add(info.id)
|
||||
load?.removedMessages.delete(info.id)
|
||||
const items = optimistic.get(info.sessionID)
|
||||
const item = items?.get(info.id)
|
||||
if (items && item) {
|
||||
if (item.parts.length === 0) clearOptimistic(info.sessionID, info.id)
|
||||
if (item.parts.length > 0) items.set(info.id, { ...item, confirmedMessage: true })
|
||||
}
|
||||
const orphans = orphanParts.get(info.sessionID)
|
||||
orphans?.delete(info.id)
|
||||
if (orphans?.size === 0) orphanParts.delete(info.sessionID)
|
||||
@@ -1097,18 +1123,13 @@ export function createServerSession(
|
||||
return
|
||||
}
|
||||
const result = Binary.search(messages, messageKey(info), messageKey)
|
||||
if (result.found) {
|
||||
setData("message", info.sessionID, result.index, reconcile(info))
|
||||
return
|
||||
}
|
||||
// Delivery rewrites time.created, changing the sort key; reposition instead of duplicating.
|
||||
setData("message", info.sessionID, (value = []) => {
|
||||
const next = value.slice()
|
||||
const moved = next.findIndex((message) => message.id === info.id)
|
||||
if (moved >= 0) next.splice(moved, 1)
|
||||
next.splice(moved >= 0 && moved < result.index ? result.index - 1 : result.index, 0, info)
|
||||
return next
|
||||
})
|
||||
if (result.found) setData("message", info.sessionID, result.index, reconcile(info))
|
||||
if (!result.found)
|
||||
setData("message", info.sessionID, (value = []) => {
|
||||
const next = value.slice()
|
||||
next.splice(result.index, 0, info)
|
||||
return next
|
||||
})
|
||||
return
|
||||
}
|
||||
case "message.removed": {
|
||||
@@ -1123,11 +1144,13 @@ export function createServerSession(
|
||||
load?.deltaParts.delete(props.messageID)
|
||||
load?.carriedDeltaParts.delete(props.messageID)
|
||||
load?.removedParts.delete(props.messageID)
|
||||
load?.optimisticParts.delete(props.messageID)
|
||||
pendingParts.get(props.sessionID)?.delete(props.messageID)
|
||||
if (pendingParts.get(props.sessionID)?.size === 0) pendingParts.delete(props.sessionID)
|
||||
const removedMessagesForSession = removedMessages.get(props.sessionID) ?? new Set<string>()
|
||||
removedMessagesForSession.add(props.messageID)
|
||||
removedMessages.set(props.sessionID, removedMessagesForSession)
|
||||
clearOptimistic(props.sessionID, props.messageID)
|
||||
setData(
|
||||
produce((draft) => {
|
||||
const messages = draft.message[props.sessionID]
|
||||
@@ -1173,8 +1196,12 @@ export function createServerSession(
|
||||
pending?.delete(part.id)
|
||||
if (pending?.size === 0) pendingParts.get(part.sessionID)?.delete(part.messageID)
|
||||
if (pendingParts.get(part.sessionID)?.size === 0) pendingParts.delete(part.sessionID)
|
||||
const optimistic = load?.optimisticParts.get(part.messageID)
|
||||
optimistic?.delete(part.id)
|
||||
if (optimistic?.size === 0) load?.optimisticParts.delete(part.messageID)
|
||||
deltaBases.delete(part.id)
|
||||
trackPartChange(part.sessionID, part.messageID, part.id)
|
||||
confirmOptimisticPart(part.sessionID, part.messageID, part)
|
||||
setData(
|
||||
"part_text_accum_delta",
|
||||
produce((draft) => void delete draft[part.id]),
|
||||
@@ -1184,9 +1211,14 @@ export function createServerSession(
|
||||
setData("part", part.messageID, [part])
|
||||
return
|
||||
}
|
||||
const index = parts.findIndex((item) => item.id === part.id)
|
||||
if (index >= 0) setData("part", part.messageID, index, reconcile(part))
|
||||
if (index < 0) setData("part", part.messageID, (value = []) => [...value, part])
|
||||
const result = Binary.search(parts, part.id, (item) => item.id)
|
||||
if (result.found) setData("part", part.messageID, result.index, reconcile(part))
|
||||
if (!result.found)
|
||||
setData("part", part.messageID, (value = []) => {
|
||||
const next = value.slice()
|
||||
next.splice(result.index, 0, part)
|
||||
return next
|
||||
})
|
||||
return
|
||||
}
|
||||
case "message.part.removed": {
|
||||
@@ -1208,16 +1240,20 @@ export function createServerSession(
|
||||
const parts = load.removedParts.get(props.messageID) ?? new Set<string>()
|
||||
parts.add(props.partID)
|
||||
load.removedParts.set(props.messageID, parts)
|
||||
const optimistic = load.optimisticParts.get(props.messageID)
|
||||
optimistic?.delete(props.partID)
|
||||
if (optimistic?.size === 0) load.optimisticParts.delete(props.messageID)
|
||||
}
|
||||
trackPartChange(props.sessionID, props.messageID, props.partID)
|
||||
clearOptimisticPart(props.sessionID, props.messageID, props.partID)
|
||||
setData(
|
||||
produce((draft) => {
|
||||
delete draft.part_text_accum_delta[props.partID]
|
||||
deltaBases.delete(props.partID)
|
||||
const parts = draft.part[props.messageID]
|
||||
if (!parts) return
|
||||
const index = parts.findIndex((part) => part.id === props.partID)
|
||||
if (index >= 0) parts.splice(index, 1)
|
||||
const result = Binary.search(parts, props.partID, (part) => part.id)
|
||||
if (result.found) parts.splice(result.index, 1)
|
||||
if (parts.length === 0) delete draft.part[props.messageID]
|
||||
}),
|
||||
)
|
||||
@@ -1233,8 +1269,8 @@ export function createServerSession(
|
||||
}
|
||||
const parts = data.part[props.messageID]
|
||||
if (!parts) return
|
||||
const index = parts.findIndex((part) => part.id === props.partID)
|
||||
if (index < 0) return
|
||||
const result = Binary.search(parts, props.partID, (part) => part.id)
|
||||
if (!result.found) return
|
||||
trackPartChange(props.sessionID, props.messageID, props.partID)
|
||||
const load = messageLoads.get(props.sessionID)
|
||||
if (load) {
|
||||
@@ -1246,7 +1282,7 @@ export function createServerSession(
|
||||
if (carried?.size === 0) load.carriedDeltaParts.delete(props.messageID)
|
||||
}
|
||||
const field = props.field as keyof (typeof parts)[number]
|
||||
const current = parts[index]?.[field]
|
||||
const current = parts[result.index]?.[field]
|
||||
if (!deltaBases.has(props.partID) && typeof current === "string")
|
||||
deltaBases.set(props.partID, { base: current, sessionID: props.sessionID })
|
||||
setData(
|
||||
@@ -1259,7 +1295,7 @@ export function createServerSession(
|
||||
props.messageID,
|
||||
produce((draft) => {
|
||||
if (!draft) return
|
||||
const part = draft[index]
|
||||
const part = draft[result.index]
|
||||
const field = props.field as keyof typeof part
|
||||
;(part[field] as string) = ((part[field] as string | undefined) ?? "") + props.delta
|
||||
}),
|
||||
@@ -1318,30 +1354,25 @@ export function createServerSession(
|
||||
while (true) {
|
||||
const pendingAt = pendingRevision.get(sessionID) ?? 0
|
||||
const formAt = formRevision.get(sessionID) ?? 0
|
||||
const previous = new Set(data.input[sessionID] ?? [])
|
||||
const result = await load()
|
||||
const pendingStable = (pendingRevision.get(sessionID) ?? 0) === pendingAt
|
||||
const formStable = (formRevision.get(sessionID) ?? 0) === formAt
|
||||
if (pendingStable) {
|
||||
const current = new Set(result.pending.filter((item) => item.type !== "compaction").map((item) => item.id))
|
||||
const settled = settledInputs.get(sessionID) ?? new Set<string>()
|
||||
previous.forEach((messageID) => {
|
||||
if (!current.has(messageID)) settled.add(messageID)
|
||||
})
|
||||
if (settled.size) settledInputs.set(sessionID, settled)
|
||||
result.pending.forEach(v2.confirm)
|
||||
setData("pending", sessionID, reconcile(result.pending))
|
||||
setData("input", sessionID, reconcile([...current]))
|
||||
setData(
|
||||
"input",
|
||||
sessionID,
|
||||
reconcile(result.pending.filter((item) => item.type !== "compaction").map((item) => item.id)),
|
||||
)
|
||||
}
|
||||
if (formStable) setData("form", sessionID, reconcile(result.forms))
|
||||
if (pendingStable && formStable) return
|
||||
}
|
||||
},
|
||||
refreshPinned(hydrateTransient: (sessionID: string) => Promise<void>) {
|
||||
const sessions = [...pinned.keys()]
|
||||
return Promise.all(
|
||||
sessions.flatMap((sessionID) => [sync(sessionID, { force: true }), hydrateTransient(sessionID)]),
|
||||
).then(() => sessions.forEach(reconcileInbox))
|
||||
[...pinned.keys()].flatMap((sessionID) => [sync(sessionID, { force: true }), hydrateTransient(sessionID)]),
|
||||
).then(() => undefined)
|
||||
},
|
||||
invalidate() {
|
||||
invalidationRevision += 1
|
||||
@@ -1350,81 +1381,77 @@ export function createServerSession(
|
||||
setMeta("at", {})
|
||||
},
|
||||
prefetch,
|
||||
shouldPrefetch(sessionID: string, messageCount: number) {
|
||||
shouldPrefetch(sessionID: string, limit: number) {
|
||||
if (data.message[sessionID] === undefined) return true
|
||||
if (Date.now() - (meta.at[sessionID] ?? 0) > 15_000) return true
|
||||
if (meta.complete[sessionID]) return false
|
||||
return (data.message[sessionID]?.length ?? 0) <= messageCount
|
||||
return (meta.limit[sessionID] ?? 0) <= limit
|
||||
},
|
||||
fresh(sessionID: string, ttl: number) {
|
||||
return Date.now() - (meta.at[sessionID] ?? 0) <= ttl
|
||||
},
|
||||
inbox: {
|
||||
echo(input: PromptEcho) {
|
||||
const created = Date.now()
|
||||
const files = input.files?.map((file) => ({
|
||||
data: "",
|
||||
mime: file.mime,
|
||||
source: { type: "uri" as const, uri: file.uri },
|
||||
name: file.name,
|
||||
mention: file.mention,
|
||||
}))
|
||||
const item: SessionInboxInfo = {
|
||||
id: input.messageID,
|
||||
sessionID: input.sessionID,
|
||||
timeCreated: created,
|
||||
type: "user",
|
||||
delivery: "steer",
|
||||
payload: { text: input.text, files, agents: input.agents },
|
||||
optimistic: {
|
||||
add(input: { sessionID: string; message: Message; parts: Part[] }) {
|
||||
const parts = input.parts
|
||||
.filter((part) => !!part?.id && !SKIP_PARTS.has(part.type))
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
const load = messageLoads.get(input.sessionID)
|
||||
if (load?.clearedMessageParts.has(input.message.id)) {
|
||||
const touched = load.touchedParts.get(input.message.id) ?? new Set<string>()
|
||||
parts.forEach((part) => touched.add(part.id))
|
||||
load.touchedParts.set(input.message.id, touched)
|
||||
}
|
||||
const projected = normalizeSessionMessages(input.sessionID, [
|
||||
{ id: `${input.messageID}:agent`, type: "agent-switched", agent: input.agent, time: { created } },
|
||||
{
|
||||
id: `${input.messageID}:model`,
|
||||
type: "model-switched",
|
||||
model: {
|
||||
id: input.model.modelID,
|
||||
providerID: input.model.providerID,
|
||||
variant: input.model.variant,
|
||||
},
|
||||
time: { created },
|
||||
},
|
||||
{
|
||||
id: input.messageID,
|
||||
type: "user",
|
||||
text: input.displayText,
|
||||
files,
|
||||
agents: input.agents,
|
||||
time: { created },
|
||||
},
|
||||
])
|
||||
const message = projected.messages[0]!
|
||||
const comments: Part[] = input.comments.map((comment, index) => ({
|
||||
id: `${input.messageID}:comment:${index}`,
|
||||
sessionID: input.sessionID,
|
||||
messageID: input.messageID,
|
||||
type: "text",
|
||||
text: formatCommentNote(comment),
|
||||
synthetic: true,
|
||||
metadata: createCommentMetadata(comment),
|
||||
}))
|
||||
const parts = [...(projected.parts.get(input.messageID) ?? []), ...comments]
|
||||
removedMessages.get(input.sessionID)?.delete(input.messageID)
|
||||
markEcho(input.sessionID, input.messageID)
|
||||
pendingRevision.set(input.sessionID, (pendingRevision.get(input.sessionID) ?? 0) + 1)
|
||||
batch(() => {
|
||||
setData("pending", input.sessionID, (items = []) => [...items.filter((entry) => entry.id !== item.id), item])
|
||||
if (!data.input[input.sessionID]?.includes(input.messageID))
|
||||
setData("input", input.sessionID, [...(data.input[input.sessionID] ?? []), input.messageID])
|
||||
setData("message", input.sessionID, (messages = []) => merge(messages, [message]).sort(compareMessages))
|
||||
setData("part", input.messageID, parts)
|
||||
})
|
||||
if (load) {
|
||||
load.removedMessages.delete(input.message.id)
|
||||
load.optimisticParts.set(input.message.id, new Set(parts.map((part) => part.id)))
|
||||
}
|
||||
const items = optimistic.get(input.sessionID)
|
||||
const removedMessagesForSession = removedMessages.get(input.sessionID)
|
||||
removedMessagesForSession?.delete(input.message.id)
|
||||
if (removedMessagesForSession?.size === 0) removedMessages.delete(input.sessionID)
|
||||
if (items) items.set(input.message.id, { ...input, parts, confirmedParts: [] })
|
||||
if (!items)
|
||||
optimistic.set(input.sessionID, new Map([[input.message.id, { ...input, parts, confirmedParts: [] }]]))
|
||||
indexProjectedMessage(input.message)
|
||||
setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]).sort(compareMessages))
|
||||
setData(
|
||||
"part_text_accum_delta",
|
||||
produce((draft) => {
|
||||
for (const part of [...(data.part[input.message.id] ?? []), ...parts]) {
|
||||
delete draft[part.id]
|
||||
deltaBases.delete(part.id)
|
||||
}
|
||||
}),
|
||||
)
|
||||
setData("part", input.message.id, parts)
|
||||
},
|
||||
confirm: confirmInbox,
|
||||
reconcile: reconcileInbox,
|
||||
clearEcho(input: { sessionID: string; messageID: string }) {
|
||||
if (echoes.get(input.sessionID)?.get(input.messageID) !== "sending") return false
|
||||
return removeEcho(input.sessionID, input.messageID)
|
||||
remove(input: { sessionID: string; messageID: string }) {
|
||||
const item = optimistic.get(input.sessionID)?.get(input.messageID)
|
||||
if (!item) return
|
||||
messageLoads.get(input.sessionID)?.optimisticParts.delete(input.messageID)
|
||||
clearOptimistic(input.sessionID, input.messageID)
|
||||
if (item.confirmedMessage) {
|
||||
const partIDs = new Set(item.parts.map((part) => part.id))
|
||||
setData(
|
||||
produce((draft) => {
|
||||
for (const part of item.parts) {
|
||||
delete draft.part_text_accum_delta[part.id]
|
||||
deltaBases.delete(part.id)
|
||||
}
|
||||
const parts = draft.part[input.messageID]
|
||||
if (!parts) return
|
||||
draft.part[input.messageID] = parts.filter((part) => !partIDs.has(part.id))
|
||||
if (draft.part[input.messageID]?.length === 0) delete draft.part[input.messageID]
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
const projectedIDs = new Set(projectMessageSource(item.message).map((message) => message.id))
|
||||
setData("session_message", input.sessionID, (messages) =>
|
||||
messages?.filter((message) => !projectedIDs.has(message.id)),
|
||||
)
|
||||
setData("message", input.sessionID, (messages) => messages?.filter((message) => message.id !== input.messageID))
|
||||
setData(produce((draft) => deleteMessageParts(draft, input.messageID)))
|
||||
},
|
||||
},
|
||||
async todo(sessionID: string, request?: { force?: boolean }) {
|
||||
@@ -1436,14 +1463,14 @@ export function createServerSession(
|
||||
history: {
|
||||
more: (sessionID: string) =>
|
||||
data.message[sessionID] !== undefined &&
|
||||
meta.complete[sessionID] !== undefined &&
|
||||
meta.limit[sessionID] !== undefined &&
|
||||
!meta.complete[sessionID] &&
|
||||
!!meta.cursor[sessionID],
|
||||
loading: (sessionID: string) => meta.loading[sessionID] ?? false,
|
||||
async loadMore(sessionID: string) {
|
||||
async loadMore(sessionID: string, count = historyMessagePageSize) {
|
||||
touch(sessionID)
|
||||
if (meta.loading[sessionID] || meta.complete[sessionID] || !meta.cursor[sessionID]) return
|
||||
await loadMessages(sessionID, meta.cursor[sessionID], "prepend")
|
||||
await loadMessages(sessionID, count, meta.cursor[sessionID], "prepend")
|
||||
},
|
||||
},
|
||||
evict(sessionID: string) {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test"
|
||||
import type {
|
||||
McpListInput,
|
||||
McpResourceCatalogInput,
|
||||
OpenCodeEvent,
|
||||
SessionApi,
|
||||
SessionInfo,
|
||||
SessionListInput,
|
||||
@@ -16,13 +15,11 @@ import {
|
||||
loadMcpResourcesQuery,
|
||||
reconcileActiveSessionStatuses,
|
||||
seedActiveSessionStatuses,
|
||||
sessionListEventDirectories,
|
||||
shouldRefreshWorkspaceSessions,
|
||||
} from "./server-sync"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
import { createServerSession } from "./server-session"
|
||||
import type { ServerApi } from "@/utils/server"
|
||||
import { adaptServerEvent } from "./server-sdk"
|
||||
|
||||
type McpApi = ServerApi["mcp"]
|
||||
|
||||
@@ -217,23 +214,6 @@ describe("workspace session inventory", () => {
|
||||
expect(shouldRefreshWorkspaceSessions(event("session.updated", "session.moved"))).toBe(true)
|
||||
expect(shouldRefreshWorkspaceSessions(event("message.updated"))).toBe(false)
|
||||
})
|
||||
|
||||
test("invalidates both locations when a session moves", () => {
|
||||
const event = adaptServerEvent({
|
||||
id: "evt_moved",
|
||||
created: 1,
|
||||
type: "session.moved",
|
||||
durable: { aggregateID: "ses_1", seq: 1, version: 1 },
|
||||
location: { directory: "/source" },
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
location: { directory: "/destination" },
|
||||
projectID: "project_2",
|
||||
},
|
||||
} satisfies Extract<OpenCodeEvent, { type: "session.moved" }>)
|
||||
|
||||
expect(sessionListEventDirectories(event)).toEqual(["/source", "/destination"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("canDisposeDirectory", () => {
|
||||
|
||||
@@ -88,12 +88,6 @@ const SESSION_LIST_EVENTS = new Set([
|
||||
"session.usage.updated",
|
||||
])
|
||||
|
||||
export function sessionListEventDirectories(event: ServerEvent) {
|
||||
if (!SESSION_LIST_EVENTS.has(event.current?.type ?? event.type)) return []
|
||||
const destination = event.current?.type === "session.moved" ? event.current.data.location.directory : undefined
|
||||
return [...new Set([event.current?.location?.directory, destination].filter((item): item is string => !!item))]
|
||||
}
|
||||
|
||||
type McpListApi = {
|
||||
readonly list: (input?: McpListInput) => Promise<McpListOutput>
|
||||
}
|
||||
@@ -237,10 +231,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
return { pending, forms }
|
||||
})
|
||||
}
|
||||
const hydrateSession = async (sessionID: string) => {
|
||||
await Promise.all([session.sync(sessionID), hydrateSessionState(sessionID)])
|
||||
session.inbox.reconcile(sessionID)
|
||||
}
|
||||
const hydrateSession = (sessionID: string) => Promise.all([session.sync(sessionID), hydrateSessionState(sessionID)])
|
||||
|
||||
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
|
||||
queries: [
|
||||
@@ -560,11 +551,14 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
type: "session.updated",
|
||||
properties: { sessionID: info.id, info },
|
||||
})
|
||||
const markSessionListsChanged = (event: ServerEvent) => {
|
||||
sessionListEventDirectories(event).forEach((directory) => {
|
||||
const markSessionListChanged = (event: ServerEvent, directory: string, previousDirectory?: string) => {
|
||||
if (SESSION_LIST_EVENTS.has(event.current?.type ?? event.type)) {
|
||||
const key = directoryKey(directory)
|
||||
sessionRevision.set(key, (sessionRevision.get(key) ?? 0) + 1)
|
||||
})
|
||||
}
|
||||
if (!previousDirectory || previousDirectory === directory) return
|
||||
const key = directoryKey(previousDirectory)
|
||||
sessionRevision.set(key, (sessionRevision.get(key) ?? 0) + 1)
|
||||
}
|
||||
const toDirectoryEvent = (event: ServerEvent) => {
|
||||
if (event.current?.type === "session.created") return
|
||||
@@ -575,10 +569,15 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
}
|
||||
|
||||
const unsub = serverSDK.event.listen((e) => {
|
||||
const directory = e.name
|
||||
const key = directoryKey(directory)
|
||||
const event = e.details
|
||||
const directory = event.current?.location?.directory
|
||||
const eventType: string = event.type
|
||||
markSessionListsChanged(event)
|
||||
const previousDirectory =
|
||||
event.current?.type === "session.moved"
|
||||
? session.get(event.current.data.sessionID)?.location.directory
|
||||
: undefined
|
||||
markSessionListChanged(event, directory, previousDirectory)
|
||||
if (event.current) session.applyV2(event.current)
|
||||
session.apply(event)
|
||||
if (event.current?.type === "session.moved") {
|
||||
@@ -630,9 +629,9 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
}
|
||||
homeSessions.refresh(event.type)
|
||||
catalog.handleEvent({ type: eventType, directory })
|
||||
connection.handleEvent({ type: eventType })
|
||||
connection.handleEvent({ type: eventType, directory })
|
||||
|
||||
if (!directory) {
|
||||
if (directory === "global") {
|
||||
applyGlobalEvent({
|
||||
event,
|
||||
project: globalStore.project,
|
||||
@@ -645,7 +644,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
return
|
||||
}
|
||||
|
||||
const key = directoryKey(directory)
|
||||
if (event.current?.type === "session.forked")
|
||||
void session
|
||||
.resolve(event.current.data.sessionID, { force: true })
|
||||
|
||||
@@ -42,7 +42,7 @@ test("invalidates global and active catalogs after connection", async () => {
|
||||
load: async () => {},
|
||||
})
|
||||
|
||||
catalog.handleEvent({ type: "server.connected" })
|
||||
catalog.handleEvent({ type: "server.connected", directory: "global" })
|
||||
await Bun.sleep(0)
|
||||
|
||||
expect(queryClient.getQueryState(global)?.isInvalidated).toBe(true)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { pathKey, type PathKey } from "@/utils/path-key"
|
||||
|
||||
type CatalogEvent = {
|
||||
type: string
|
||||
directory?: string
|
||||
directory: string
|
||||
}
|
||||
|
||||
export function createCatalogSync(input: {
|
||||
@@ -24,7 +24,7 @@ export function createCatalogSync(input: {
|
||||
event.type === "integration.updated" ||
|
||||
event.type === "integration.connection.updated"
|
||||
) {
|
||||
void refresh(event.directory ? pathKey(event.directory) : null).catch(() => undefined)
|
||||
void refresh(event.directory === "global" ? null : pathKey(event.directory)).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,10 @@ test("invalidates disconnected data and synchronizes after the handshake", () =>
|
||||
connected: () => calls.push("connected"),
|
||||
})
|
||||
|
||||
connection.handleEvent({ type: "server.connected" })
|
||||
connection.handleEvent({ type: "server.connected", directory: "global" })
|
||||
expect(calls).toContain("connected")
|
||||
connection.handleEvent({ type: "server.connected", directory: "/repo" })
|
||||
expect(calls.filter((call) => call === "connected")).toHaveLength(1)
|
||||
setStatus("connected")
|
||||
return dispose
|
||||
})
|
||||
|
||||
@@ -12,8 +12,8 @@ export function createConnectionSync(input: {
|
||||
})
|
||||
|
||||
let connectedOnce = false
|
||||
function handleEvent(event: { type: string }) {
|
||||
if (event.type !== "server.connected") return
|
||||
function handleEvent(event: { type: string; directory: string }) {
|
||||
if (event.directory !== "global" || event.type !== "server.connected") return
|
||||
input.connected({ reconnect: connectedOnce })
|
||||
connectedOnce = true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message, Part } from "@/types"
|
||||
import { applyOptimisticAdd, applyOptimisticRemove, mergeOptimisticPage } from "./sync"
|
||||
|
||||
type Text = Extract<Part, { type: "text" }>
|
||||
|
||||
const userMessage = (id: string, sessionID: string, created = 1): Message => ({
|
||||
id,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created },
|
||||
agent: "assistant",
|
||||
model: { providerID: "openai", modelID: "gpt" },
|
||||
})
|
||||
|
||||
const textPart = (id: string, sessionID: string, messageID: string): Text => ({
|
||||
id,
|
||||
sessionID,
|
||||
messageID,
|
||||
type: "text",
|
||||
text: id,
|
||||
})
|
||||
|
||||
describe("sync optimistic reducers", () => {
|
||||
test("applyOptimisticAdd inserts by creation time", () => {
|
||||
const sessionID = "ses_1"
|
||||
const draft = {
|
||||
message: { [sessionID]: [userMessage("msg_z", sessionID, 1)] },
|
||||
part: {} as Record<string, Part[] | undefined>,
|
||||
}
|
||||
|
||||
applyOptimisticAdd(draft, {
|
||||
sessionID,
|
||||
message: userMessage("msg_a", sessionID, 2),
|
||||
parts: [textPart("prt_2", sessionID, "msg_a"), textPart("prt_1", sessionID, "msg_a")],
|
||||
})
|
||||
|
||||
expect(draft.message[sessionID]?.map((x) => x.id)).toEqual(["msg_z", "msg_a"])
|
||||
expect(draft.part.msg_a?.map((x) => x.id)).toEqual(["prt_1", "prt_2"])
|
||||
})
|
||||
|
||||
test("applyOptimisticRemove removes message and part entries", () => {
|
||||
const sessionID = "ses_1"
|
||||
const draft = {
|
||||
message: { [sessionID]: [userMessage("msg_1", sessionID), userMessage("msg_2", sessionID)] },
|
||||
part: {
|
||||
msg_1: [textPart("prt_1", sessionID, "msg_1")],
|
||||
msg_2: [textPart("prt_2", sessionID, "msg_2")],
|
||||
} as Record<string, Part[] | undefined>,
|
||||
}
|
||||
|
||||
applyOptimisticRemove(draft, { sessionID, messageID: "msg_1" })
|
||||
|
||||
expect(draft.message[sessionID]?.map((x) => x.id)).toEqual(["msg_2"])
|
||||
expect(draft.part.msg_1).toBeUndefined()
|
||||
expect(draft.part.msg_2).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("mergeOptimisticPage keeps pending messages in fetched timelines", () => {
|
||||
const sessionID = "ses_1"
|
||||
const page = mergeOptimisticPage(
|
||||
{
|
||||
session: [userMessage("msg_z", sessionID, 1)],
|
||||
part: [{ id: "msg_z", part: [textPart("prt_1", sessionID, "msg_z")] }],
|
||||
complete: true,
|
||||
},
|
||||
[{ message: userMessage("msg_a", sessionID, 2), parts: [textPart("prt_2", sessionID, "msg_a")] }],
|
||||
)
|
||||
|
||||
expect(page.session.map((x) => x.id)).toEqual(["msg_z", "msg_a"])
|
||||
expect(page.part.find((x) => x.id === "msg_a")?.part.map((x) => x.id)).toEqual(["prt_2"])
|
||||
expect(page.confirmed).toEqual([])
|
||||
expect(page.complete).toBe(true)
|
||||
})
|
||||
|
||||
test("mergeOptimisticPage uses IDs only to break equal-time ties", () => {
|
||||
const sessionID = "ses_1"
|
||||
const page = mergeOptimisticPage(
|
||||
{
|
||||
session: [userMessage("msg_z", sessionID, 1)],
|
||||
part: [],
|
||||
complete: true,
|
||||
},
|
||||
[{ message: userMessage("msg_a", sessionID, 1), parts: [] }],
|
||||
)
|
||||
|
||||
expect(page.session.map((message) => message.id)).toEqual(["msg_a", "msg_z"])
|
||||
})
|
||||
|
||||
test("mergeOptimisticPage keeps missing optimistic parts until the server has them", () => {
|
||||
const sessionID = "ses_1"
|
||||
const page = mergeOptimisticPage(
|
||||
{
|
||||
session: [userMessage("msg_2", sessionID)],
|
||||
part: [{ id: "msg_2", part: [textPart("prt_2", sessionID, "msg_2")] }],
|
||||
complete: true,
|
||||
},
|
||||
[
|
||||
{
|
||||
message: userMessage("msg_2", sessionID),
|
||||
parts: [textPart("prt_1", sessionID, "msg_2"), textPart("prt_2", sessionID, "msg_2")],
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
expect(page.part.find((x) => x.id === "msg_2")?.part.map((x) => x.id)).toEqual(["prt_1", "prt_2"])
|
||||
expect(page.confirmed).toEqual([])
|
||||
})
|
||||
|
||||
test("mergeOptimisticPage confirms echoed messages once all parts arrive", () => {
|
||||
const sessionID = "ses_1"
|
||||
const page = mergeOptimisticPage(
|
||||
{
|
||||
session: [userMessage("msg_2", sessionID)],
|
||||
part: [
|
||||
{
|
||||
id: "msg_2",
|
||||
part: [{ ...textPart("prt_1", sessionID, "msg_2"), text: "server" }, textPart("prt_2", sessionID, "msg_2")],
|
||||
},
|
||||
],
|
||||
complete: true,
|
||||
},
|
||||
[
|
||||
{
|
||||
message: userMessage("msg_2", sessionID),
|
||||
parts: [textPart("prt_1", sessionID, "msg_2"), textPart("prt_2", sessionID, "msg_2")],
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
expect(page.confirmed).toEqual(["msg_2"])
|
||||
expect(page.part.find((x) => x.id === "msg_2")?.part).toMatchObject([
|
||||
{ id: "prt_1", type: "text", text: "server" },
|
||||
{ id: "prt_2", type: "text", text: "prt_2" },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,114 @@
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { createMemo } from "solid-js"
|
||||
import { useServerSync } from "./server-sync"
|
||||
import { useSDK } from "./sdk"
|
||||
import type { Message, Part } from "@/types"
|
||||
import { messageKey } from "@/utils/session-message"
|
||||
|
||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||
|
||||
function sortParts(parts: Part[]) {
|
||||
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
|
||||
}
|
||||
|
||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
|
||||
type OptimisticStore = {
|
||||
message: Record<string, Message[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
}
|
||||
|
||||
type OptimisticAddInput = {
|
||||
sessionID: string
|
||||
message: Message
|
||||
parts: Part[]
|
||||
}
|
||||
|
||||
type OptimisticRemoveInput = {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
}
|
||||
|
||||
type OptimisticItem = {
|
||||
message: Message
|
||||
parts: Part[]
|
||||
}
|
||||
|
||||
type MessagePage = {
|
||||
session: Message[]
|
||||
part: { id: string; part: Part[] }[]
|
||||
cursor?: string
|
||||
complete: boolean
|
||||
}
|
||||
|
||||
const hasParts = (parts: Part[] | undefined, want: Part[]) => {
|
||||
if (!parts) return want.length === 0
|
||||
return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found)
|
||||
}
|
||||
|
||||
const mergeParts = (parts: Part[] | undefined, want: Part[]) => {
|
||||
if (!parts) return sortParts(want)
|
||||
const next = [...parts]
|
||||
let changed = false
|
||||
for (const part of want) {
|
||||
const result = Binary.search(next, part.id, (item) => item.id)
|
||||
if (result.found) continue
|
||||
next.splice(result.index, 0, part)
|
||||
changed = true
|
||||
}
|
||||
if (!changed) return parts
|
||||
return next
|
||||
}
|
||||
|
||||
export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
|
||||
if (items.length === 0) return { ...page, confirmed: [] as string[] }
|
||||
|
||||
const session = [...page.session]
|
||||
const part = new Map(page.part.map((item) => [item.id, sortParts(item.part)]))
|
||||
const confirmed: string[] = []
|
||||
|
||||
for (const item of items) {
|
||||
const result = Binary.search(session, messageKey(item.message), messageKey)
|
||||
const found = result.found
|
||||
if (!found) session.splice(result.index, 0, item.message)
|
||||
|
||||
const current = part.get(item.message.id)
|
||||
if (found && hasParts(current, item.parts)) {
|
||||
confirmed.push(item.message.id)
|
||||
continue
|
||||
}
|
||||
|
||||
part.set(item.message.id, mergeParts(current, item.parts))
|
||||
}
|
||||
|
||||
return {
|
||||
cursor: page.cursor,
|
||||
complete: page.complete,
|
||||
session,
|
||||
part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, part]) => ({ id, part })),
|
||||
confirmed,
|
||||
}
|
||||
}
|
||||
|
||||
export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) {
|
||||
const messages = draft.message[input.sessionID]
|
||||
if (messages) {
|
||||
const result = Binary.search(messages, messageKey(input.message), messageKey)
|
||||
messages.splice(result.index, 0, input.message)
|
||||
} else {
|
||||
draft.message[input.sessionID] = [input.message]
|
||||
}
|
||||
draft.part[input.message.id] = sortParts(input.parts)
|
||||
}
|
||||
|
||||
export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) {
|
||||
const messages = draft.message[input.sessionID]
|
||||
if (messages) {
|
||||
const index = messages.findIndex((message) => message.id === input.messageID)
|
||||
if (index >= 0) messages.splice(index, 1)
|
||||
}
|
||||
delete draft.part[input.messageID]
|
||||
}
|
||||
|
||||
export const useSync = () => {
|
||||
const serverSync = useServerSync()
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useQueryOptions } from "@/context/server-sync"
|
||||
import { Iterable, pipe } from "effect"
|
||||
import { type Accessor } from "solid-js"
|
||||
import { emptyProviderCatalog } from "./provider-catalog"
|
||||
import { useIntegrations } from "./use-integrations"
|
||||
import { useQuery } from "@tanstack/solid-query"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
|
||||
@@ -24,7 +23,6 @@ export function useProviders(directory: Accessor<string | undefined>) {
|
||||
const dir = directory()
|
||||
return queryOpts.providers(dir ? pathKey(dir) : null)
|
||||
})
|
||||
const integrations = useIntegrations(directory)
|
||||
|
||||
const providers = () => (!providersQuery.isSuccess ? emptyProviderCatalog : providersQuery.data)
|
||||
|
||||
@@ -32,22 +30,13 @@ export function useProviders(directory: Accessor<string | undefined>) {
|
||||
ready: () => providersQuery.isSuccess,
|
||||
all: () => providers().all,
|
||||
default: () => providers().default,
|
||||
// V2 servers list only available providers, so the connectable catalog
|
||||
// comes from the integration list, with the provider catalog as fallback.
|
||||
popular: () => {
|
||||
const catalog = integrations
|
||||
.list()
|
||||
.filter((integration) => popularProviderSet.has(integration.id))
|
||||
.map((integration) => ({ id: integration.id, name: integration.name }))
|
||||
const seen = new Set(catalog.map((integration) => integration.id))
|
||||
return pipe(
|
||||
popular: () =>
|
||||
pipe(
|
||||
providers().all,
|
||||
Iterable.map(([, p]) => p),
|
||||
Iterable.filter((p) => popularProviderSet.has(p.id) && !seen.has(p.id)),
|
||||
Iterable.map((p) => ({ id: p.id, name: p.name })),
|
||||
(v) => [...catalog, ...v],
|
||||
)
|
||||
},
|
||||
Iterable.filter((p) => popularProviderSet.has(p.id)),
|
||||
(v) => Array.from(v),
|
||||
),
|
||||
connected: () => {
|
||||
const connected = new Set(providers().connected)
|
||||
return pipe(
|
||||
|
||||
@@ -4,7 +4,7 @@ export type UpdaterState =
|
||||
| { status: "disabled" }
|
||||
| { status: "idle" }
|
||||
| { status: "checking" }
|
||||
| { status: "downloading"; version: string }
|
||||
| { status: "downloading"; version: string; percent?: number }
|
||||
| { status: "ready"; version: string }
|
||||
| { status: "up-to-date" }
|
||||
| { status: "installing"; version: string }
|
||||
|
||||
@@ -22,10 +22,7 @@ function blobUrl(id: string, blob: Blob) {
|
||||
}
|
||||
|
||||
async function blobID(blob: Blob) {
|
||||
const bytes = crypto.subtle
|
||||
? new Uint8Array(await crypto.subtle.digest("SHA-256", await blob.arrayBuffer()))
|
||||
: crypto.getRandomValues(new Uint8Array(16))
|
||||
const id = Array.from(bytes)
|
||||
const id = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", await blob.arrayBuffer())))
|
||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||
.join("")
|
||||
return id
|
||||
|
||||
@@ -41,8 +41,8 @@
|
||||
"solid-js": "catalog:",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"uqr": "0.1.3",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"uqr": "0.1.3",
|
||||
"ws": "8.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -192,7 +192,7 @@ export type ProviderInfo = {
|
||||
id: string
|
||||
integrationID?: string
|
||||
name: string
|
||||
activation: "auto" | "enabled" | "disabled"
|
||||
disabled?: boolean
|
||||
package: string
|
||||
settings?: { [x: string]: any }
|
||||
headers?: { [x: string]: string }
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function TermsOfService() {
|
||||
<section data-component="brand-content">
|
||||
<article data-component="terms-of-service">
|
||||
<h1>Terms of Use</h1>
|
||||
<p class="effective-date">Effective date: Aug 15, 2026</p>
|
||||
<p class="effective-date">Effective date: Mar 6, 2026</p>
|
||||
|
||||
<p>
|
||||
Welcome to OpenCode. Please read on to learn the rules and restrictions that govern your use of
|
||||
@@ -154,11 +154,6 @@ export default function TermsOfService() {
|
||||
is dangerous, harmful, fraudulent, deceptive, threatening, harassing, defamatory, obscene, or
|
||||
otherwise objectionable;
|
||||
</li>
|
||||
<li>
|
||||
creates, maintains, or uses accounts in bulk, or creates, maintains, or uses multiple accounts to
|
||||
circumvent usage limits, access restrictions, billing obligations, promotions, suspensions, or any
|
||||
other restriction or policy applicable to the Services;
|
||||
</li>
|
||||
<li>automatically or programmatically extracts data or Output (defined below);</li>
|
||||
<li>Represent that the Output was human-generated when it was not;</li>
|
||||
<li>
|
||||
|
||||
@@ -65,8 +65,8 @@ const layer = Layer.effect(
|
||||
const integrations = yield* Integration.Service
|
||||
|
||||
const available = (provider: Provider.Info, integration: Integration.Info | undefined) => {
|
||||
if (provider.activation === "disabled") return false
|
||||
if (provider.activation === "enabled") return true
|
||||
if (provider.disabled) return false
|
||||
if (typeof provider.settings?.apiKey === "string") return true
|
||||
if (integration?.connections.length) return true
|
||||
return provider.integrationID === undefined && !integration
|
||||
}
|
||||
|
||||
@@ -40,7 +40,6 @@ export const Plugin = define({
|
||||
for (const [id, item] of configuredProviders(loaded.entries)) {
|
||||
const providerID = id
|
||||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.activation = "enabled"
|
||||
if (item.name !== undefined) provider.name = item.name
|
||||
if (item.package !== undefined) provider.package = item.package
|
||||
if (item.settings !== undefined) provider.settings = Provider.mergeOverlay(provider.settings, item.settings)
|
||||
|
||||
@@ -23,32 +23,6 @@ export type Options = typeof Options.Type
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem/Search") {}
|
||||
|
||||
const REFRESH_INTERVAL = Duration.toMillis("10 seconds")
|
||||
type Prepared = ReturnType<typeof fuzzysort.prepare>
|
||||
|
||||
function emptyIndex() {
|
||||
return { files: new Map<string, Prepared>(), directories: new Map<string, Prepared>() }
|
||||
}
|
||||
|
||||
function search(index: ReturnType<typeof emptyIndex>, input: FileSystem.FindInput) {
|
||||
const items =
|
||||
input.type === "file"
|
||||
? Array.from(index.files.values())
|
||||
: input.type === "directory"
|
||||
? Array.from(index.directories.values())
|
||||
: [...index.files.values(), ...index.directories.values()]
|
||||
const result = fuzzysort.go(input.query, items, { limit: input.limit ?? 50 })
|
||||
// Targets are owned by the current location index. The only global fuzzysort
|
||||
// state left is its query cache, which must not retain every query forever.
|
||||
fuzzysort.cleanup()
|
||||
return result.map((item) => {
|
||||
const relative = item.target
|
||||
const type = relative.endsWith(path.sep) ? ("directory" as const) : ("file" as const)
|
||||
return FileSystem.Entry.make({
|
||||
path: RelativePath.make(relative),
|
||||
type,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export const ripgrepLayer = Layer.effect(
|
||||
Service,
|
||||
@@ -58,13 +32,12 @@ export const ripgrepLayer = Layer.effect(
|
||||
const scope = yield* Scope.Scope
|
||||
const clock = yield* Clock.Clock
|
||||
const home = Protected.isHome(location.directory)
|
||||
let index = emptyIndex()
|
||||
let index = { files: [] as string[], directories: new Set<string>() }
|
||||
let initialized = false
|
||||
let settledAt = Number.NEGATIVE_INFINITY
|
||||
let refreshing = false
|
||||
const scan = Effect.gen(function* () {
|
||||
const next = emptyIndex()
|
||||
const previous = index
|
||||
const next = { files: [] as string[], directories: new Set<string>() }
|
||||
if (!initialized) index = next
|
||||
yield* ripgrep.find({
|
||||
cwd: location.directory,
|
||||
@@ -73,13 +46,11 @@ export const ripgrepLayer = Layer.effect(
|
||||
exclude: home ? [...Protected.names()].map((name) => `${name}/**`) : undefined,
|
||||
onEntry: (entry) =>
|
||||
Effect.sync(() => {
|
||||
next.files.set(entry.path, previous.files.get(entry.path) ?? fuzzysort.prepare(entry.path))
|
||||
next.files.push(entry.path)
|
||||
const parts = entry.path.split("/")
|
||||
parts.slice(0, -1).forEach((_, offset) => {
|
||||
const directory = parts.slice(0, offset + 1).join("/") + path.sep
|
||||
if (!next.directories.has(directory))
|
||||
next.directories.set(directory, previous.directories.get(directory) ?? fuzzysort.prepare(directory))
|
||||
})
|
||||
parts
|
||||
.slice(0, -1)
|
||||
.forEach((_, offset) => next.directories.add(parts.slice(0, offset + 1).join("/") + path.sep))
|
||||
}),
|
||||
})
|
||||
index = next
|
||||
@@ -103,7 +74,20 @@ export const ripgrepLayer = Layer.effect(
|
||||
find: (input) =>
|
||||
Effect.gen(function* () {
|
||||
yield* refresh
|
||||
return search(index, input)
|
||||
const items =
|
||||
input.type === "file"
|
||||
? index.files
|
||||
: input.type === "directory"
|
||||
? Array.from(index.directories)
|
||||
: [...index.files, ...index.directories]
|
||||
return fuzzysort.go(input.query, items, { limit: input.limit ?? 50 }).map((item) => {
|
||||
const relative = item.target
|
||||
const type = relative.endsWith(path.sep) ? ("directory" as const) : ("file" as const)
|
||||
return FileSystem.Entry.make({
|
||||
path: RelativePath.make(relative),
|
||||
type,
|
||||
})
|
||||
})
|
||||
}),
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Effect, Layer, LayerMap } from "effect"
|
||||
import path from "path"
|
||||
import { Agent } from "./agent.js"
|
||||
import { AISDK } from "./aisdk.js"
|
||||
import { Catalog } from "./catalog.js"
|
||||
@@ -50,7 +49,6 @@ import { ReadToolFileSystem } from "./tool/read-filesystem.js"
|
||||
import { Tool } from "./tool.js"
|
||||
import { ToolOutput } from "./tool-output.js"
|
||||
import { Vcs } from "./vcs.js"
|
||||
import { AbsolutePath } from "./schema.js"
|
||||
|
||||
export { LocationServiceMap } from "./location-service-map.js"
|
||||
|
||||
@@ -112,13 +110,11 @@ export type LocationError = LayerNode.Error<typeof locationServices>
|
||||
export function buildLocationServiceMap(
|
||||
replacements: LayerNode.Replacements = [],
|
||||
): Layer.Layer<LocationServiceMap.Service> {
|
||||
// Structural Equal distinguishes optional-key shape and Windows separator style.
|
||||
// The RcMap caches the raw key before the build callback, so normalize both here.
|
||||
const canonical = (ref: Location.Ref) =>
|
||||
Location.Ref.make({
|
||||
directory: AbsolutePath.make(process.platform === "win32" ? path.normalize(ref.directory) : ref.directory),
|
||||
workspaceID: ref.workspaceID,
|
||||
})
|
||||
// Structural Equal is own-key-set sensitive, so `{ directory }` (schema-decoded
|
||||
// payloads omit optional keys) and `{ directory, workspaceID: undefined }` are
|
||||
// different RcMap keys. The RcMap caches by the raw key before the build
|
||||
// callback runs, so canonicalize at the map boundary to the key-present shape.
|
||||
const canonical = (ref: Location.Ref) => Location.Ref.make({ directory: ref.directory, workspaceID: ref.workspaceID })
|
||||
return Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
Effect.map(
|
||||
|
||||
@@ -358,21 +358,17 @@ export const layer = Layer.effect(
|
||||
const connection = yield* integrations.connection.active(
|
||||
provider?.integrationID ?? Integration.ID.make(selected.providerID),
|
||||
)
|
||||
const credential = connection ? yield* integrations.connection.resolve(connection) : undefined
|
||||
const runtimeInfo = yield* withVariant(selected, variant)
|
||||
const model = yield* fromCatalogModel(runtimeInfo, credential, {
|
||||
loadPackage: (specifier) => Provider.loadPackage(specifier, npm),
|
||||
loadAISDK: (model) => aisdk.model(model),
|
||||
})
|
||||
const runtime =
|
||||
provider?.activation === "enabled" &&
|
||||
credential === undefined &&
|
||||
!hasConfiguredAuth(runtimeInfo) &&
|
||||
usesAPIKeyAuth(runtimeInfo.package)
|
||||
? LanguageModel.update(model, { route: model.route.with({ auth: Auth.none }) })
|
||||
: model
|
||||
const model = yield* resolveModel(
|
||||
selected,
|
||||
variant,
|
||||
connection ? yield* integrations.connection.resolve(connection) : undefined,
|
||||
{
|
||||
loadPackage: (specifier) => Provider.loadPackage(specifier, npm),
|
||||
loadAISDK: (model) => aisdk.model(model),
|
||||
},
|
||||
)
|
||||
return {
|
||||
model: runtime,
|
||||
model,
|
||||
ref: Ref.make({
|
||||
id: selected.id,
|
||||
providerID: selected.providerID,
|
||||
@@ -403,35 +399,6 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
function hasConfiguredAuth(model: Info) {
|
||||
return [model.settings?.apiKey, model.settings?.authToken, model.settings?.accessToken].some(
|
||||
(value) => typeof value === "string" && value !== "",
|
||||
)
|
||||
}
|
||||
|
||||
function usesAPIKeyAuth(packageName: string | undefined) {
|
||||
const name = Provider.packageName(packageName)
|
||||
return (
|
||||
name === "@ai-sdk/openai" ||
|
||||
name === "@ai-sdk/anthropic" ||
|
||||
name === "@ai-sdk/openai-compatible" ||
|
||||
name === "@ai-sdk/google" ||
|
||||
name === "@ai-sdk/xai" ||
|
||||
name === "@openrouter/ai-sdk-provider" ||
|
||||
name === "@ai-sdk/azure" ||
|
||||
name === "@opencode-ai/ai/providers/openai" ||
|
||||
name?.startsWith("@opencode-ai/ai/providers/openai/") === true ||
|
||||
name === "@opencode-ai/ai/providers/anthropic" ||
|
||||
name === "@opencode-ai/ai/providers/anthropic-compatible" ||
|
||||
name === "@opencode-ai/ai/providers/openai-compatible" ||
|
||||
name === "@opencode-ai/ai/providers/google" ||
|
||||
name === "@opencode-ai/ai/providers/xai" ||
|
||||
name === "@opencode-ai/ai/providers/openrouter" ||
|
||||
name === "@opencode-ai/ai/providers/azure" ||
|
||||
name?.startsWith("@opencode-ai/ai/providers/azure/") === true
|
||||
)
|
||||
}
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
|
||||
@@ -86,7 +86,6 @@ function normalize(input: Record<string, SourceProvider>): readonly Snapshot[] {
|
||||
const info = {
|
||||
id: providerID,
|
||||
name: item.name,
|
||||
activation: "auto",
|
||||
package: Provider.aisdk(item.npm),
|
||||
...(item.api ? { settings: { baseURL: item.api } } : {}),
|
||||
} satisfies Provider.Info
|
||||
|
||||
@@ -10,7 +10,7 @@ export const LLMGatewayPlugin = define({
|
||||
const configured = new Set((yield* integrations.list()).map((integration) => integration.id))
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.activation === "disabled") continue
|
||||
if (item.provider.disabled) continue
|
||||
if (!Provider.isAISDK(item.provider.package)) continue
|
||||
if (Provider.packageName(item.provider.package) !== "@ai-sdk/openai-compatible") continue
|
||||
if (item.provider.settings?.baseURL !== "https://api.llmgateway.io/v1") continue
|
||||
|
||||
@@ -178,10 +178,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
|
||||
if (!item) return
|
||||
const hasKey = Boolean(process.env.OPENCODE_API_KEY || connected || item.provider.settings?.apiKey)
|
||||
catalog.provider.update(item.provider.id, (provider) => {
|
||||
if (!hasKey) {
|
||||
provider.activation = "enabled"
|
||||
provider.settings = { ...provider.settings, apiKey: "public" }
|
||||
}
|
||||
if (!hasKey) provider.settings = { ...provider.settings, apiKey: "public" }
|
||||
})
|
||||
if (hasKey) return
|
||||
for (const model of item.models.values()) {
|
||||
|
||||
@@ -63,39 +63,12 @@ examples, but do not fetch it to determine the V2 configuration shape.
|
||||
See the [full configuration guide](https://opencode.ai/v2/docs/config) for
|
||||
every field, examples, config locations, and links to dedicated feature guides.
|
||||
|
||||
## [MCP servers](https://opencode.ai/v2/docs/mcp-servers)
|
||||
|
||||
Configure MCP servers under `mcp.servers`. Prefer the CLI because it preserves
|
||||
unrelated configuration. Use `--global` when the user asks to set up a service
|
||||
for themselves without limiting it to the current project; omit it when they
|
||||
explicitly want project-local configuration.
|
||||
|
||||
```sh
|
||||
opencode2 mcp add <name> --global --url <remote-url>
|
||||
opencode2 mcp list
|
||||
```
|
||||
|
||||
Remote servers use OAuth by default. If `mcp list` reports that a server needs
|
||||
authentication, run the OAuth flow and then verify the connection:
|
||||
|
||||
```sh
|
||||
opencode2 mcp auth <name>
|
||||
opencode2 mcp list
|
||||
```
|
||||
|
||||
The auth command prints an authorization URL, waits for the browser redirect,
|
||||
and stores credentials outside the OpenCode configuration. Do not ask for or
|
||||
store an API key when the server supports OAuth. Use header-based credentials
|
||||
only when OAuth is unavailable or the user explicitly requires them, and use an
|
||||
environment substitution such as `{env:MCP_API_KEY}` instead of writing a
|
||||
secret into configuration.
|
||||
|
||||
## [V1 to V2 migration](https://opencode.ai/v2/docs/migrate-v1)
|
||||
|
||||
For any request to migrate OpenCode configuration, agents, commands, skills,
|
||||
plugins, integrations, or other behavior from V1 to V2, read the full
|
||||
[migration guide](https://opencode.ai/v2/docs/migrate-v1) before acting. In
|
||||
the repository, its source is `packages/www/content/docs/migrate-v1.mdx`.
|
||||
the repository, its source is `packages/www/content/docs/(Get started)/migrate-v1.mdx`.
|
||||
|
||||
V1 config files and `.opencode/` definitions are intended to remain compatible.
|
||||
The only intentional breaking changes are the server API and plugin API. Native
|
||||
|
||||
@@ -789,10 +789,7 @@ const layer = Layer.effect(
|
||||
return false
|
||||
}),
|
||||
)
|
||||
if (recovered) {
|
||||
yield* execution.wakeActive(input.sessionID)
|
||||
return
|
||||
}
|
||||
if (recovered) return
|
||||
yield* execution.wake(input.sessionID)
|
||||
}),
|
||||
compact: Effect.fn("Session.compact")(function* (input) {
|
||||
@@ -876,7 +873,12 @@ const layer = Layer.effect(
|
||||
),
|
||||
),
|
||||
interrupt: Effect.fn("Session.interrupt")((sessionID, options) =>
|
||||
Effect.uninterruptible(execution.interrupt(sessionID, options)),
|
||||
Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
yield* execution.interrupt(sessionID)
|
||||
if (options?.continue && (yield* SessionInbox.has(db, sessionID, "any"))) yield* execution.wake(sessionID)
|
||||
}),
|
||||
),
|
||||
),
|
||||
revert: {
|
||||
stage: Effect.fn("Session.revert.stage")(function* (input) {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
export * as SessionExecution from "./execution.js"
|
||||
|
||||
import { Cause, Context, Effect, Exit, Layer } from "effect"
|
||||
import { Cause, Context, Effect, Exit, Layer, Stream } from "effect"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Database } from "../database/database.js"
|
||||
import { LocationServiceMap } from "../location-service-map.js"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { SessionEvent } from "./event.js"
|
||||
@@ -12,7 +11,6 @@ import { SessionSchema } from "./schema.js"
|
||||
import { SessionStore } from "./store.js"
|
||||
import { toSessionError } from "./to-session-error.js"
|
||||
import { UserInterruptedError } from "./error.js"
|
||||
import { SessionInbox } from "./inbox.js"
|
||||
|
||||
export interface Interface {
|
||||
/** Snapshots active execution owned by this process. */
|
||||
@@ -21,10 +19,8 @@ export interface Interface {
|
||||
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
|
||||
/** Registers newly recorded work. Repeated wakeups may coalesce. */
|
||||
readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
/** Wakes only an active execution, preserving its current input eligibility. */
|
||||
readonly wakeActive: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
/** Interrupt active work owned by this process. Idle interruption is a no-op. */
|
||||
readonly interrupt: (sessionID: SessionSchema.ID, options?: { readonly continue?: boolean }) => Effect.Effect<void>
|
||||
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
/** Resolves once this process owns no active execution for the Session. Returns immediately when idle and never starts work. */
|
||||
readonly awaitIdle: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
}
|
||||
@@ -49,7 +45,6 @@ export const layer = Layer.effect(
|
||||
const store = yield* SessionStore.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const bus = yield* Bus.Service
|
||||
const db = (yield* Database.Service).db
|
||||
const reportLifecycle = <A>(sessionID: SessionSchema.ID, effect: Effect.Effect<A>) =>
|
||||
effect.pipe(
|
||||
Effect.tapCause((cause) =>
|
||||
@@ -76,13 +71,12 @@ export const layer = Layer.effect(
|
||||
sessionID: SessionSchema.ID,
|
||||
force: boolean,
|
||||
continuation?: SessionRunner.Continuation,
|
||||
promotable: SessionInbox.Promotable = "input",
|
||||
): Effect.Effect<void, SessionRunner.RunError> {
|
||||
return Effect.gen(function* () {
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
|
||||
const result = yield* SessionRunner.Service.use((runner) =>
|
||||
runner.drain({ sessionID, force, continuation, promotable }),
|
||||
runner.drain({ sessionID, force, continuation }),
|
||||
).pipe(
|
||||
Effect.provide(locations.get(session.location)),
|
||||
Effect.tapCause((cause) =>
|
||||
@@ -92,7 +86,7 @@ export const layer = Layer.effect(
|
||||
),
|
||||
)
|
||||
if (result.type === "complete") return
|
||||
return yield* drain(sessionID, false, result.continuation, promotable)
|
||||
return yield* drain(sessionID, false, result.continuation)
|
||||
})
|
||||
}
|
||||
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError, InterruptReason>({
|
||||
@@ -101,7 +95,7 @@ export const layer = Layer.effect(
|
||||
sessionID,
|
||||
bus.publish(SessionEvent.Execution.Started, { sessionID }, claimOnCommit(sessionID)),
|
||||
),
|
||||
drain: (sessionID, force, promotable) => drain(sessionID, force, undefined, promotable),
|
||||
drain: (sessionID, force) => drain(sessionID, force),
|
||||
// One terminal observation per busy period, covering every coalesced drain.
|
||||
settled: (sessionID, exit, reason) =>
|
||||
reportLifecycle(
|
||||
@@ -133,20 +127,16 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
),
|
||||
})
|
||||
yield* bus.subscribe(SessionEvent.Moved).pipe(
|
||||
Stream.runForEach((event) => coordinator.wake(event.data.sessionID)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
active: coordinator.active,
|
||||
interrupt: (sessionID, options) =>
|
||||
coordinator.interrupt(
|
||||
sessionID,
|
||||
"user",
|
||||
options?.continue
|
||||
? { continue: { request: "steer", when: SessionInbox.has(db, sessionID, "steer") } }
|
||||
: undefined,
|
||||
),
|
||||
interrupt: (sessionID) => coordinator.interrupt(sessionID, "user"),
|
||||
resume: coordinator.run,
|
||||
wake: coordinator.wake,
|
||||
wakeActive: coordinator.wakeActive,
|
||||
awaitIdle: coordinator.awaitIdle,
|
||||
})
|
||||
}),
|
||||
@@ -155,7 +145,7 @@ export const layer = Layer.effect(
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [SessionStore.node, LocationServiceMap.node, Bus.node, Database.node],
|
||||
deps: [SessionStore.node, LocationServiceMap.node, Bus.node],
|
||||
})
|
||||
|
||||
/** Low-level compatibility layer for callers that only need durable Session recording. */
|
||||
@@ -165,7 +155,6 @@ export const noopLayer = Layer.succeed(
|
||||
active: Effect.succeed(new Set()),
|
||||
resume: () => Effect.void,
|
||||
wake: () => Effect.void,
|
||||
wakeActive: () => Effect.void,
|
||||
interrupt: () => Effect.void,
|
||||
awaitIdle: () => Effect.void,
|
||||
}),
|
||||
|
||||
@@ -349,14 +349,6 @@ export const nextSteer = Effect.fn("SessionInbox.nextSteer")(function* (
|
||||
return row ? fromRow(row) : undefined
|
||||
})
|
||||
|
||||
export const nextPromotable = Effect.fn("SessionInbox.nextPromotable")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
promotable: Promotable,
|
||||
) {
|
||||
return (yield* nextSteer(db, sessionID)) ?? (promotable === "input" ? yield* nextQueued(db, sessionID) : undefined)
|
||||
})
|
||||
|
||||
/**
|
||||
* Which pending rows count: "any" counts every row, while "input" means any
|
||||
* item in either delivery mode.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * as SessionRunCoordinator from "./run-coordinator.js"
|
||||
|
||||
import { Deferred, Effect, Exit, Fiber, FiberSet, Scope } from "effect"
|
||||
import type { Promotable } from "./inbox.js"
|
||||
|
||||
/** Serializes execution for each key while allowing different keys to run concurrently. */
|
||||
export interface Coordinator<Key, E, Reason = never> {
|
||||
@@ -10,41 +9,26 @@ export interface Coordinator<Key, E, Reason = never> {
|
||||
/** Starts an execution while idle, or joins the active execution and returns its exit. */
|
||||
readonly run: (key: Key) => Effect.Effect<void, E>
|
||||
/** Rings the doorbell: an idle key starts an execution; an active one drains again before settling. */
|
||||
readonly wake: (key: Key, request?: Request) => Effect.Effect<void>
|
||||
/** Rings the current execution's doorbell with its existing request. Idle keys remain idle. */
|
||||
readonly wakeActive: (key: Key) => Effect.Effect<void>
|
||||
readonly wake: (key: Key) => Effect.Effect<void>
|
||||
/** Stops the active execution, clears its doorbell, and waits for cleanup. No-op when idle. */
|
||||
readonly interrupt: (
|
||||
key: Key,
|
||||
reason?: Reason,
|
||||
options?: { readonly continue?: { readonly request: Request; readonly when: Effect.Effect<boolean> } },
|
||||
) => Effect.Effect<void>
|
||||
readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect<void>
|
||||
/** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */
|
||||
readonly awaitIdle: (key: Key) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export type Request = Promotable
|
||||
|
||||
/**
|
||||
* One execution is a busy period for one key: one fiber that drains from the first wake
|
||||
* until the key would stay idle. `pendingWake` is the doorbell: work recorded during the
|
||||
* execution rings it with its eligibility request, and the execution loop drains again
|
||||
* instead of ending. The doorbell closes the gap between a drain's last eligibility check
|
||||
* and the idle transition, since those cannot be one atomic step. `done` resolves joiners
|
||||
* with this execution's exit.
|
||||
* execution rings it, and the execution loop drains again instead of ending. The doorbell
|
||||
* closes the gap between a drain's last eligibility check and the idle transition, since
|
||||
* those cannot be one atomic step. `done` resolves joiners with this execution's exit.
|
||||
*/
|
||||
type Execution<E, Reason> = {
|
||||
readonly done: Deferred.Deferred<void, E>
|
||||
owner?: Fiber.Fiber<void>
|
||||
request: Request
|
||||
pendingWake?: Request
|
||||
pendingWake: boolean
|
||||
stopping: boolean
|
||||
interruptionReason?: Reason
|
||||
continuation?: {
|
||||
readonly request: Request
|
||||
readonly when: Effect.Effect<boolean>
|
||||
signaled: boolean
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,7 +43,7 @@ type Execution<E, Reason> = {
|
||||
* ```
|
||||
*/
|
||||
export const make = <Key, E, Reason = never>(options: {
|
||||
readonly drain: (key: Key, force: boolean, request: Request) => Effect.Effect<void, E>
|
||||
readonly drain: (key: Key, force: boolean) => Effect.Effect<void, E>
|
||||
/** Runs once when a process-local busy period begins, before its first drain. */
|
||||
readonly started?: (key: Key) => Effect.Effect<void>
|
||||
/**
|
||||
@@ -73,22 +57,21 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
const fork = yield* FiberSet.makeRuntime<never, void, never>()
|
||||
|
||||
const loop = (key: Key, execution: Execution<E, Reason>, force: boolean): Effect.Effect<void, E> =>
|
||||
Effect.suspend(() => options.drain(key, force, execution.request)).pipe(
|
||||
Effect.suspend(() => options.drain(key, force)).pipe(
|
||||
Effect.flatMap(() =>
|
||||
Effect.suspend(() => {
|
||||
if (execution.stopping || execution.pendingWake === undefined) return Effect.void
|
||||
execution.request = execution.pendingWake
|
||||
execution.pendingWake = undefined
|
||||
if (execution.stopping || !execution.pendingWake) return Effect.void
|
||||
execution.pendingWake = false
|
||||
// Trampoline so drains that complete synchronously cannot grow the stack.
|
||||
return Effect.yieldNow.pipe(Effect.andThen(loop(key, execution, false)))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const start = (key: Key, force: boolean, request: Request) => {
|
||||
const start = (key: Key, force: boolean) => {
|
||||
const execution: Execution<E, Reason> = {
|
||||
done: Deferred.makeUnsafe<void, E>(),
|
||||
request,
|
||||
pendingWake: false,
|
||||
stopping: false,
|
||||
}
|
||||
executions.set(key, execution)
|
||||
@@ -104,7 +87,7 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
execution.owner = undefined
|
||||
}).pipe(Effect.andThen(options.settled?.(key, exit, execution.interruptionReason) ?? Effect.void)),
|
||||
),
|
||||
Effect.onExit((exit) => finish(key, execution, exit)),
|
||||
Effect.onExit((exit) => Effect.sync(() => settle(key, execution, exit))),
|
||||
Effect.exit,
|
||||
Effect.asVoid,
|
||||
),
|
||||
@@ -114,22 +97,12 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
|
||||
// A doorbell that survives the execution loop (rung after the loop decided to end, or
|
||||
// during failure or interruption cleanup) starts a fresh execution for the remaining work.
|
||||
const settle = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>, resume: boolean) => {
|
||||
if (resume && execution.continuation) start(key, false, execution.continuation.request)
|
||||
else if (execution.pendingWake) start(key, false, execution.pendingWake)
|
||||
const settle = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>) => {
|
||||
if (execution.pendingWake) start(key, false)
|
||||
else executions.delete(key)
|
||||
Deferred.doneUnsafe(execution.done, exit)
|
||||
}
|
||||
|
||||
const finish = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>) => {
|
||||
if (!execution.continuation) return Effect.sync(() => settle(key, execution, exit, false))
|
||||
return execution.continuation.when.pipe(
|
||||
Effect.flatMap((ready) =>
|
||||
Effect.sync(() => settle(key, execution, exit, ready || execution.continuation?.signaled === true)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const run = (key: Key): Effect.Effect<void, E> =>
|
||||
Effect.suspend(() => {
|
||||
const execution = executions.get(key)
|
||||
@@ -138,58 +111,26 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
if (execution.stopping) return Deferred.await(execution.done).pipe(Effect.andThen(run(key)))
|
||||
return Deferred.await(execution.done)
|
||||
}
|
||||
return Deferred.await(start(key, true, "input").done)
|
||||
return Deferred.await(start(key, true).done)
|
||||
})
|
||||
|
||||
const wake = (key: Key, request: Request = "input") =>
|
||||
const wake = (key: Key) =>
|
||||
Effect.sync(() => {
|
||||
const execution = executions.get(key)
|
||||
if (execution !== undefined) {
|
||||
if (execution.stopping) {
|
||||
if (execution.continuation) execution.continuation.signaled = true
|
||||
else execution.continuation = { request, when: Effect.succeed(true), signaled: true }
|
||||
return
|
||||
}
|
||||
// Coalesced wakes keep the widest request: "input" subsumes "steer".
|
||||
execution.pendingWake = execution.pendingWake === "input" ? "input" : request
|
||||
execution.pendingWake = true
|
||||
return
|
||||
}
|
||||
start(key, false, request)
|
||||
start(key, false)
|
||||
})
|
||||
|
||||
const wakeActive = (key: Key) =>
|
||||
const interrupt = (key: Key, reason?: Reason): Effect.Effect<void> =>
|
||||
Effect.suspend(() => {
|
||||
const execution = executions.get(key)
|
||||
return execution ? wake(key, execution.request) : Effect.void
|
||||
})
|
||||
|
||||
const interrupt = (
|
||||
key: Key,
|
||||
reason?: Reason,
|
||||
options?: { readonly continue?: { readonly request: Request; readonly when: Effect.Effect<boolean> } },
|
||||
): Effect.Effect<void> =>
|
||||
Effect.suspend(() => {
|
||||
const execution = executions.get(key)
|
||||
if (execution === undefined) return Effect.void
|
||||
if (execution.stopping) {
|
||||
if (options?.continue)
|
||||
execution.continuation = {
|
||||
...options.continue,
|
||||
signaled: execution.continuation?.signaled ?? false,
|
||||
}
|
||||
return Deferred.await(execution.done).pipe(Effect.exit, Effect.asVoid)
|
||||
}
|
||||
if (execution.owner === undefined) {
|
||||
if (!options?.continue) return Effect.void
|
||||
execution.stopping = true
|
||||
execution.pendingWake = undefined
|
||||
execution.continuation = { ...options.continue, signaled: false }
|
||||
return Deferred.await(execution.done).pipe(Effect.exit, Effect.asVoid)
|
||||
}
|
||||
if (execution?.owner === undefined || execution.stopping) return Effect.void
|
||||
execution.stopping = true
|
||||
execution.pendingWake = undefined
|
||||
execution.pendingWake = false
|
||||
execution.interruptionReason = reason
|
||||
if (options?.continue) execution.continuation = { ...options.continue, signaled: false }
|
||||
return Fiber.interrupt(execution.owner)
|
||||
})
|
||||
|
||||
@@ -202,5 +143,5 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
return Deferred.await(execution.done).pipe(Effect.exit, Effect.andThen(awaitIdle(key)))
|
||||
})
|
||||
|
||||
return { active: Effect.sync(() => new Set(executions.keys())), run, wake, wakeActive, interrupt, awaitIdle }
|
||||
return { active: Effect.sync(() => new Set(executions.keys())), run, wake, interrupt, awaitIdle }
|
||||
})
|
||||
|
||||
@@ -3,7 +3,6 @@ export * as SessionRunner from "./index.js"
|
||||
import type { AIError } from "@opencode-ai/ai"
|
||||
import { Context, Effect } from "effect"
|
||||
import { SessionSchema } from "../schema.js"
|
||||
import type { Promotable } from "../inbox.js"
|
||||
import type { AgentNotFoundError, MessageDecodeError, StepFailedError, UserInterruptedError } from "../error.js"
|
||||
import { SessionRunnerModel } from "./model.js"
|
||||
import type { Instructions } from "../../instructions/index.js"
|
||||
@@ -30,8 +29,6 @@ export interface Interface {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly force: boolean
|
||||
readonly continuation?: Continuation
|
||||
/** "steer" settles the active intent without promoting queued next-turn work. */
|
||||
readonly promotable?: Promotable
|
||||
}) => Effect.Effect<DrainResult, RunError>
|
||||
}
|
||||
|
||||
|
||||
@@ -128,25 +128,22 @@ const layer = Layer.effect(
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly force: boolean
|
||||
readonly continuation?: Continuation
|
||||
readonly promotable?: SessionInbox.Promotable
|
||||
}) {
|
||||
let force = input.force
|
||||
let continuation = input.continuation
|
||||
const promotable = input.promotable ?? "input"
|
||||
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, promotable)))
|
||||
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, "any")))
|
||||
return { type: "complete" as const }
|
||||
yield* settleStaleToolCalls(input.sessionID)
|
||||
while (true) {
|
||||
if (yield* runPendingCompaction(input.sessionID, promotable)) {
|
||||
if (yield* runPendingCompaction(input.sessionID)) {
|
||||
force = false
|
||||
continue
|
||||
}
|
||||
if (yield* runPendingMove(input.sessionID, promotable)) return { type: "moved" as const }
|
||||
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, promotable)))
|
||||
if (yield* runPendingMove(input.sessionID, "input")) return { type: "moved" as const }
|
||||
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, "input")))
|
||||
return { type: "complete" as const }
|
||||
const result = yield* runSteps(input.sessionID, continuation, promotable)
|
||||
const result = yield* runSteps(input.sessionID, continuation)
|
||||
if (result.type === "moved") return result
|
||||
if (promotable === "steer") return { type: "complete" as const }
|
||||
force = false
|
||||
continuation = undefined
|
||||
}
|
||||
@@ -158,15 +155,14 @@ const layer = Layer.effect(
|
||||
*/
|
||||
const runSteps = Effect.fn("SessionRunner.runSteps")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
continuation: Continuation | undefined,
|
||||
drainPromotable: SessionInbox.Promotable,
|
||||
continuation?: Continuation,
|
||||
) {
|
||||
// Fresh work may promote queued input; resumed turns and later steps absorb steers only.
|
||||
let promotable: SessionInbox.Promotable = continuation ? "steer" : drainPromotable
|
||||
// Fresh work may promote queued input; later steps absorb steers only.
|
||||
let promotable: SessionInbox.Promotable = continuation ? "steer" : "input"
|
||||
let step = continuation?.step ?? 1
|
||||
let next = continuation
|
||||
while (true) {
|
||||
if (yield* runPendingCompaction(sessionID, "steer")) continue
|
||||
if (yield* runPendingCompaction(sessionID)) continue
|
||||
if (yield* runPendingMove(sessionID, "steer")) return { type: "moved" as const, continuation: next }
|
||||
const result = yield* runStep(sessionID, promotable, step)
|
||||
next = result.needsContinuation ? { step: result.step + 1 } : undefined
|
||||
@@ -519,14 +515,14 @@ const layer = Layer.effect(
|
||||
/** Executes a previously admitted manual compaction request, if one is pending. */
|
||||
const runPendingCompaction = Effect.fn("SessionRunner.runPendingCompaction")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
promotable: SessionInbox.Promotable,
|
||||
) {
|
||||
return yield* Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
const pending = yield* SessionInbox.serialized(
|
||||
sessionID,
|
||||
Effect.gen(function* () {
|
||||
const selected = yield* SessionInbox.nextPromotable(db, sessionID, promotable)
|
||||
const selected =
|
||||
(yield* SessionInbox.nextSteer(db, sessionID)) ?? (yield* SessionInbox.nextQueued(db, sessionID))
|
||||
if (selected?.type !== "compaction") return
|
||||
yield* bus.publishAll([
|
||||
[SessionEvent.InboxDelivered, { sessionID, inboxID: selected.id }],
|
||||
@@ -568,7 +564,9 @@ const layer = Layer.effect(
|
||||
return yield* SessionInbox.serialized(
|
||||
sessionID,
|
||||
Effect.gen(function* () {
|
||||
const pending = yield* SessionInbox.nextPromotable(db, sessionID, promotable)
|
||||
const pending =
|
||||
(yield* SessionInbox.nextSteer(db, sessionID)) ??
|
||||
(promotable === "input" ? yield* SessionInbox.nextQueued(db, sessionID) : undefined)
|
||||
if (pending?.type !== "move") return false
|
||||
yield* modelTransport.close(sessionID)
|
||||
yield* bus.publishAll([
|
||||
|
||||
@@ -181,7 +181,9 @@ export const Plugin = {
|
||||
)
|
||||
|
||||
const background = input.background === true
|
||||
yield* context.progress({ sessionID: child.id, status: "running" })
|
||||
yield* context.progress({
|
||||
metadata: { sessionID: child.id, status: "running" },
|
||||
})
|
||||
|
||||
const run = Effect.gen(function* () {
|
||||
// The child session owns its agent/model (set at create); prompt only admits input.
|
||||
|
||||
@@ -102,35 +102,6 @@ describe("Catalog", () => {
|
||||
}).pipe(Effect.provide(localCatalogLayer))
|
||||
})
|
||||
|
||||
it.effect("makes an explicitly enabled provider available without a connection", () => {
|
||||
const integrationID = Integration.ID.make("gateway")
|
||||
const providerID = Provider.ID.make("remote")
|
||||
const localCatalogLayer = Layer.fresh(
|
||||
AppNodeBuilder.build(LayerNode.group([Catalog.node, Credential.node, Integration.node]), [
|
||||
[Location.node, locationLayer],
|
||||
]),
|
||||
)
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* (yield* Integration.Service).transform((editor) => editor.update(integrationID, () => {}))
|
||||
yield* catalog.transform((editor) =>
|
||||
editor.provider.update(providerID, (provider) => {
|
||||
provider.integrationID = integrationID
|
||||
provider.settings = { baseURL: "https://gateway.example.com/v1" }
|
||||
}),
|
||||
)
|
||||
expect(yield* catalog.provider.available()).toEqual([])
|
||||
|
||||
yield* catalog.transform((editor) =>
|
||||
editor.provider.update(providerID, (provider) => {
|
||||
provider.activation = "enabled"
|
||||
}),
|
||||
)
|
||||
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([providerID])
|
||||
}).pipe(Effect.provide(localCatalogLayer))
|
||||
})
|
||||
|
||||
it.effect("projects environment connections without a catalog plugin", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
@@ -307,7 +278,7 @@ describe("Catalog", () => {
|
||||
const fallbackModel = Model.ID.make("fallback")
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(disabledProvider, (provider) => {
|
||||
provider.activation = "disabled"
|
||||
provider.disabled = true
|
||||
})
|
||||
catalog.model.update(disabledProvider, disabledModel, () => {})
|
||||
catalog.provider.update(enabledProvider, () => {})
|
||||
|
||||
@@ -342,7 +342,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
names: ["CUSTOM_API_KEY"],
|
||||
})
|
||||
expect((yield* integrations.get(Integration.ID.make("custom")))?.name).toBe("Renamed")
|
||||
expect(provider.activation).toBe("enabled")
|
||||
expect(provider.disabled).toBeUndefined()
|
||||
expect(provider.package).toBe("aisdk:custom-sdk")
|
||||
expect(provider.settings).toEqual({ baseURL: "https://example.test" })
|
||||
expect(provider.headers).toEqual({ first: "first", shared: "last", last: "last" })
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, expect, spyOn, test } from "bun:test"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { Deferred, Effect, Layer } from "effect"
|
||||
@@ -124,58 +123,4 @@ describe("FileSystemSearch", () => {
|
||||
}).pipe(Effect.provide(layer), Effect.provide(TestClock.layer()), Effect.scoped),
|
||||
)
|
||||
})
|
||||
|
||||
test("reuses location-owned fuzzy targets across index refreshes", async () => {
|
||||
let scans = 0
|
||||
const first = Effect.runSync(Deferred.make<void>())
|
||||
const second = Effect.runSync(Deferred.make<void>())
|
||||
const prepare = spyOn(fuzzysort, "prepare")
|
||||
const cleanup = spyOn(fuzzysort, "cleanup")
|
||||
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-cache")) }),
|
||||
),
|
||||
),
|
||||
],
|
||||
[
|
||||
Ripgrep.node,
|
||||
Layer.succeed(
|
||||
Ripgrep.Service,
|
||||
Ripgrep.Service.of({
|
||||
find: (input) =>
|
||||
Effect.gen(function* () {
|
||||
scans++
|
||||
const entry = FileSystem.Entry.make({ path: RelativePath.make("src/index.ts"), type: "file" })
|
||||
if (input.onEntry) yield* input.onEntry(entry)
|
||||
yield* Deferred.succeed(scans === 1 ? first : second, undefined)
|
||||
return [entry]
|
||||
}),
|
||||
glob: () => Effect.succeed([]),
|
||||
grep: () => Effect.succeed([]),
|
||||
}),
|
||||
),
|
||||
],
|
||||
])
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const search = yield* FileSystemSearch.Service
|
||||
yield* Deferred.await(first)
|
||||
yield* search.find({ query: "index", type: "file" })
|
||||
yield* TestClock.adjust("10 seconds")
|
||||
yield* search.find({ query: "index", type: "file" })
|
||||
yield* Deferred.await(second)
|
||||
yield* search.find({ query: "index", type: "file" })
|
||||
|
||||
expect(prepare).toHaveBeenCalledTimes(2)
|
||||
expect(cleanup).toHaveBeenCalledTimes(3)
|
||||
}).pipe(Effect.provide(layer), Effect.provide(TestClock.layer()), Effect.scoped),
|
||||
)
|
||||
prepare.mockRestore()
|
||||
cleanup.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -510,7 +510,7 @@ describe("LocationServiceMap", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("normalizes equivalent refs to one cached location graph", () =>
|
||||
it.live("normalizes ref key shapes to one cached location graph", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
@@ -520,20 +520,16 @@ describe("LocationServiceMap", () => {
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const directory = AbsolutePath.make(dir.path)
|
||||
const alternate = AbsolutePath.make(directory.replaceAll("\\", "/"))
|
||||
const absent = Location.Ref.make({ directory: alternate })
|
||||
const absent = Location.Ref.make({ directory })
|
||||
const present = Location.Ref.make({ directory, workspaceID: undefined })
|
||||
// The two shapes are not structurally Equal: own-key sets differ.
|
||||
expect(Object.keys(absent)).toEqual(["directory"])
|
||||
expect(Object.keys(present)).toEqual(["directory", "workspaceID"])
|
||||
expect(Equal.equals(absent, present)).toBe(false)
|
||||
if (process.platform === "win32") expect(absent.directory).not.toBe(present.directory)
|
||||
|
||||
const first = yield* locations.contextEffect(absent)
|
||||
expect(yield* locations.contextEffect(present)).toBe(first)
|
||||
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([
|
||||
Location.Ref.make({ directory, workspaceID: undefined }),
|
||||
])
|
||||
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toHaveLength(1)
|
||||
|
||||
// Invalidating with the shape opposite to the one that booted must evict.
|
||||
yield* locations.invalidate(present)
|
||||
|
||||
@@ -2,16 +2,13 @@ import { describe, expect } from "bun:test"
|
||||
import { LLM, LanguageModel } from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||
import { compileRequest } from "@opencode-ai/ai/route/client"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Compatibility, ID, Info, VariantID } from "@opencode-ai/core/model"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { ModelResolver } from "@opencode-ai/core/model-resolver"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
interface ModelOptions {
|
||||
@@ -272,109 +269,6 @@ describe("ModelResolver", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses no native API-key auth for an explicitly enabled provider without credentials", () => {
|
||||
const selected = model(Provider.aisdk("@ai-sdk/google"), {
|
||||
providerID: Provider.ID.make("gateway"),
|
||||
settings: { baseURL: "https://gateway.example.com/v1" },
|
||||
headers: { "cf-access-token": "access-token" },
|
||||
})
|
||||
const provider = Provider.Info.make({
|
||||
...Provider.Info.empty(selected.providerID),
|
||||
activation: "enabled",
|
||||
package: selected.package ?? "",
|
||||
settings: selected.settings,
|
||||
headers: selected.headers,
|
||||
})
|
||||
const catalog = Layer.mock(Catalog.Service, {
|
||||
provider: {
|
||||
get: () => Effect.succeed(provider),
|
||||
all: () => Effect.die("unused"),
|
||||
available: () => Effect.die("unused"),
|
||||
},
|
||||
model: {
|
||||
get: () => Effect.succeed(selected),
|
||||
all: () => Effect.die("unused"),
|
||||
available: () => Effect.die("unused"),
|
||||
default: () => Effect.die("unused"),
|
||||
small: () => Effect.die("unused"),
|
||||
},
|
||||
})
|
||||
const integrations = Layer.mock(Integration.Service, {
|
||||
connection: {
|
||||
active: () => Effect.succeed(undefined),
|
||||
resolve: () => Effect.die("unused"),
|
||||
key: () => Effect.die("unused"),
|
||||
update: () => Effect.die("unused"),
|
||||
remove: () => Effect.die("unused"),
|
||||
},
|
||||
oauth: {
|
||||
connect: () => Effect.die("unused"),
|
||||
status: () => Effect.die("unused"),
|
||||
complete: () => Effect.die("unused"),
|
||||
cancel: () => Effect.die("unused"),
|
||||
},
|
||||
command: {
|
||||
connect: () => Effect.die("unused"),
|
||||
status: () => Effect.die("unused"),
|
||||
cancel: () => Effect.die("unused"),
|
||||
},
|
||||
})
|
||||
const npm = Layer.mock(Npm.Service, {
|
||||
add: () => Effect.die("unused"),
|
||||
which: () => Effect.die("unused"),
|
||||
})
|
||||
const aisdk = Layer.mock(AISDK.Service, {
|
||||
hook: {
|
||||
sdk: () => Effect.die("unused"),
|
||||
language: () => Effect.die("unused"),
|
||||
},
|
||||
model: () => Effect.die("unused"),
|
||||
})
|
||||
const layer = ModelResolver.layer.pipe(Layer.provide(Layer.mergeAll(catalog, integrations, npm, aisdk)))
|
||||
|
||||
return withEnv({ GOOGLE_GENERATIVE_AI_API_KEY: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const resolver = yield* ModelResolver.Service
|
||||
const resolved = yield* resolver.resolveModel(selected)
|
||||
|
||||
const headers = yield* resolved.model.route.auth.apply({
|
||||
request: LLM.request({ model: resolved.model, prompt: "Hello" }),
|
||||
method: "POST",
|
||||
url: "https://gateway.example.com/v1",
|
||||
body: "{}",
|
||||
headers: Headers.fromInput(resolved.model.route.defaults.headers),
|
||||
})
|
||||
|
||||
expect(headers["cf-access-token"]).toBe("access-token")
|
||||
expect(headers.authorization).toBeUndefined()
|
||||
expect(headers["x-goog-api-key"]).toBeUndefined()
|
||||
}).pipe(Effect.provide(layer)),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("keeps native provider environment auth strict when no API key is configured", () =>
|
||||
withEnv({ GOOGLE_GENERATIVE_AI_API_KEY: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/google"), {
|
||||
settings: { baseURL: "https://google.example.com/v1" },
|
||||
}),
|
||||
)
|
||||
const exit = yield* Effect.exit(
|
||||
resolved.route.auth.apply({
|
||||
request: LLM.request({ model: resolved, prompt: "Hello" }),
|
||||
method: "POST",
|
||||
url: "https://google.example.com/v1",
|
||||
body: "{}",
|
||||
headers: Headers.empty,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(exit._tag).toBe("Failure")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("uses merged API settings for OpenAI-compatible auth and request defaults", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
|
||||
@@ -47,7 +47,6 @@ const fixtureSnapshot = [
|
||||
info: {
|
||||
id: Provider.ID.make("acme"),
|
||||
name: "Acme",
|
||||
activation: "auto",
|
||||
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
||||
},
|
||||
models: [
|
||||
@@ -110,7 +109,6 @@ const fixture2Snapshot = [
|
||||
info: {
|
||||
id: Provider.ID.make("beta"),
|
||||
name: "Beta",
|
||||
activation: "auto",
|
||||
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
||||
},
|
||||
models: [
|
||||
|
||||
@@ -64,7 +64,6 @@ describe("ModelsDevPlugin", () => {
|
||||
info: {
|
||||
id: providerID,
|
||||
name: "Acme",
|
||||
activation: "auto",
|
||||
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
||||
settings: { baseURL: "https://api.acme.test/v1" },
|
||||
},
|
||||
@@ -240,7 +239,6 @@ describe("ModelsDevPlugin", () => {
|
||||
info: {
|
||||
id: providerID,
|
||||
name: "Acme",
|
||||
activation: "auto",
|
||||
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
||||
},
|
||||
environment: [],
|
||||
@@ -332,7 +330,6 @@ describe("ModelsDevPlugin", () => {
|
||||
info: {
|
||||
id: providerID,
|
||||
name: "Acme",
|
||||
activation: "auto",
|
||||
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
||||
settings: { baseURL: "https://${ACME_HOST}/${UNDECLARED_HOST}/v1" },
|
||||
},
|
||||
@@ -388,7 +385,6 @@ describe("ModelsDevPlugin", () => {
|
||||
info: {
|
||||
id: Provider.ID.make(id),
|
||||
name,
|
||||
activation: "auto",
|
||||
package: Provider.aisdk(packageName),
|
||||
},
|
||||
environment: id === "azure" ? ["AZURE_RESOURCE_NAME", environment] : [environment],
|
||||
|
||||
@@ -60,14 +60,14 @@ describe("LLMGatewayPlugin", () => {
|
||||
})
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(Provider.ID.make("llmgateway"), (provider) => {
|
||||
provider.activation = "disabled"
|
||||
provider.disabled = true
|
||||
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
|
||||
provider.settings = { baseURL: "https://api.llmgateway.io/v1" }
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
||||
expect((yield* catalog.provider.get(Provider.ID.make("llmgateway")))?.activation).toBe("disabled")
|
||||
expect((yield* catalog.provider.get(Provider.ID.make("llmgateway")))?.disabled).toBe(true)
|
||||
expect((yield* catalog.provider.get(Provider.ID.make("llmgateway")))?.headers).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -347,8 +347,6 @@ describe("OpencodePlugin", () => {
|
||||
})
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.provider.get(Provider.ID.opencode)).settings?.apiKey).toBe("public")
|
||||
expect(required(yield* catalog.provider.get(Provider.ID.opencode)).activation).toBe("enabled")
|
||||
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(Provider.ID.opencode)
|
||||
expect(required(yield* catalog.model.get(Provider.ID.opencode, Model.ID.make("free"))).enabled).toBe(true)
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -31,7 +31,6 @@ import { testEffect } from "./lib/effect"
|
||||
|
||||
const executionCalls: Session.ID[] = []
|
||||
const interruptCalls: Session.ID[] = []
|
||||
const interruptContinuations: Array<boolean | undefined> = []
|
||||
const wakeCalls: Session.ID[] = []
|
||||
const activeSessions = new Set<Session.ID>()
|
||||
const execution = Layer.succeed(
|
||||
@@ -42,16 +41,14 @@ const execution = Layer.succeed(
|
||||
Effect.sync(() => {
|
||||
executionCalls.push(sessionID)
|
||||
}),
|
||||
interrupt: (sessionID, options) =>
|
||||
interrupt: (sessionID) =>
|
||||
Effect.sync(() => {
|
||||
interruptCalls.push(sessionID)
|
||||
interruptContinuations.push(options?.continue)
|
||||
}),
|
||||
wake: (sessionID) =>
|
||||
Effect.sync(() => {
|
||||
wakeCalls.push(sessionID)
|
||||
}),
|
||||
wakeActive: () => Effect.void,
|
||||
awaitIdle: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
@@ -180,18 +177,31 @@ describe("Session.prompt", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("forwards interrupt continuation policy", () =>
|
||||
it.effect("continues after interruption when pending work remains", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* Session.Service
|
||||
yield* session.synthetic({ sessionID, text: "Continue after interrupt", resume: false })
|
||||
interruptCalls.length = 0
|
||||
wakeCalls.length = 0
|
||||
|
||||
yield* session.interrupt(sessionID, { continue: true })
|
||||
|
||||
expect(interruptCalls).toEqual([sessionID])
|
||||
expect(wakeCalls).toEqual([sessionID])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not continue after interruption without pending work", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* Session.Service
|
||||
interruptCalls.length = 0
|
||||
interruptContinuations.length = 0
|
||||
wakeCalls.length = 0
|
||||
|
||||
yield* session.interrupt(sessionID, { continue: true })
|
||||
|
||||
expect(interruptCalls).toEqual([sessionID])
|
||||
expect(interruptContinuations).toEqual([true])
|
||||
expect(wakeCalls).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -269,35 +269,6 @@ describe("SessionRunCoordinator", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("replaces a settlement-window wake with a steer continuation", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const settling = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const requests: SessionRunCoordinator.Request[] = []
|
||||
const coordinator = yield* SessionRunCoordinator.make({
|
||||
drain: (_key, _force, request) => Effect.sync(() => requests.push(request)),
|
||||
settled: () => Deferred.succeed(settling, undefined).pipe(Effect.andThen(Deferred.await(release))),
|
||||
})
|
||||
|
||||
yield* coordinator.wake("session", "input")
|
||||
yield* Deferred.await(settling)
|
||||
yield* coordinator.wake("session", "input")
|
||||
const interrupted = yield* coordinator
|
||||
.interrupt("session", undefined, {
|
||||
continue: { request: "steer", when: Effect.succeed(true) },
|
||||
})
|
||||
.pipe(Effect.forkChild)
|
||||
yield* Effect.yieldNow
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(interrupted)
|
||||
yield* coordinator.awaitIdle("session")
|
||||
|
||||
expect(requests).toEqual(["input", "steer"])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("interrupts active execution and clears its pending wake", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
@@ -371,193 +342,6 @@ describe("SessionRunCoordinator", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("coalesces drain requests with input taking precedence", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const requests: SessionRunCoordinator.Request[] = []
|
||||
const coordinator = yield* SessionRunCoordinator.make({
|
||||
drain: (_key, _force, request) =>
|
||||
Effect.gen(function* () {
|
||||
requests.push(request)
|
||||
if (requests.length !== 1) return
|
||||
yield* Deferred.succeed(firstStarted, undefined)
|
||||
yield* Deferred.await(release)
|
||||
}),
|
||||
})
|
||||
|
||||
yield* coordinator.wake("session", "steer")
|
||||
yield* Deferred.await(firstStarted)
|
||||
yield* coordinator.wake("session", "steer")
|
||||
yield* coordinator.wake("session", "input")
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* coordinator.awaitIdle("session")
|
||||
|
||||
expect(requests).toEqual(["steer", "input"])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("does not carry a completed input request into a steer drain", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const requests: SessionRunCoordinator.Request[] = []
|
||||
const coordinator = yield* SessionRunCoordinator.make({
|
||||
drain: (_key, _force, request) =>
|
||||
Effect.gen(function* () {
|
||||
requests.push(request)
|
||||
if (requests.length !== 1) return
|
||||
yield* Deferred.succeed(firstStarted, undefined)
|
||||
yield* Deferred.await(release)
|
||||
}),
|
||||
})
|
||||
|
||||
yield* coordinator.wake("session", "input")
|
||||
yield* Deferred.await(firstStarted)
|
||||
yield* coordinator.wake("session", "steer")
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* coordinator.awaitIdle("session")
|
||||
|
||||
expect(requests).toEqual(["input", "steer"])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("an active wake inherits scope without starting idle work", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const requests: SessionRunCoordinator.Request[] = []
|
||||
const coordinator = yield* SessionRunCoordinator.make({
|
||||
drain: (_key, _force, request) =>
|
||||
Effect.gen(function* () {
|
||||
requests.push(request)
|
||||
if (requests.length !== 1) return
|
||||
yield* Deferred.succeed(firstStarted, undefined)
|
||||
yield* Deferred.await(release)
|
||||
}),
|
||||
})
|
||||
|
||||
yield* coordinator.wakeActive("session")
|
||||
yield* coordinator.wake("session", "steer")
|
||||
yield* Deferred.await(firstStarted)
|
||||
yield* coordinator.wakeActive("session")
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* coordinator.awaitIdle("session")
|
||||
|
||||
expect(requests).toEqual(["steer", "steer"])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("coalesces overlapping interrupt continuations into one steer successor", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const cleanupStarted = yield* Deferred.make<void>()
|
||||
const cleanupGate = yield* Deferred.make<void>()
|
||||
const requests: SessionRunCoordinator.Request[] = []
|
||||
const coordinator = yield* SessionRunCoordinator.make({
|
||||
drain: (_key, _force, request) =>
|
||||
Effect.gen(function* () {
|
||||
requests.push(request)
|
||||
if (requests.length !== 1) return
|
||||
yield* Deferred.succeed(firstStarted, undefined)
|
||||
yield* Effect.never.pipe(
|
||||
Effect.onInterrupt(() =>
|
||||
Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))),
|
||||
),
|
||||
)
|
||||
}),
|
||||
})
|
||||
const continuation = { continue: { request: "steer" as const, when: Effect.succeed(false) } }
|
||||
|
||||
yield* coordinator.wake("session")
|
||||
yield* Deferred.await(firstStarted)
|
||||
const first = yield* coordinator.interrupt("session", undefined, continuation).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(cleanupStarted)
|
||||
const second = yield* coordinator.interrupt("session", undefined, continuation).pipe(Effect.forkChild)
|
||||
yield* Effect.yieldNow
|
||||
yield* coordinator.wake("session", "input")
|
||||
yield* Deferred.succeed(cleanupGate, undefined)
|
||||
yield* Effect.all([Fiber.join(first), Fiber.join(second)])
|
||||
yield* coordinator.awaitIdle("session")
|
||||
|
||||
expect(requests).toEqual(["input", "steer"])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("a continuing interrupt replaces a cleanup-era input wake", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const cleanupStarted = yield* Deferred.make<void>()
|
||||
const cleanupGate = yield* Deferred.make<void>()
|
||||
const requests: SessionRunCoordinator.Request[] = []
|
||||
const coordinator = yield* SessionRunCoordinator.make({
|
||||
drain: (_key, _force, request) =>
|
||||
Effect.gen(function* () {
|
||||
requests.push(request)
|
||||
if (requests.length !== 1) return
|
||||
yield* Deferred.succeed(firstStarted, undefined)
|
||||
yield* Effect.never.pipe(
|
||||
Effect.onInterrupt(() =>
|
||||
Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))),
|
||||
),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
yield* coordinator.wake("session", "input")
|
||||
yield* Deferred.await(firstStarted)
|
||||
const plain = yield* coordinator.interrupt("session").pipe(Effect.forkChild)
|
||||
yield* Deferred.await(cleanupStarted)
|
||||
yield* coordinator.wake("session", "input")
|
||||
const continuing = yield* coordinator
|
||||
.interrupt("session", undefined, {
|
||||
continue: { request: "steer", when: Effect.succeed(false) },
|
||||
})
|
||||
.pipe(Effect.forkChild)
|
||||
yield* Effect.yieldNow
|
||||
yield* Deferred.succeed(cleanupGate, undefined)
|
||||
yield* Effect.all([Fiber.join(plain), Fiber.join(continuing)])
|
||||
yield* coordinator.awaitIdle("session")
|
||||
|
||||
expect(requests).toEqual(["input", "steer"])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("does not start a conditional continuation without eligible work", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const started = yield* Deferred.make<void>()
|
||||
const requests: SessionRunCoordinator.Request[] = []
|
||||
const coordinator = yield* SessionRunCoordinator.make({
|
||||
drain: (_key, _force, request) =>
|
||||
Effect.sync(() => requests.push(request)).pipe(
|
||||
Effect.andThen(Deferred.succeed(started, undefined)),
|
||||
Effect.andThen(Effect.never),
|
||||
),
|
||||
})
|
||||
|
||||
yield* coordinator.wake("session")
|
||||
yield* Deferred.await(started)
|
||||
yield* coordinator.interrupt("session", undefined, {
|
||||
continue: { request: "steer", when: Effect.succeed(false) },
|
||||
})
|
||||
yield* coordinator.awaitIdle("session")
|
||||
|
||||
expect(requests).toEqual(["input"])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("starts a resume registered during interruption cleanup", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -126,8 +126,7 @@ const execution = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
|
||||
active: coordinator.active,
|
||||
resume: coordinator.run,
|
||||
wake: coordinator.wake,
|
||||
wakeActive: coordinator.wakeActive,
|
||||
interrupt: (sessionID) => coordinator.interrupt(sessionID),
|
||||
interrupt: coordinator.interrupt,
|
||||
awaitIdle: coordinator.awaitIdle,
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -413,8 +413,7 @@ const execution = Layer.effect(
|
||||
active: coordinator.active,
|
||||
resume: coordinator.run,
|
||||
wake: coordinator.wake,
|
||||
wakeActive: coordinator.wakeActive,
|
||||
interrupt: (sessionID) => coordinator.interrupt(sessionID),
|
||||
interrupt: coordinator.interrupt,
|
||||
awaitIdle: coordinator.awaitIdle,
|
||||
})
|
||||
}),
|
||||
@@ -1384,44 +1383,6 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps queued input parked across a mid-turn move", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const bus = yield* Bus.Service
|
||||
const { db } = yield* Database.Service
|
||||
yield* admit(session, "Echo before moving")
|
||||
yield* TestLLM.push(
|
||||
TestLLM.tool("call-move", "echo", { text: "moving" }),
|
||||
TestLLM.text("Done", "text-after-move"),
|
||||
TestLLM.text("Handled queue", "text-after-queue"),
|
||||
)
|
||||
const tools = yield* blockTools()
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* tools.started
|
||||
yield* session.prompt({ sessionID, text: "Queued for later", delivery: "queue", resume: false })
|
||||
yield* SessionInbox.admit(db, bus, {
|
||||
id: SessionMessage.ID.create(),
|
||||
sessionID,
|
||||
item: {
|
||||
type: "move",
|
||||
payload: {
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/project") }),
|
||||
projectID: Project.ID.global,
|
||||
},
|
||||
delivery: "steer",
|
||||
},
|
||||
})
|
||||
|
||||
yield* tools.release
|
||||
yield* Fiber.join(run)
|
||||
|
||||
// The resumed turn absorbs steers only; queued input waits for the turn to end.
|
||||
expect(requests).toHaveLength(3)
|
||||
expect(userTexts(requests[1])).not.toContain("Queued for later")
|
||||
expect(userTexts(requests[2])).toContain("Queued for later")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("seeds a fork with the parent's newest instruction values", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
@@ -3127,24 +3088,6 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("stops a steer-scoped drain before queued input", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const { db } = yield* Database.Service
|
||||
yield* session.prompt({ sessionID, text: "Queue for later", delivery: "queue", resume: false })
|
||||
yield* session.prompt({ sessionID, text: "Steer now", resume: false })
|
||||
yield* TestLLM.push(TestLLM.stop())
|
||||
|
||||
const runner = yield* SessionRunner.Service
|
||||
yield* runner.drain({ sessionID, force: false, promotable: "steer" })
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(userTexts(requests[0])).toEqual(["Steer now"])
|
||||
expect(yield* SessionInbox.has(db, sessionID, "steer")).toBe(false)
|
||||
expect(yield* SessionInbox.has(db, sessionID, "queue")).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("promotes queued input after steering continuation ends", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
|
||||
@@ -115,7 +115,6 @@ const executionNode = makeGlobalNode({
|
||||
active: Effect.succeed(new Set()),
|
||||
resume: complete,
|
||||
wake: () => Effect.void,
|
||||
wakeActive: () => Effect.void,
|
||||
interrupt: () => Effect.void,
|
||||
awaitIdle: (id) => complete(id).pipe(Effect.exit, Effect.asVoid),
|
||||
})
|
||||
|
||||
@@ -86,7 +86,6 @@ const executionNode = makeGlobalNode({
|
||||
active: Effect.succeed(new Set()),
|
||||
resume: complete,
|
||||
wake: () => Effect.void,
|
||||
wakeActive: () => Effect.void,
|
||||
interrupt: () => Effect.void,
|
||||
awaitIdle: (sessionID) => complete(sessionID).pipe(Effect.exit, Effect.asVoid),
|
||||
})
|
||||
@@ -298,7 +297,7 @@ describe("SubagentTool", () => {
|
||||
})
|
||||
const child = yield* sessions.get(outputSessionID(settled.metadata))
|
||||
expect(settled.metadata).toEqual({ sessionID: child.id, status: "completed" })
|
||||
expect(progress[0]).toEqual({ sessionID: child.id, status: "running" })
|
||||
expect(progress[0]?.metadata).toEqual({ sessionID: child.id, status: "running" })
|
||||
expect(child).toMatchObject({
|
||||
parentID: parent.id,
|
||||
location: parent.location,
|
||||
|
||||
@@ -24,7 +24,8 @@ import {
|
||||
isOldLayoutEligible,
|
||||
} from "./onboarding"
|
||||
import { getDefaultServerUrl, preferAppEnv, setDefaultServerUrl } from "./server"
|
||||
import { registerUpdaterIpc, setupAutoUpdater, showUpdaterDialog } from "./updater"
|
||||
import { setupAutoUpdater, showUpdaterDialog } from "./updater"
|
||||
import { registerUpdaterIpc } from "./updater-ipc"
|
||||
import { safeWebContentsURL } from "./window-state"
|
||||
import {
|
||||
getLastFocusedWindow,
|
||||
@@ -241,7 +242,7 @@ const main = Effect.gen(function* () {
|
||||
const win = getLastFocusedWindow()
|
||||
if (win) sendMenuCommand(win, id)
|
||||
},
|
||||
checkForUpdates: () => void showUpdaterDialog(updater),
|
||||
checkForUpdates: () => void showUpdaterDialog(updater, true),
|
||||
relaunch,
|
||||
}
|
||||
registerIpcHandlers({
|
||||
@@ -266,7 +267,7 @@ const main = Effect.gen(function* () {
|
||||
setDisplayBackend: async () => undefined,
|
||||
checkAppExists: (appName) => checkAppExists(appName),
|
||||
resolveAppPath: async (appName) => resolveAppPath(appName),
|
||||
showUpdater: () => showUpdaterDialog(updater),
|
||||
showUpdater: () => showUpdaterDialog(updater, true),
|
||||
setBackgroundColor: (color) => setBackgroundColor(color),
|
||||
exportDebugLogs: () => exportDebugLogs(),
|
||||
recordFatalRendererError: (error) => writeLog("renderer", "fatal renderer error", { ...error }, "error"),
|
||||
|
||||
@@ -1,35 +1,26 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createUpdaterController, type UpdaterReadyRecord } from "./updater-controller"
|
||||
import { createUpdaterController, type UpdaterPlatform, type UpdaterReadyRecord } from "./updater-controller"
|
||||
|
||||
// Drives the controller the way the app does: start or check, observe the states
|
||||
// the renderer sees, then install like a button click. `calls` records the platform
|
||||
// operations in order; installs record the staged version they would apply.
|
||||
function setup(input?: {
|
||||
currentVersion?: string
|
||||
ready?: UpdaterReadyRecord
|
||||
latest?: () => string
|
||||
stage?: () => Promise<void>
|
||||
install?: () => Promise<never>
|
||||
}) {
|
||||
function setup(input?: { currentVersion?: string; ready?: UpdaterReadyRecord }) {
|
||||
const calls: string[] = []
|
||||
const states: string[] = []
|
||||
const platform: UpdaterPlatform = {
|
||||
async checkForUpdate() {
|
||||
calls.push("check")
|
||||
return "2.0.0"
|
||||
},
|
||||
async stageUpdate() {
|
||||
calls.push("download")
|
||||
},
|
||||
installAndRestart() {
|
||||
calls.push("install")
|
||||
return new Promise<never>(() => {})
|
||||
},
|
||||
}
|
||||
let ready = input?.ready
|
||||
const controller = createUpdaterController({
|
||||
enabled: true,
|
||||
currentVersion: input?.currentVersion ?? "1.0.0",
|
||||
platform: {
|
||||
async checkForUpdate() {
|
||||
calls.push("check")
|
||||
return input?.latest?.() ?? "2.0.0"
|
||||
},
|
||||
async stageUpdate() {
|
||||
calls.push("download")
|
||||
await input?.stage?.()
|
||||
},
|
||||
installAndRestart() {
|
||||
calls.push(`install:${ready?.version}`)
|
||||
return input?.install?.() ?? new Promise<never>(() => {})
|
||||
},
|
||||
},
|
||||
platform,
|
||||
lifecycle: {
|
||||
async prepareToRestart() {
|
||||
calls.push("prepare")
|
||||
@@ -45,29 +36,21 @@ function setup(input?: {
|
||||
},
|
||||
},
|
||||
})
|
||||
controller.subscribe((state) => states.push(state.status))
|
||||
return { controller, calls, states, getReady: () => ready }
|
||||
return { controller, calls, getReady: () => ready }
|
||||
}
|
||||
|
||||
describe("updater controller", () => {
|
||||
test("stages an update found at launch and shows it as ready", async () => {
|
||||
test("checks, downloads, persists, and publishes one authoritative ready state", async () => {
|
||||
const app = setup()
|
||||
const states: ReturnType<typeof app.controller.getState>[] = []
|
||||
app.controller.subscribe((state) => states.push(state))
|
||||
|
||||
await app.controller.start()
|
||||
|
||||
expect(app.states).toEqual(["idle", "checking", "downloading", "ready"])
|
||||
expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
|
||||
expect(app.calls).toEqual(["check", "download"])
|
||||
expect(app.getReady()).toEqual({ version: "2.0.0" })
|
||||
})
|
||||
|
||||
test("reports up to date and clears the record once the update is installed", async () => {
|
||||
const app = setup({ currentVersion: "2.0.0", ready: { version: "2.0.0" } })
|
||||
|
||||
await app.controller.start()
|
||||
|
||||
expect(app.states).toEqual(["idle", "checking", "up-to-date"])
|
||||
expect(app.calls).toEqual(["check"])
|
||||
expect(app.getReady()).toBeUndefined()
|
||||
expect(states.map((state) => state.status)).toEqual(["idle", "checking", "downloading", "ready"])
|
||||
expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
|
||||
})
|
||||
|
||||
test("revalidates a persisted target through the updater cache on launch", async () => {
|
||||
@@ -79,7 +62,16 @@ describe("updater controller", () => {
|
||||
expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
|
||||
})
|
||||
|
||||
test("concurrent checks share one platform check", async () => {
|
||||
test("clears a target already installed before checking", async () => {
|
||||
const app = setup({ currentVersion: "2.0.0", ready: { version: "2.0.0" } })
|
||||
|
||||
await app.controller.start()
|
||||
|
||||
expect(app.getReady()).toBeUndefined()
|
||||
expect(app.calls).toEqual(["check"])
|
||||
})
|
||||
|
||||
test("coalesces concurrent checks", async () => {
|
||||
const app = setup()
|
||||
|
||||
await Promise.all([app.controller.check(), app.controller.check(), app.controller.check()])
|
||||
@@ -87,142 +79,81 @@ describe("updater controller", () => {
|
||||
expect(app.calls).toEqual(["check", "download"])
|
||||
})
|
||||
|
||||
test("clicking install twice checks once and installs the staged version once", async () => {
|
||||
test("starts installing synchronously and coalesces restart requests", async () => {
|
||||
const app = setup()
|
||||
await app.controller.start()
|
||||
|
||||
void app.controller.install()
|
||||
void app.controller.install()
|
||||
const first = app.controller.install()
|
||||
const second = app.controller.install()
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(app.calls).toEqual(["check", "download", "check", "prepare", "install:2.0.0"])
|
||||
expect(first).toBe(second)
|
||||
await Promise.resolve()
|
||||
expect(app.calls).toEqual(["check", "download", "prepare", "install"])
|
||||
expect(app.controller.getState()).toEqual({ status: "installing", version: "2.0.0" })
|
||||
})
|
||||
|
||||
test("ignores checks while an installation is in progress", async () => {
|
||||
test("does not check for updates while installation is in progress", async () => {
|
||||
const app = setup()
|
||||
await app.controller.start()
|
||||
void app.controller.install()
|
||||
|
||||
await app.controller.check()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(app.calls).toEqual(["check", "download", "check", "prepare", "install:2.0.0"])
|
||||
})
|
||||
|
||||
test("clicking install downloads and installs a newer release", async () => {
|
||||
let latest = "2.0.0"
|
||||
const app = setup({ latest: () => latest })
|
||||
await app.controller.start()
|
||||
|
||||
latest = "3.0.0"
|
||||
void app.controller.install()
|
||||
|
||||
expect(app.controller.getState()).toEqual({ status: "installing", version: "2.0.0" })
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(app.calls).toEqual(["check", "download", "check", "download", "prepare", "install:3.0.0"])
|
||||
expect(app.controller.getState()).toEqual({ status: "installing", version: "3.0.0" })
|
||||
})
|
||||
|
||||
test("clicking install uses the staged release when the final check fails", async () => {
|
||||
let offline = false
|
||||
const app = setup({
|
||||
latest: () => {
|
||||
if (offline) throw new Error("offline")
|
||||
return "2.0.0"
|
||||
},
|
||||
})
|
||||
await app.controller.start()
|
||||
|
||||
offline = true
|
||||
void app.controller.install()
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(app.calls).toEqual(["check", "download", "check", "prepare", "install:2.0.0"])
|
||||
expect(app.calls).toEqual(["check", "download", "prepare", "install"])
|
||||
expect(app.controller.getState()).toEqual({ status: "installing", version: "2.0.0" })
|
||||
})
|
||||
|
||||
test("later checks stay silent while ready and pick up newer versions", async () => {
|
||||
let latest = "2.0.0"
|
||||
const app = setup({ latest: () => latest })
|
||||
test("returns to ready when installation fails", async () => {
|
||||
const app = setup()
|
||||
await app.controller.start()
|
||||
|
||||
await app.controller.check()
|
||||
// Nothing new was published: the install button never hid.
|
||||
expect(app.states).toEqual(["idle", "checking", "downloading", "ready"])
|
||||
|
||||
latest = "3.0.0"
|
||||
await app.controller.check()
|
||||
expect(app.states).toEqual(["idle", "checking", "downloading", "ready", "ready"])
|
||||
expect(app.controller.getState()).toEqual({ status: "ready", version: "3.0.0" })
|
||||
expect(app.getReady()).toEqual({ version: "3.0.0" })
|
||||
})
|
||||
|
||||
test("keeps the staged update installable when a silent re-check fails", async () => {
|
||||
let offline = false
|
||||
const app = setup({
|
||||
latest: () => {
|
||||
if (offline) throw new Error("offline")
|
||||
return "2.0.0"
|
||||
const error = new Error("install failed")
|
||||
const failed = createUpdaterController({
|
||||
enabled: true,
|
||||
currentVersion: "1.0.0",
|
||||
platform: {
|
||||
checkForUpdate: async () => "2.0.0",
|
||||
stageUpdate: async () => {},
|
||||
installAndRestart: () => Promise.reject(error),
|
||||
},
|
||||
lifecycle: { prepareToRestart: async () => {} },
|
||||
persistence: { get: () => undefined, set() {}, clear() {} },
|
||||
})
|
||||
await app.controller.start()
|
||||
await failed.start()
|
||||
|
||||
offline = true
|
||||
await app.controller.check()
|
||||
|
||||
expect(app.states).toEqual(["idle", "checking", "downloading", "ready"])
|
||||
expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
|
||||
expect(app.getReady()).toEqual({ version: "2.0.0" })
|
||||
await expect(failed.install()).rejects.toThrow("install failed")
|
||||
expect(failed.getState()).toEqual({ status: "ready", version: "2.0.0" })
|
||||
})
|
||||
|
||||
test("install during a silent refresh waits for the download, then installs the newer version", async () => {
|
||||
let latest = "2.0.0"
|
||||
let slowStage = false
|
||||
let releaseStage = () => {}
|
||||
const app = setup({
|
||||
latest: () => latest,
|
||||
stage: () => {
|
||||
if (!slowStage) return Promise.resolve()
|
||||
return new Promise<void>((resolve) => {
|
||||
releaseStage = resolve
|
||||
})
|
||||
},
|
||||
})
|
||||
await app.controller.start()
|
||||
|
||||
latest = "3.0.0"
|
||||
slowStage = true
|
||||
const refresh = app.controller.check()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
void app.controller.install()
|
||||
expect(app.controller.getState()).toEqual({ status: "installing", version: "2.0.0" })
|
||||
|
||||
releaseStage()
|
||||
await refresh
|
||||
expect(app.controller.getState()).toEqual({ status: "installing", version: "3.0.0" })
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(app.calls).toEqual(["check", "download", "check", "download", "prepare", "install:3.0.0"])
|
||||
})
|
||||
|
||||
test("returns to ready after a failed installation and allows a retry", async () => {
|
||||
test("allows a state subscriber to retry after installation fails", async () => {
|
||||
let attempts = 0
|
||||
const app = setup({
|
||||
install() {
|
||||
attempts++
|
||||
if (attempts === 1) return Promise.reject(new Error("install failed"))
|
||||
return new Promise<never>(() => {})
|
||||
let sawInstalling = false
|
||||
let retry: Promise<void> | undefined
|
||||
const failed = createUpdaterController({
|
||||
enabled: true,
|
||||
currentVersion: "1.0.0",
|
||||
platform: {
|
||||
checkForUpdate: async () => "2.0.0",
|
||||
stageUpdate: async () => {},
|
||||
installAndRestart() {
|
||||
attempts++
|
||||
if (attempts === 1) return Promise.reject(new Error("install failed"))
|
||||
return new Promise<never>(() => {})
|
||||
},
|
||||
},
|
||||
lifecycle: { prepareToRestart: async () => {} },
|
||||
persistence: { get: () => undefined, set() {}, clear() {} },
|
||||
})
|
||||
await app.controller.start()
|
||||
failed.subscribe((state) => {
|
||||
if (state.status === "installing") sawInstalling = true
|
||||
if (!sawInstalling || state.status !== "ready" || retry) return
|
||||
retry = failed.install()
|
||||
})
|
||||
await failed.start()
|
||||
|
||||
await expect(app.controller.install()).rejects.toThrow("install failed")
|
||||
expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
|
||||
await expect(failed.install()).rejects.toThrow("install failed")
|
||||
|
||||
void app.controller.install()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(retry).toBeDefined()
|
||||
expect(attempts).toBe(2)
|
||||
expect(app.controller.getState()).toEqual({ status: "installing", version: "2.0.0" })
|
||||
expect(failed.getState()).toEqual({ status: "installing", version: "2.0.0" })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,13 +21,14 @@ type UpdaterPersistence = {
|
||||
}
|
||||
|
||||
export function createUpdaterController(input: {
|
||||
enabled: boolean
|
||||
currentVersion: string
|
||||
platform?: UpdaterPlatform
|
||||
lifecycle: UpdaterLifecycle
|
||||
persistence: UpdaterPersistence
|
||||
log?: (message: string, data?: object) => void
|
||||
}) {
|
||||
let state: UpdaterState = input.platform ? { status: "idle" } : { status: "disabled" }
|
||||
let state: UpdaterState = input.enabled ? { status: "idle" } : { status: "disabled" }
|
||||
let pending: Promise<UpdaterState> | undefined
|
||||
let installing: Promise<void> | undefined
|
||||
const listeners = new Set<(state: UpdaterState) => void>()
|
||||
@@ -40,21 +41,13 @@ export function createUpdaterController(input: {
|
||||
}
|
||||
|
||||
const check = () => {
|
||||
if (!input.enabled) return Promise.resolve(state)
|
||||
const platform = input.platform
|
||||
if (!platform) return Promise.resolve(state)
|
||||
if (state.status === "installing") return Promise.resolve(state)
|
||||
if (state.status === "ready" || state.status === "installing") return Promise.resolve(state)
|
||||
if (pending) return pending
|
||||
|
||||
pending = (state.status === "ready" ? refreshStaged(platform, state.version) : findAndStage(platform)).finally(
|
||||
() => {
|
||||
pending = undefined
|
||||
},
|
||||
)
|
||||
return pending
|
||||
}
|
||||
|
||||
const findAndStage = (platform: UpdaterPlatform) =>
|
||||
(async () => {
|
||||
pending = (async () => {
|
||||
transition({ status: "checking" })
|
||||
const version = await platform.checkForUpdate()
|
||||
if (!version || version === input.currentVersion) {
|
||||
@@ -66,49 +59,41 @@ export function createUpdaterController(input: {
|
||||
await platform.stageUpdate()
|
||||
await input.persistence.set({ version })
|
||||
return transition({ status: "ready", version })
|
||||
})().catch((error) =>
|
||||
transition({ status: "error", message: error instanceof Error ? error.message : String(error) }),
|
||||
)
|
||||
|
||||
// A staged update stays visible and installable throughout: the refresh makes no
|
||||
// transitions until a newer version is staged, and a failure keeps the current one.
|
||||
const refreshStaged = (platform: UpdaterPlatform, staged: string) =>
|
||||
(async () => {
|
||||
const version = await platform.checkForUpdate()
|
||||
if (!version || version === staged || version === input.currentVersion) return state
|
||||
|
||||
await platform.stageUpdate()
|
||||
await input.persistence.set({ version })
|
||||
// An install may have started while this stage was in flight; keep its status
|
||||
// and show the newer version instead of flickering back to ready.
|
||||
return transition({ status: installing ? "installing" : "ready", version })
|
||||
})().catch((error) => {
|
||||
input.log?.("updater refresh failed, keeping staged update", {
|
||||
staged,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
})()
|
||||
.catch((error) =>
|
||||
transition({ status: "error", message: error instanceof Error ? error.message : String(error) }),
|
||||
)
|
||||
.finally(() => {
|
||||
pending = undefined
|
||||
})
|
||||
return state
|
||||
})
|
||||
return pending
|
||||
}
|
||||
|
||||
const install = () => {
|
||||
if (installing) return installing
|
||||
const platform = input.platform
|
||||
if (!platform || state.status !== "ready") return Promise.reject(new Error("Update is not ready to install"))
|
||||
if (state.status !== "ready") return Promise.reject(new Error("Update is not ready to install"))
|
||||
|
||||
const staged = state.version
|
||||
transition({ status: "installing", version: staged })
|
||||
installing = (async () => {
|
||||
// Installation is the commit point: refresh once more so one restart lands
|
||||
// on the newest release, or keep the known-good staged update if checking fails.
|
||||
await (pending ?? refreshStaged(platform, staged))
|
||||
await input.lifecycle.prepareToRestart()
|
||||
await platform.installAndRestart()
|
||||
})().catch((error) => {
|
||||
const version = startInstalling(state.version)
|
||||
installing = restartWithUpdate(version)
|
||||
return installing
|
||||
}
|
||||
|
||||
const startInstalling = (version: string) => {
|
||||
transition({ status: "installing", version })
|
||||
return version
|
||||
}
|
||||
|
||||
const restartWithUpdate = (version: string) =>
|
||||
prepareAndRestart().catch((error) => {
|
||||
installing = undefined
|
||||
if (state.status === "installing") transition({ status: "ready", version: state.version })
|
||||
transition({ status: "ready", version })
|
||||
throw error
|
||||
})
|
||||
return installing
|
||||
|
||||
const prepareAndRestart = async () => {
|
||||
if (!input.platform) throw new Error("Updater is disabled")
|
||||
await input.lifecycle.prepareToRestart()
|
||||
await input.platform.installAndRestart()
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { app, ipcMain } from "electron"
|
||||
import type { UpdaterController } from "./updater-controller"
|
||||
import { createUpdaterSubscriptions } from "./updater-subscriptions"
|
||||
|
||||
export function registerUpdaterIpc(controller: UpdaterController) {
|
||||
const subscriptions = createUpdaterSubscriptions()
|
||||
app.once("will-quit", subscriptions.clear)
|
||||
|
||||
ipcMain.handle("updater-subscribe", (event) => {
|
||||
const id = event.sender.id
|
||||
subscriptions.set(
|
||||
id,
|
||||
controller.subscribe((state) => {
|
||||
if (event.sender.isDestroyed()) return subscriptions.delete(id)
|
||||
event.sender.send("updater-state", state)
|
||||
}),
|
||||
)
|
||||
event.sender.once("destroyed", () => subscriptions.delete(id))
|
||||
})
|
||||
ipcMain.handle("updater-unsubscribe", (event) => subscriptions.delete(event.sender.id))
|
||||
ipcMain.handle("updater-check", () => controller.check())
|
||||
ipcMain.handle("updater-install", () => controller.install())
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createUpdaterSubscriptions } from "./updater-subscriptions"
|
||||
|
||||
describe("updater subscriptions", () => {
|
||||
test("replaces the previous renderer subscription on reload", () => {
|
||||
const subscriptions = createUpdaterSubscriptions()
|
||||
const disposed: string[] = []
|
||||
|
||||
subscriptions.set(1, () => disposed.push("first"))
|
||||
subscriptions.set(1, () => disposed.push("second"))
|
||||
|
||||
expect(disposed).toEqual(["first"])
|
||||
subscriptions.delete(1)
|
||||
expect(disposed).toEqual(["first", "second"])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
export function createUpdaterSubscriptions() {
|
||||
const subscriptions = new Map<number, () => void>()
|
||||
|
||||
const remove = (id: number) => {
|
||||
subscriptions.get(id)?.()
|
||||
subscriptions.delete(id)
|
||||
}
|
||||
|
||||
return {
|
||||
set(id: number, unsubscribe: () => void) {
|
||||
remove(id)
|
||||
subscriptions.set(id, unsubscribe)
|
||||
},
|
||||
delete: remove,
|
||||
clear() {
|
||||
subscriptions.forEach((unsubscribe) => unsubscribe())
|
||||
subscriptions.clear()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { app, dialog, ipcMain } from "electron"
|
||||
import { app, dialog } from "electron"
|
||||
import { UPDATER_ENABLED } from "./constants"
|
||||
import { createUpdaterController, type UpdaterController, type UpdaterReadyRecord } from "./updater-controller"
|
||||
import { createUpdaterController, type UpdaterReadyRecord } from "./updater-controller"
|
||||
import { getLogger } from "./logging"
|
||||
import { getStore } from "./store"
|
||||
import { nativeT } from "./native-translations"
|
||||
@@ -12,6 +12,7 @@ export function setupAutoUpdater(prepareToRestart: () => Promise<void>) {
|
||||
const logger = getLogger()
|
||||
const store = getStore("opencode.updater")
|
||||
return createUpdaterController({
|
||||
enabled: UPDATER_ENABLED,
|
||||
currentVersion: app.getVersion(),
|
||||
platform: UPDATER_ENABLED ? createUpdaterPlatform(logger) : undefined,
|
||||
lifecycle: { prepareToRestart },
|
||||
@@ -28,34 +29,10 @@ export function setupAutoUpdater(prepareToRestart: () => Promise<void>) {
|
||||
})
|
||||
}
|
||||
|
||||
export function registerUpdaterIpc(controller: UpdaterController) {
|
||||
const subscriptions = new Map<number, () => void>()
|
||||
const unsubscribe = (id: number) => {
|
||||
subscriptions.get(id)?.()
|
||||
subscriptions.delete(id)
|
||||
}
|
||||
app.once("will-quit", () => subscriptions.forEach((dispose) => dispose()))
|
||||
|
||||
ipcMain.handle("updater-subscribe", (event) => {
|
||||
const id = event.sender.id
|
||||
subscriptions.get(id)?.() // a reloaded renderer replaces its previous subscription
|
||||
subscriptions.set(
|
||||
id,
|
||||
controller.subscribe((state) => {
|
||||
if (event.sender.isDestroyed()) return unsubscribe(id)
|
||||
event.sender.send("updater-state", state)
|
||||
}),
|
||||
)
|
||||
event.sender.once("destroyed", () => unsubscribe(id))
|
||||
})
|
||||
ipcMain.handle("updater-unsubscribe", (event) => unsubscribe(event.sender.id))
|
||||
ipcMain.handle("updater-check", () => controller.check())
|
||||
ipcMain.handle("updater-install", () => controller.install())
|
||||
}
|
||||
|
||||
export async function showUpdaterDialog(controller: UpdaterController) {
|
||||
export async function showUpdaterDialog(controller: ReturnType<typeof setupAutoUpdater>, alertOnFail: boolean) {
|
||||
const state = await controller.check()
|
||||
if (state.status === "error") {
|
||||
if (!alertOnFail) return
|
||||
await dialog.showMessageBox({
|
||||
type: "error",
|
||||
message: nativeT("desktop.updater.dialog.checkFailed.message"),
|
||||
@@ -64,6 +41,7 @@ export async function showUpdaterDialog(controller: UpdaterController) {
|
||||
return
|
||||
}
|
||||
if (state.status === "up-to-date") {
|
||||
if (!alertOnFail) return
|
||||
await dialog.showMessageBox({
|
||||
type: "info",
|
||||
message: nativeT("desktop.updater.dialog.upToDate.message"),
|
||||
|
||||
@@ -3964,7 +3964,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes pending steering input while queued work remains parked.",
|
||||
"description": "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes if durable inbox work remains after interruption.",
|
||||
"summary": "Interrupt session execution"
|
||||
}
|
||||
},
|
||||
@@ -17728,9 +17728,8 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"activation": {
|
||||
"type": "string",
|
||||
"enum": ["auto", "enabled", "disabled"]
|
||||
"disabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"package": {
|
||||
"type": "string"
|
||||
@@ -17748,7 +17747,7 @@
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": ["id", "name", "activation", "package"],
|
||||
"required": ["id", "name", "package"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ProviderNotFoundError": {
|
||||
|
||||
@@ -660,7 +660,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
identifier: "v2.session.interrupt",
|
||||
summary: "Interrupt session execution",
|
||||
description:
|
||||
"Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes pending steering input while queued work remains parked.",
|
||||
"Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes if durable inbox work remains after interruption.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -25,9 +25,6 @@ export type ID = typeof ID.Type
|
||||
export const Package = Schema.String
|
||||
export type Package = typeof Package.Type
|
||||
|
||||
export const Activation = Schema.Literals(["auto", "enabled", "disabled"])
|
||||
export type Activation = typeof Activation.Type
|
||||
|
||||
export const Overlays = {
|
||||
settings: Schema.Record(Schema.String, Schema.Any).pipe(optional),
|
||||
headers: Schema.Record(Schema.String, Schema.String).pipe(optional),
|
||||
@@ -49,13 +46,13 @@ export const Info = Schema.Struct({
|
||||
id: ID,
|
||||
integrationID: Integration.ID.pipe(optional),
|
||||
name: Schema.String,
|
||||
activation: Activation,
|
||||
disabled: Schema.Boolean.pipe(optional),
|
||||
package: Package,
|
||||
...Overlays,
|
||||
})
|
||||
.annotate({ identifier: "Provider.Info" })
|
||||
.pipe(
|
||||
statics(() => ({
|
||||
empty: (id: ID): Info => ({ id, name: id, activation: "auto", package: "" }),
|
||||
empty: (id: ID): Info => ({ id, name: id, package: "" }),
|
||||
})),
|
||||
)
|
||||
|
||||
@@ -120,12 +120,10 @@ describe("contract hygiene", () => {
|
||||
test("model defaults and provider overlays preserve public invariants", () => {
|
||||
const id = Model.ID.make("model")
|
||||
expect(Model.Info.default(Provider.ID.make("provider"), id)).toMatchObject({ modelID: id, variants: [] })
|
||||
expect(Provider.Info.empty(Provider.ID.make("provider")).activation).toBe("auto")
|
||||
expect(
|
||||
Schema.decodeUnknownSync(Provider.Info)({
|
||||
id: "provider",
|
||||
name: "Provider",
|
||||
activation: "auto",
|
||||
package: "native",
|
||||
settings: { arbitrary: 1n },
|
||||
}).settings,
|
||||
|
||||
@@ -3,43 +3,7 @@ import { patchFiles } from "./apply-patch-file"
|
||||
import { text } from "./session-diff"
|
||||
|
||||
describe("apply patch file", () => {
|
||||
test("parses v2 patch metadata", () => {
|
||||
const file = patchFiles([
|
||||
{
|
||||
file: "a.ts",
|
||||
status: "modified",
|
||||
patch:
|
||||
"Index: a.ts\n===================================================================\n--- a.ts\n+++ a.ts\n@@ -1,2 +1,2 @@\n one\n-two\n+three\n",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
},
|
||||
])[0]
|
||||
|
||||
expect(file).toBeDefined()
|
||||
expect(file?.filePath).toBe("a.ts")
|
||||
expect(file?.relativePath).toBe("a.ts")
|
||||
expect(file?.type).toBe("update")
|
||||
expect(file?.view.fileDiff.name).toBe("a.ts")
|
||||
expect(file?.view.fileDiff.isPartial).toBe(true)
|
||||
expect(text(file.view, "deletions")).toBe("one\ntwo\n")
|
||||
expect(text(file.view, "additions")).toBe("one\nthree\n")
|
||||
})
|
||||
|
||||
test("maps all v2 patch statuses", () => {
|
||||
expect(
|
||||
patchFiles([
|
||||
{ file: "added.ts", status: "added", patch: "+one", additions: 1, deletions: 0 },
|
||||
{ file: "deleted.ts", status: "deleted", patch: "-one", additions: 0, deletions: 1 },
|
||||
{ file: "modified.ts", status: "modified", patch: "-one\n+two", additions: 1, deletions: 1 },
|
||||
]).map((file) => ({ file: file.filePath, type: file.type })),
|
||||
).toEqual([
|
||||
{ file: "added.ts", type: "add" },
|
||||
{ file: "deleted.ts", type: "delete" },
|
||||
{ file: "modified.ts", type: "update" },
|
||||
])
|
||||
})
|
||||
|
||||
test("parses legacy patch metadata", () => {
|
||||
test("parses patch metadata from the server", () => {
|
||||
const file = patchFiles([
|
||||
{
|
||||
filePath: "/tmp/a.ts",
|
||||
@@ -55,8 +19,8 @@ describe("apply patch file", () => {
|
||||
expect(file).toBeDefined()
|
||||
expect(file?.view.fileDiff.name).toBe("a.ts")
|
||||
expect(file?.view.fileDiff.isPartial).toBe(false)
|
||||
expect(text(file.view, "deletions")).toBe("one\ntwo\n")
|
||||
expect(text(file.view, "additions")).toBe("one\nthree\n")
|
||||
expect(text(file!.view, "deletions")).toBe("one\ntwo\n")
|
||||
expect(text(file!.view, "additions")).toBe("one\nthree\n")
|
||||
})
|
||||
|
||||
test("keeps legacy before and after payloads working", () => {
|
||||
@@ -73,7 +37,7 @@ describe("apply patch file", () => {
|
||||
])[0]
|
||||
|
||||
expect(file).toBeDefined()
|
||||
expect(text(file.view, "deletions")).toBe("one\n")
|
||||
expect(text(file.view, "additions")).toBe("two\n")
|
||||
expect(text(file!.view, "deletions")).toBe("one\n")
|
||||
expect(text(file!.view, "additions")).toBe("two\n")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,11 +3,9 @@ import { normalize, type ViewDiff } from "./session-diff"
|
||||
type Kind = "add" | "update" | "delete" | "move"
|
||||
|
||||
type Raw = {
|
||||
file?: string
|
||||
filePath?: string
|
||||
relativePath?: string
|
||||
type?: Kind
|
||||
status?: "added" | "deleted" | "modified"
|
||||
patch?: string
|
||||
diff?: string
|
||||
before?: string
|
||||
@@ -29,10 +27,6 @@ export type ApplyPatchFile = {
|
||||
|
||||
function kind(value: unknown) {
|
||||
if (value === "add" || value === "update" || value === "delete" || value === "move") return value
|
||||
if (value === "added") return "add"
|
||||
if (value === "deleted") return "delete"
|
||||
if (value === "modified") return "update"
|
||||
return undefined
|
||||
}
|
||||
|
||||
function status(type: Kind): "added" | "deleted" | "modified" {
|
||||
@@ -42,19 +36,18 @@ function status(type: Kind): "added" | "deleted" | "modified" {
|
||||
}
|
||||
|
||||
export function patchFile(raw: unknown): ApplyPatchFile | undefined {
|
||||
if (!raw || typeof raw !== "object") return undefined
|
||||
if (!raw || typeof raw !== "object") return
|
||||
|
||||
const value = raw as Raw
|
||||
const type = kind(value.type) ?? kind(value.status)
|
||||
const filePath =
|
||||
typeof value.filePath === "string" ? value.filePath : typeof value.file === "string" ? value.file : undefined
|
||||
const type = kind(value.type)
|
||||
const filePath = typeof value.filePath === "string" ? value.filePath : undefined
|
||||
const relativePath = typeof value.relativePath === "string" ? value.relativePath : filePath
|
||||
const patch = typeof value.patch === "string" ? value.patch : typeof value.diff === "string" ? value.diff : undefined
|
||||
const before = typeof value.before === "string" ? value.before : undefined
|
||||
const after = typeof value.after === "string" ? value.after : undefined
|
||||
|
||||
if (!type || !filePath || !relativePath) return undefined
|
||||
if (!patch && before === undefined && after === undefined) return undefined
|
||||
if (!type || !filePath || !relativePath) return
|
||||
if (!patch && before === undefined && after === undefined) return
|
||||
|
||||
const additions = typeof value.additions === "number" ? value.additions : 0
|
||||
const deletions = typeof value.deletions === "number" ? value.deletions : 0
|
||||
|
||||
@@ -474,17 +474,6 @@ function webSearchProviderLabel(provider: unknown, i18n: ReturnType<typeof useI1
|
||||
return i18n.t("ui.tool.websearch")
|
||||
}
|
||||
|
||||
function readToolPath(input: Record<string, unknown>) {
|
||||
if (typeof input.path === "string") return input.path
|
||||
if (typeof input.filePath === "string") return input.filePath
|
||||
}
|
||||
|
||||
function skillToolName(input: Record<string, unknown>, metadata?: Record<string, unknown>) {
|
||||
if (typeof metadata?.name === "string") return metadata.name
|
||||
if (typeof input.id === "string") return input.id
|
||||
if (typeof input.name === "string") return input.name
|
||||
}
|
||||
|
||||
export function getToolInfo(
|
||||
tool: string,
|
||||
input: any = {},
|
||||
@@ -492,14 +481,12 @@ export function getToolInfo(
|
||||
): ToolInfo {
|
||||
const i18n = useI18n()
|
||||
switch (tool) {
|
||||
case "read": {
|
||||
const path = readToolPath(input)
|
||||
case "read":
|
||||
return {
|
||||
icon: "glasses",
|
||||
title: i18n.t("ui.tool.read"),
|
||||
subtitle: path ? getFilename(path) : undefined,
|
||||
subtitle: input.filePath ? getFilename(input.filePath) : undefined,
|
||||
}
|
||||
}
|
||||
case "list":
|
||||
return {
|
||||
icon: "bullet-list",
|
||||
@@ -581,7 +568,7 @@ export function getToolInfo(
|
||||
case "skill":
|
||||
return {
|
||||
icon: "brain",
|
||||
title: skillToolName(input, metadata) || i18n.t("ui.tool.skill"),
|
||||
title: input.name || i18n.t("ui.tool.skill"),
|
||||
}
|
||||
default:
|
||||
return {
|
||||
@@ -860,7 +847,7 @@ function contextToolDetail(part: ToolPart): string | undefined {
|
||||
function contextToolTrigger(part: ToolPart, i18n: ReturnType<typeof useI18n>) {
|
||||
const input = (part.state.input ?? {}) as Record<string, unknown>
|
||||
const path = typeof input.path === "string" ? input.path : "/"
|
||||
const filePath = readToolPath(input)
|
||||
const filePath = typeof input.filePath === "string" ? input.filePath : undefined
|
||||
const pattern = typeof input.pattern === "string" ? input.pattern : undefined
|
||||
const include = typeof input.include === "string" ? input.include : undefined
|
||||
const offset = typeof input.offset === "number" ? input.offset : undefined
|
||||
@@ -1806,7 +1793,7 @@ ToolRegistry.register({
|
||||
icon="glasses"
|
||||
trigger={{
|
||||
title: i18n.t("ui.tool.read"),
|
||||
subtitle: getFilename(readToolPath(props.input) ?? ""),
|
||||
subtitle: props.input.filePath ? getFilename(props.input.filePath) : "",
|
||||
args,
|
||||
}}
|
||||
/>
|
||||
@@ -2637,7 +2624,7 @@ ToolRegistry.register({
|
||||
name: "skill",
|
||||
render(props) {
|
||||
const i18n = useI18n()
|
||||
const title = createMemo(() => skillToolName(props.input, props.metadata) || i18n.t("ui.tool.skill"))
|
||||
const title = createMemo(() => props.input.name || i18n.t("ui.tool.skill"))
|
||||
const running = createMemo(() => props.status === "pending" || props.status === "running")
|
||||
|
||||
const titleContent = () => <TextShimmer text={title()} active={running()} />
|
||||
|
||||
@@ -26,21 +26,6 @@ describe("partDefaultOpen", () => {
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test("collapses v2 patches containing only deleted files when enabled", () => {
|
||||
expect(
|
||||
partDefaultOpen(
|
||||
tool("patch", {
|
||||
files: [
|
||||
{ file: "one.ts", status: "deleted" },
|
||||
{ file: "two.ts", status: "deleted" },
|
||||
],
|
||||
}),
|
||||
false,
|
||||
true,
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test("keeps mixed patches expanded when enabled", () => {
|
||||
expect(
|
||||
partDefaultOpen(
|
||||
@@ -56,21 +41,6 @@ describe("partDefaultOpen", () => {
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("keeps mixed v2 patches expanded when enabled", () => {
|
||||
expect(
|
||||
partDefaultOpen(
|
||||
tool("patch", {
|
||||
files: [
|
||||
{ file: "one.ts", status: "deleted" },
|
||||
{ file: "two.ts", status: "modified" },
|
||||
],
|
||||
}),
|
||||
false,
|
||||
true,
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("preserves shell defaults", () => {
|
||||
expect(partDefaultOpen(tool("shell", {}), true, false)).toBe(true)
|
||||
})
|
||||
|
||||
@@ -7,12 +7,7 @@ function deletionOnly(part: ToolPart) {
|
||||
|
||||
const files = metadata.files
|
||||
if (Array.isArray(files) && files.length > 0) {
|
||||
return files.every(
|
||||
(file) =>
|
||||
!!file &&
|
||||
typeof file === "object" &&
|
||||
(("type" in file && file.type === "delete") || ("status" in file && file.status === "deleted")),
|
||||
)
|
||||
return files.every((file) => !!file && typeof file === "object" && "type" in file && file.type === "delete")
|
||||
}
|
||||
|
||||
const filediff = metadata.filediff
|
||||
@@ -21,12 +16,11 @@ function deletionOnly(part: ToolPart) {
|
||||
return filediff.additions === 0 && typeof filediff.deletions === "number" && filediff.deletions > 0
|
||||
}
|
||||
|
||||
export function partDefaultOpen(part: PartType, shell = false, edit = false): boolean | undefined {
|
||||
if (part.type !== "tool") return undefined
|
||||
export function partDefaultOpen(part: PartType, shell = false, edit = false) {
|
||||
if (part.type !== "tool") return
|
||||
if (part.tool === "bash" || part.tool === "shell") return shell
|
||||
if (part.tool === "edit" || part.tool === "write" || part.tool === "patch" || part.tool === "apply_patch") {
|
||||
if (!edit) return false
|
||||
return !deletionOnly(part)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createMemo, createResource, createSignal, onMount, Show } from "solid-js"
|
||||
import path from "path"
|
||||
import type { SessionInfo } from "@opencode-ai/client"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import type { RGBA } from "@opentui/core"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
@@ -55,12 +54,10 @@ export function DialogSessionList() {
|
||||
const response = await client.api.session.list({
|
||||
...(allProjects
|
||||
? {}
|
||||
: current.project.id === Project.ID.global
|
||||
? { directory: current.directory }
|
||||
: {
|
||||
project: current.project.id,
|
||||
subpath: path.relative(current.project.directory, current.directory).replaceAll("\\", "/"),
|
||||
}),
|
||||
: {
|
||||
project: current.project.id,
|
||||
subpath: path.relative(current.project.directory, current.directory).replaceAll("\\", "/"),
|
||||
}),
|
||||
...(query ? { search: query } : {}),
|
||||
limit: 50,
|
||||
order: "desc",
|
||||
|
||||
@@ -57,7 +57,12 @@ import {
|
||||
type LocalAttachment,
|
||||
} from "./local-attachment"
|
||||
import { useData } from "../../context/data"
|
||||
import { usePromptRef } from "../../context/prompt"
|
||||
import { useLocation } from "../../context/location"
|
||||
import type { LocationRef, PromptFileAttachment, PromptSkillAttachment, SkillInfo } from "@opencode-ai/client"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { SessionID } from "@opencode-ai/schema/session-id"
|
||||
import { useSessionTabs } from "../../context/session-tabs"
|
||||
import { Keymap, type KeymapCommand } from "../../context/keymap"
|
||||
import { abbreviateHome } from "../../runtime"
|
||||
import { Slot } from "../../plugin/render"
|
||||
@@ -71,7 +76,6 @@ import { DialogImagePreview } from "../dialog-image-preview"
|
||||
import { useDirectoryRecents } from "../../prompt/directory-recents"
|
||||
import { directoryRecentValue } from "../../prompt/directory-completion"
|
||||
import { useWorkingDirectoryActions } from "../../ui/working-directory-actions"
|
||||
import { truncateFilePath } from "../../ui/file-path"
|
||||
|
||||
export type PromptProps = {
|
||||
sessionID?: string
|
||||
@@ -101,6 +105,59 @@ export type PromptRef = {
|
||||
|
||||
const DRAFT_RETENTION_MIN_CHARS = 20
|
||||
|
||||
// Serialize background prompt submissions per session so admission order matches
|
||||
// the on-screen optimistic order, even across Prompt remounts and route changes.
|
||||
const submitTails = new Map<string, Promise<void>>()
|
||||
|
||||
function enqueueSubmit(sessionID: string, task: () => Promise<void>) {
|
||||
const tail = (submitTails.get(sessionID) ?? Promise.resolve()).then(task, task)
|
||||
submitTails.set(sessionID, tail)
|
||||
void tail.finally(() => {
|
||||
if (submitTails.get(sessionID) === tail) submitTails.delete(sessionID)
|
||||
})
|
||||
return tail
|
||||
}
|
||||
|
||||
// Approximate the server's materialized attachment shape for the local echo. Pasted
|
||||
// data: URIs carry real content so images preview immediately; file references render
|
||||
// as labels until the admission echo replaces them with server truth.
|
||||
function optimisticFiles(files: PromptInfo["files"]): PromptFileAttachment[] | undefined {
|
||||
if (!files?.length) return undefined
|
||||
return files.map((file) => {
|
||||
const match = /^data:([^;,]*);base64,(.*)$/.exec(file.uri)
|
||||
if (match)
|
||||
return {
|
||||
data: match[2] ?? "",
|
||||
mime: match[1] || "application/octet-stream",
|
||||
source: { type: "inline" as const },
|
||||
name: file.name,
|
||||
description: file.description,
|
||||
mention: file.mention,
|
||||
}
|
||||
return {
|
||||
data: "",
|
||||
mime: file.uri.endsWith("/") ? "application/x-directory" : "text/plain",
|
||||
source: { type: "uri" as const, uri: file.uri },
|
||||
name: file.name,
|
||||
description: file.description,
|
||||
mention: file.mention,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function optimisticSkills(
|
||||
skills: PromptInfo["skills"],
|
||||
available: SkillInfo[],
|
||||
): PromptSkillAttachment[] | undefined {
|
||||
if (!skills?.length) return undefined
|
||||
return skills.map((attachment) => ({
|
||||
id: attachment.id,
|
||||
name: available.find((skill) => skill.id === attachment.id)?.name ?? attachment.id,
|
||||
text: "",
|
||||
mention: attachment.mention,
|
||||
}))
|
||||
}
|
||||
|
||||
function randomIndex(count: number) {
|
||||
if (count <= 0) return 0
|
||||
return Math.floor(Math.random() * count)
|
||||
@@ -202,6 +259,8 @@ export function Prompt(props: PromptProps) {
|
||||
const editor = useEditorContext()
|
||||
const route = useRoute()
|
||||
const data = useData()
|
||||
const activePrompt = usePromptRef()
|
||||
const sessionTabs = useSessionTabs()
|
||||
const directoryRecents = useDirectoryRecents()
|
||||
const keymapCommands = Keymap.useCommands()
|
||||
const currentLocation = useLocation()
|
||||
@@ -1071,6 +1130,20 @@ export function Prompt(props: PromptProps) {
|
||||
}
|
||||
})
|
||||
|
||||
// Return a failed background submission to its author. When the target tab's
|
||||
// editor is live and empty, restore directly; otherwise stash a draft for the
|
||||
// next mount. A non-empty editor is never clobbered—prompt history retains the
|
||||
// failed prompt either way.
|
||||
function restorePrompt(sessionID: string, snapshot: PromptInfo) {
|
||||
const active = route.data
|
||||
if (active.type === "session" && active.sessionID === sessionID) {
|
||||
const live = activePrompt.current
|
||||
if (live && !live.current.text.trim()) live.set(snapshot)
|
||||
return
|
||||
}
|
||||
saveDraft(sessionID, { prompt: snapshot, cursor: snapshot.text.length })
|
||||
}
|
||||
|
||||
let submitting = false
|
||||
async function submit(delivery: SessionInbox.Delivery = "steer") {
|
||||
// Prevent overlapping invocations (e.g. a double-pressed Enter, or the
|
||||
@@ -1171,8 +1244,9 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
const variant = selection.variant
|
||||
let sessionID = props.sessionID
|
||||
let session = sessionID ? data.session.get(sessionID) : undefined
|
||||
let finishMoveProgress = false
|
||||
let createSession: (() => Promise<unknown>) | undefined
|
||||
let createLocation: LocationRef | undefined
|
||||
if (sessionID == null) {
|
||||
const directory = await move.getDirectory()
|
||||
if (move.pending() && !directory) return false
|
||||
@@ -1180,32 +1254,46 @@ export function Prompt(props: PromptProps) {
|
||||
// The location context is where the next session is created: seeded by the home
|
||||
// route (launch cwd, inherited session location, or picked project) and updated
|
||||
// by /cd before a session exists.
|
||||
const location = currentLocation.ref ?? data.location.default()
|
||||
|
||||
const created = await client.api.session
|
||||
.create({
|
||||
location: directory ? { directory } : location,
|
||||
const location = directory ? { directory } : (currentLocation.ref ?? data.location.default())
|
||||
const model = { providerID: selection.providerID, id: selection.modelID, variant }
|
||||
// Seed the session locally with a client-generated ID and create it in the
|
||||
// background so navigation and the prompt echo render immediately. Optimistic
|
||||
// creation covers the plain prompt path when the location's project is known.
|
||||
// Worktree-backed creation keeps its progress flow, and command, skill, and
|
||||
// shell submissions target the session right away, so those await creation.
|
||||
const plainPrompt = store.mode !== "shell" && !(slashHead && isCommand) && !isSkill
|
||||
const projectID = directory ? undefined : data.location.info(location)?.project.id
|
||||
if (plainPrompt && projectID) {
|
||||
const created = SessionID.create()
|
||||
data.session.optimistic.create({
|
||||
sessionID: created,
|
||||
projectID,
|
||||
location,
|
||||
agent: agent.id,
|
||||
model: {
|
||||
providerID: selection.providerID,
|
||||
id: selection.modelID,
|
||||
variant,
|
||||
},
|
||||
model,
|
||||
})
|
||||
.catch(() => undefined)
|
||||
|
||||
if (!created) {
|
||||
if (finishMoveProgress) move.finishSubmit()
|
||||
toast.show({
|
||||
message: "Creating a session failed. Open console for more details.",
|
||||
variant: "error",
|
||||
})
|
||||
|
||||
return true
|
||||
sessionID = created
|
||||
createLocation = location
|
||||
createSession = () => client.api.session.create({ id: created, location, agent: agent.id, model })
|
||||
}
|
||||
|
||||
sessionID = created.id
|
||||
session = created
|
||||
if (sessionID == null) {
|
||||
const created = await client.api.session
|
||||
.create({ location, agent: agent.id, model })
|
||||
.catch(() => undefined)
|
||||
|
||||
if (!created) {
|
||||
if (finishMoveProgress) move.finishSubmit()
|
||||
toast.show({
|
||||
message: "Creating a session failed. Open console for more details.",
|
||||
variant: "error",
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
sessionID = created.id
|
||||
}
|
||||
}
|
||||
|
||||
// Capture mode before it gets reset
|
||||
@@ -1246,70 +1334,87 @@ export function Prompt(props: PromptProps) {
|
||||
})
|
||||
} else {
|
||||
move.startSubmit()
|
||||
if (!session) {
|
||||
await data.session.sync(sessionID)
|
||||
session = data.session.get(sessionID)
|
||||
}
|
||||
if (session?.agent !== agent.id) {
|
||||
await client.api.session.switchAgent({ sessionID, agent: agent.id })
|
||||
}
|
||||
if (
|
||||
session?.model?.providerID !== selection.providerID ||
|
||||
session.model.id !== selection.modelID ||
|
||||
(session.model.variant ?? "default") !== (variant ?? "default")
|
||||
) {
|
||||
const model = { providerID: selection.providerID, id: selection.modelID, variant }
|
||||
const cancelCommit = local.model.trackSessionCommit(sessionID, model)
|
||||
await client.api.session.switchModel({ sessionID, model }).catch((error) => {
|
||||
cancelCommit()
|
||||
throw error
|
||||
})
|
||||
}
|
||||
if (session?.revert) {
|
||||
const error = await client.api.session.revert.commit({ sessionID }).then(
|
||||
() => undefined,
|
||||
(error) => error,
|
||||
)
|
||||
if (error) {
|
||||
toast.show({ title: "Failed to commit revert", message: errorMessage(error), variant: "error" })
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (pendingEditorSelection) {
|
||||
// Keep editor context hidden while admitting it before the corresponding user prompt.
|
||||
const error = await client.api.session
|
||||
.synthetic({
|
||||
sessionID,
|
||||
text: formatEditorContext(pendingEditorSelection),
|
||||
resume: false,
|
||||
// Echo the prompt locally and admit it in the background: the editor clears and
|
||||
// the message renders immediately, while a per-session queue preserves admission
|
||||
// order. Failure rolls the echo back and restores the captured prompt.
|
||||
const submitSessionID = sessionID
|
||||
const messageID = SessionMessage.ID.create()
|
||||
const snapshot = structuredClone(unwrap(store.prompt))
|
||||
const editorContextText = pendingEditorSelection ? formatEditorContext(pendingEditorSelection) : undefined
|
||||
const targetAgent = agent.id
|
||||
const model = { providerID: selection.providerID, id: selection.modelID, variant }
|
||||
data.session.optimistic.prompt({
|
||||
sessionID: submitSessionID,
|
||||
messageID,
|
||||
delivery,
|
||||
text: inputText,
|
||||
files: optimisticFiles(snapshot.files),
|
||||
agents: snapshot.agents?.length ? snapshot.agents : undefined,
|
||||
skills: optimisticSkills(snapshot.skills, data.location.skill.list(currentLocation.ref) ?? []),
|
||||
})
|
||||
// Mark the editor context sent with the echo so a rapid follow-up submit
|
||||
// does not re-attach the same selection while admission is in flight.
|
||||
if (editorContextText) editor.markSelectionSent()
|
||||
let createFailed = false
|
||||
void enqueueSubmit(submitSessionID, async () => {
|
||||
const error = await (async () => {
|
||||
if (createSession)
|
||||
await createSession().catch((error) => {
|
||||
createFailed = true
|
||||
throw error
|
||||
})
|
||||
let session = data.session.get(submitSessionID)
|
||||
if (!session) {
|
||||
await data.session.sync(submitSessionID)
|
||||
session = data.session.get(submitSessionID)
|
||||
}
|
||||
if (session?.agent !== targetAgent) {
|
||||
await client.api.session.switchAgent({ sessionID: submitSessionID, agent: targetAgent })
|
||||
}
|
||||
if (
|
||||
session?.model?.providerID !== model.providerID ||
|
||||
session.model.id !== model.id ||
|
||||
(session.model.variant ?? "default") !== (model.variant ?? "default")
|
||||
) {
|
||||
const cancelCommit = local.model.trackSessionCommit(submitSessionID, model)
|
||||
await client.api.session.switchModel({ sessionID: submitSessionID, model }).catch((error) => {
|
||||
cancelCommit()
|
||||
throw error
|
||||
})
|
||||
}
|
||||
if (session?.revert) await client.api.session.revert.commit({ sessionID: submitSessionID })
|
||||
// Keep editor context hidden while admitting it before the corresponding user prompt.
|
||||
if (editorContextText)
|
||||
await client.api.session.synthetic({ sessionID: submitSessionID, text: editorContextText, resume: false })
|
||||
await client.api.session.prompt({
|
||||
sessionID: submitSessionID,
|
||||
id: messageID,
|
||||
text: inputText,
|
||||
files: snapshot.files,
|
||||
agents: snapshot.agents,
|
||||
skills: snapshot.skills?.length ? snapshot.skills : undefined,
|
||||
delivery,
|
||||
})
|
||||
.then(
|
||||
() => undefined,
|
||||
(error) => error,
|
||||
)
|
||||
if (error) {
|
||||
toast.show({ title: "Failed to send editor context", message: errorMessage(error), variant: "error" })
|
||||
return false
|
||||
}
|
||||
}
|
||||
const error = await client.api.session
|
||||
.prompt({
|
||||
sessionID,
|
||||
text: inputText,
|
||||
files: store.prompt.files,
|
||||
agents: store.prompt.agents,
|
||||
skills: store.prompt.skills?.length ? store.prompt.skills : undefined,
|
||||
delivery,
|
||||
})
|
||||
.then(
|
||||
})().then(
|
||||
() => undefined,
|
||||
(error) => error,
|
||||
)
|
||||
if (error) {
|
||||
if (error === undefined) return
|
||||
data.session.optimistic.rollback(submitSessionID, messageID)
|
||||
if (createFailed) {
|
||||
// The session never existed server-side: leave the optimistic tab, return
|
||||
// to the home screen with the prompt restored, and drop the seed.
|
||||
if (route.data.type === "session" && route.data.sessionID === submitSessionID)
|
||||
route.navigate({ type: "home", prompt: snapshot, location: createLocation })
|
||||
else saveDraft(undefined, { prompt: snapshot, cursor: snapshot.text.length })
|
||||
sessionTabs.close(submitSessionID)
|
||||
data.session.optimistic.rollbackCreate(submitSessionID)
|
||||
toast.show({ title: "Failed to create session", message: errorMessage(error), variant: "error" })
|
||||
return
|
||||
}
|
||||
restorePrompt(submitSessionID, snapshot)
|
||||
toast.show({ title: "Failed to send prompt", message: errorMessage(error), variant: "error" })
|
||||
return false
|
||||
}
|
||||
if (pendingEditorSelection) editor.markSelectionSent()
|
||||
})
|
||||
}
|
||||
history.append({
|
||||
...store.prompt,
|
||||
@@ -1320,15 +1425,14 @@ export function Prompt(props: PromptProps) {
|
||||
setStore("extmarkToPart", new Map())
|
||||
props.onSubmit?.()
|
||||
|
||||
// temporary hack to make sure the message is sent
|
||||
if (!props.sessionID) {
|
||||
if (pendingEditorSelection) editor.preserveSelectionFromNewSession()
|
||||
setTimeout(() => {
|
||||
route.navigate({
|
||||
type: "session",
|
||||
sessionID,
|
||||
})
|
||||
}, 50)
|
||||
// The optimistic echo is already in the data store, so the session route
|
||||
// renders the prompt immediately; admission continues in the background.
|
||||
route.navigate({
|
||||
type: "session",
|
||||
sessionID,
|
||||
})
|
||||
}
|
||||
input.clear()
|
||||
if (finishMoveProgress) move.finishSubmit()
|
||||
@@ -1556,12 +1660,6 @@ export function Prompt(props: PromptProps) {
|
||||
const branch = data.location.vcs.info(location)?.branch.current
|
||||
return branch ? `${directory}:${branch}` : directory
|
||||
})
|
||||
const [locationWidth, setLocationWidth] = createSignal(dimensions().width)
|
||||
const locationLabelDisplay = createMemo(() => {
|
||||
const label = locationLabel()
|
||||
if (!label) return
|
||||
return truncateFilePath(label, locationWidth())
|
||||
})
|
||||
const locationActions = useWorkingDirectoryActions({
|
||||
directory: () => footerLocation()?.directory,
|
||||
onMove: () => void move.open(),
|
||||
@@ -1847,15 +1945,7 @@ export function Prompt(props: PromptProps) {
|
||||
<box width="100%" flexDirection="row" justifyContent="space-between" gap={2}>
|
||||
<Slot path="prompt.footer" input={footerInput()}>
|
||||
<Slot path="prompt.footer.status" input={footerInput()}>
|
||||
<box
|
||||
flexGrow={1}
|
||||
flexShrink={1}
|
||||
minWidth={0}
|
||||
onSizeChange={function (this: BoxRenderable) {
|
||||
const width = this.width
|
||||
queueMicrotask(() => setLocationWidth(width))
|
||||
}}
|
||||
>
|
||||
<box flexGrow={1} flexShrink={1} minWidth={0}>
|
||||
<Switch>
|
||||
<Match when={status() === "running"}>
|
||||
<box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start">
|
||||
@@ -1892,7 +1982,7 @@ export function Prompt(props: PromptProps) {
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<Show when={!props.hint && locationLabelDisplay()} fallback={props.hint ?? <text />}>
|
||||
<Show when={!props.hint && locationLabel()} fallback={props.hint ?? <text />}>
|
||||
{(location) => (
|
||||
<text
|
||||
id="prompt.footer.location"
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
ProviderInfo,
|
||||
ReferenceInfo,
|
||||
SessionMessageInfo,
|
||||
SessionMessageUser,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageAssistantReasoning,
|
||||
SessionMessageAssistantText,
|
||||
@@ -40,7 +41,7 @@ import { useClient } from "./client"
|
||||
import { nonEmptyToolContent } from "../util/tool-display"
|
||||
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
|
||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import { createEffect, createSignal, onCleanup } from "solid-js"
|
||||
import { batch, createEffect, createSignal, onCleanup } from "solid-js"
|
||||
|
||||
export type DataSessionStatus = "idle" | "running"
|
||||
|
||||
@@ -161,6 +162,25 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
const messageIndex = new Map<string, Map<string, number>>()
|
||||
const sync = createSync()
|
||||
|
||||
// Optimistic prompt echoes: user prompts applied locally before server admission,
|
||||
// keyed sessionID -> messageID. The session.inbox.enqueued echo carrying the same
|
||||
// ID replaces the local copy with server truth (admission materializes file
|
||||
// attachments and expands skills). Entries survive wholesale sync replaces until
|
||||
// the server confirms them or the submitter rolls back.
|
||||
type OptimisticPrompt = { message: SessionMessageInfo; pending: SessionInboxInfo }
|
||||
const optimisticPrompts = new Map<string, Map<string, OptimisticPrompt>>()
|
||||
|
||||
function confirmOptimistic(sessionID: string, messageID: string) {
|
||||
const entries = optimisticPrompts.get(sessionID)
|
||||
if (!entries?.delete(messageID)) return false
|
||||
if (entries.size === 0) optimisticPrompts.delete(sessionID)
|
||||
return true
|
||||
}
|
||||
|
||||
function optimisticMessages(sessionID: string) {
|
||||
return [...(optimisticPrompts.get(sessionID)?.values() ?? [])]
|
||||
}
|
||||
|
||||
function setSessionActive(sessionID: string, status: DataSessionStatus) {
|
||||
setStore("session", "active", sessionID, status)
|
||||
}
|
||||
@@ -188,6 +208,17 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
)
|
||||
}
|
||||
|
||||
function removeMessage(sessionID: string, messageID: string) {
|
||||
if (!messageIndex.get(sessionID)?.has(messageID)) return
|
||||
message.update(sessionID, (draft, index) => {
|
||||
const position = index.get(messageID)
|
||||
if (position === undefined) return
|
||||
draft.splice(position, 1)
|
||||
index.delete(messageID)
|
||||
message.reindex(draft, index, position)
|
||||
})
|
||||
}
|
||||
|
||||
function removePermission(sessionID: string, requestID: string) {
|
||||
const requests = store.session.permission[sessionID]
|
||||
if (!requests?.some((request) => request.id === requestID)) return
|
||||
@@ -313,7 +344,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
|
||||
function removeSession(sessionID: string) {
|
||||
messageIndex.delete(sessionID)
|
||||
optimisticPrompts.delete(sessionID)
|
||||
sync.invalidate(`session:${sessionID}`)
|
||||
sync.invalidate(`session.family:${sessionID}`)
|
||||
sync.invalidate(`session.pending:${sessionID}`)
|
||||
sync.invalidate(`session.message:${sessionID}`)
|
||||
sync.invalidate(`session.permission:${sessionID}`)
|
||||
@@ -471,6 +504,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
break
|
||||
}
|
||||
case "session.inbox.delivered": {
|
||||
// Delivery implies the message is projected server-side, so future message
|
||||
// fetches include it and the optimistic entry no longer needs re-appending.
|
||||
confirmOptimistic(event.data.sessionID, event.data.inboxID)
|
||||
const admitted = store.session.input[event.data.sessionID]?.includes(event.data.inboxID) ?? false
|
||||
removePending(event.data.sessionID, event.data.inboxID)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
@@ -489,25 +525,31 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
updatePending(event.data.sessionID, event.data.inboxID, event.data.delivery)
|
||||
break
|
||||
case "session.inbox.cancelled": {
|
||||
confirmOptimistic(event.data.sessionID, event.data.inboxID)
|
||||
removePending(event.data.sessionID, event.data.inboxID)
|
||||
if (messageIndex.get(event.data.sessionID)?.has(event.data.inboxID))
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const position = index.get(event.data.inboxID)
|
||||
if (position === undefined) return
|
||||
draft.splice(position, 1)
|
||||
index.delete(event.data.inboxID)
|
||||
message.reindex(draft, index, position)
|
||||
})
|
||||
removeMessage(event.data.sessionID, event.data.inboxID)
|
||||
break
|
||||
}
|
||||
case "session.inbox.enqueued": {
|
||||
const item = event.data.item
|
||||
addPending({
|
||||
// The admission echo is authoritative for an optimistic local copy: it
|
||||
// materializes file attachments and expands skills, so replace in place.
|
||||
const confirmed = confirmOptimistic(event.data.sessionID, event.data.inboxID)
|
||||
const pendingItem: SessionInboxInfo = {
|
||||
id: event.data.inboxID,
|
||||
sessionID: event.data.sessionID,
|
||||
timeCreated: event.created,
|
||||
...item,
|
||||
})
|
||||
}
|
||||
const pendingList = store.session.pending[event.data.sessionID]
|
||||
if (confirmed && pendingList?.some((pending) => pending.id === event.data.inboxID))
|
||||
setStore(
|
||||
"session",
|
||||
"pending",
|
||||
event.data.sessionID,
|
||||
pendingList.map((pending) => (pending.id === event.data.inboxID ? pendingItem : pending)),
|
||||
)
|
||||
else addPending(pendingItem)
|
||||
if (!store.session.input[event.data.sessionID]?.includes(event.data.inboxID))
|
||||
setStore("session", "input", event.data.sessionID, [
|
||||
...(store.session.input[event.data.sessionID] ?? []),
|
||||
@@ -515,9 +557,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
])
|
||||
if (item.type !== "user" && item.type !== "synthetic") break
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(
|
||||
draft,
|
||||
index,
|
||||
const next: SessionMessageInfo =
|
||||
item.type === "user"
|
||||
? {
|
||||
id: event.data.inboxID,
|
||||
@@ -530,8 +570,13 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
type: "synthetic",
|
||||
...item.payload,
|
||||
time: { created: event.created },
|
||||
},
|
||||
)
|
||||
}
|
||||
const position = index.get(event.data.inboxID)
|
||||
if (confirmed && position !== undefined) {
|
||||
draft[position] = next
|
||||
return
|
||||
}
|
||||
message.append(draft, index, next)
|
||||
})
|
||||
break
|
||||
}
|
||||
@@ -825,22 +870,30 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
if (store.session.info[event.data.sessionID])
|
||||
setStore("session", "info", event.data.sessionID, "revert", undefined)
|
||||
break
|
||||
case "session.revert.committed":
|
||||
case "session.revert.committed": {
|
||||
if (store.session.info[event.data.sessionID]) {
|
||||
setStore("session", "info", event.data.sessionID, "revert", undefined)
|
||||
}
|
||||
// Unconfirmed optimistic prompts postdate the revert boundary but were never
|
||||
// part of the reverted history: the server admits them after the commit.
|
||||
const local = optimisticPrompts.get(event.data.sessionID)
|
||||
setStore(
|
||||
"session",
|
||||
"input",
|
||||
event.data.sessionID,
|
||||
(store.session.input[event.data.sessionID] ?? []).filter((id) => id < event.data.to),
|
||||
(store.session.input[event.data.sessionID] ?? []).filter(
|
||||
(id) => id < event.data.to || local?.has(id) === true,
|
||||
),
|
||||
)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const position = draft.findIndex((item) => item.id >= event.data.to)
|
||||
if (position === -1) return
|
||||
for (const item of draft.splice(position)) index.delete(item.id)
|
||||
const dropped = draft.splice(position)
|
||||
for (const item of dropped) index.delete(item.id)
|
||||
for (const item of dropped) if (local?.has(item.id)) message.append(draft, index, item)
|
||||
})
|
||||
break
|
||||
}
|
||||
case "session.compaction.delta":
|
||||
message.update(event.data.sessionID, (draft) => {
|
||||
const current = message.compaction(draft)
|
||||
@@ -1013,13 +1066,109 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
return store.session.input[sessionID]?.includes(inboxID) ?? false
|
||||
},
|
||||
},
|
||||
optimistic: {
|
||||
// Seed a session locally before server creation so navigation and the first
|
||||
// prompt echo render immediately. Reads are marked complete so the session
|
||||
// route does not fetch a session the server does not know yet; the
|
||||
// session.created echo invalidates the info read and loads server truth.
|
||||
create(input: {
|
||||
sessionID: string
|
||||
projectID: string
|
||||
location: LocationRef
|
||||
agent?: string
|
||||
model?: SessionInfo["model"]
|
||||
}) {
|
||||
const now = Date.now()
|
||||
const info: SessionInfo = {
|
||||
id: input.sessionID,
|
||||
projectID: input.projectID,
|
||||
location: input.location,
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: now, updated: now },
|
||||
}
|
||||
batch(() => {
|
||||
setStore("session", "info", input.sessionID, info)
|
||||
registerSession(input.sessionID)
|
||||
})
|
||||
sync.complete(`session:${input.sessionID}`)
|
||||
sync.complete(`session.family:${input.sessionID}`)
|
||||
sync.complete(`session.pending:${input.sessionID}`)
|
||||
sync.complete(`session.message:${input.sessionID}`)
|
||||
},
|
||||
// Remove a seeded session after creation fails. Callers navigate away first.
|
||||
rollbackCreate(sessionID: string) {
|
||||
removeSession(sessionID)
|
||||
},
|
||||
// Locally echo a user prompt before server admission. The session.inbox.enqueued
|
||||
// echo carrying the same message ID replaces the copy with server truth; rollback
|
||||
// removes the echo when submission fails.
|
||||
prompt(input: {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
delivery: SessionInbox.Delivery
|
||||
text: string
|
||||
files?: SessionMessageUser["files"]
|
||||
agents?: SessionMessageUser["agents"]
|
||||
skills?: SessionMessageUser["skills"]
|
||||
}) {
|
||||
const created = Date.now()
|
||||
const pendingItem: SessionInboxInfo = {
|
||||
id: input.messageID,
|
||||
sessionID: input.sessionID,
|
||||
timeCreated: created,
|
||||
type: "user",
|
||||
payload: { text: input.text, files: input.files, agents: input.agents, skills: input.skills },
|
||||
delivery: input.delivery,
|
||||
}
|
||||
const messageItem: SessionMessageInfo = {
|
||||
id: input.messageID,
|
||||
type: "user",
|
||||
text: input.text,
|
||||
files: input.files,
|
||||
agents: input.agents,
|
||||
skills: input.skills,
|
||||
time: { created },
|
||||
}
|
||||
const entries = optimisticPrompts.get(input.sessionID) ?? new Map<string, OptimisticPrompt>()
|
||||
optimisticPrompts.set(input.sessionID, entries)
|
||||
entries.set(input.messageID, { message: messageItem, pending: pendingItem })
|
||||
batch(() => {
|
||||
addPending(pendingItem)
|
||||
if (!store.session.input[input.sessionID]?.includes(input.messageID))
|
||||
setStore("session", "input", input.sessionID, [
|
||||
...(store.session.input[input.sessionID] ?? []),
|
||||
input.messageID,
|
||||
])
|
||||
message.update(input.sessionID, (draft, index) => message.append(draft, index, messageItem))
|
||||
})
|
||||
},
|
||||
rollback(sessionID: string, messageID: string) {
|
||||
if (!confirmOptimistic(sessionID, messageID)) return
|
||||
batch(() => {
|
||||
removePending(sessionID, messageID)
|
||||
removeMessage(sessionID, messageID)
|
||||
})
|
||||
},
|
||||
},
|
||||
pending: {
|
||||
list(sessionID: string) {
|
||||
return store.session.pending[sessionID] ?? []
|
||||
},
|
||||
sync(sessionID: string) {
|
||||
return sync.run(`session.pending:${sessionID}`, async () => {
|
||||
const pending = await client.api.session.inbox.list({ sessionID })
|
||||
const fetched = await client.api.session.inbox.list({ sessionID })
|
||||
// Keep unconfirmed optimistic prompts pending across the wholesale replace.
|
||||
// Server presence here is not treated as confirmation: until the enqueued
|
||||
// echo or a projected message arrives, the ledger still guards message.sync.
|
||||
const pending = [
|
||||
...fetched,
|
||||
...optimisticMessages(sessionID)
|
||||
.map((entry) => entry.pending)
|
||||
.filter((entry) => !fetched.some((item) => item.id === entry.id)),
|
||||
]
|
||||
setStore("session", "pending", sessionID, reconcile(pending))
|
||||
setStore(
|
||||
"session",
|
||||
@@ -1069,9 +1218,15 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
},
|
||||
sync(sessionID: string) {
|
||||
return sync.run(`session.message:${sessionID}`, async () => {
|
||||
const messages = (
|
||||
const fetched = (
|
||||
await client.api.message.list({ sessionID, limit: 200, order: "desc" })
|
||||
).data.toReversed()
|
||||
// A wholesale replace would drop optimistic prompts the server has not
|
||||
// admitted yet. A fetched ID is server confirmation; the rest re-append.
|
||||
for (const entry of optimisticMessages(sessionID))
|
||||
if (fetched.some((item) => item.id === entry.message.id))
|
||||
confirmOptimistic(sessionID, entry.message.id)
|
||||
const messages = [...fetched, ...optimisticMessages(sessionID).map((entry) => entry.message)]
|
||||
messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])))
|
||||
setStore("session", "message", sessionID, reconcile(messages))
|
||||
})
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { expect, test } from "bun:test"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client"
|
||||
import { createEffect, type ParentProps } from "solid-js"
|
||||
import { ConfigProvider } from "../../../src/config"
|
||||
import { ClientProvider, useClient } from "../../../src/context/client"
|
||||
import { DataProvider as DataProviderBase, useData } from "../../../src/context/data"
|
||||
import { LocationProvider, useLocation } from "../../../src/context/location"
|
||||
import { createApi, createEventStream, createFetch, directory, json } from "../../fixture/tui-client"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
|
||||
async function wait(fn: () => boolean, timeout = 2000) {
|
||||
const start = Date.now()
|
||||
while (!fn()) {
|
||||
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
|
||||
await Bun.sleep(10)
|
||||
}
|
||||
}
|
||||
|
||||
function emitEvent(events: ReturnType<typeof createEventStream>, event: OpenCodeEvent) {
|
||||
events.emit({ ...event, location: { directory } })
|
||||
}
|
||||
|
||||
const config = createTuiResolvedConfig()
|
||||
|
||||
function DataProvider(props: ParentProps) {
|
||||
return (
|
||||
<ConfigProvider config={config}>
|
||||
<DataProviderBase>
|
||||
<LocationProvider>
|
||||
<SyncLocation />
|
||||
{props.children}
|
||||
</LocationProvider>
|
||||
</DataProviderBase>
|
||||
</ConfigProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function SyncLocation() {
|
||||
const data = useData()
|
||||
const location = useLocation()
|
||||
createEffect(() => location.set(data.location.default()))
|
||||
return null
|
||||
}
|
||||
|
||||
function durable(sessionID: string, seq = 0): { aggregateID: string; seq: number; version: 1 } {
|
||||
return { aggregateID: sessionID, seq, version: 1 }
|
||||
}
|
||||
|
||||
type Harness = {
|
||||
data: ReturnType<typeof useData>
|
||||
client: ReturnType<typeof useClient>
|
||||
}
|
||||
|
||||
async function renderData(fetch: ReturnType<typeof createFetch>["fetch"]) {
|
||||
const harness = {} as Harness
|
||||
|
||||
function Probe() {
|
||||
harness.client = useClient()
|
||||
harness.data = useData()
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<ClientProvider api={createApi(fetch)}>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
</DataProvider>
|
||||
</ClientProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
await wait(() => harness.client.connection.status() === "connected")
|
||||
return { app, ...harness }
|
||||
}
|
||||
|
||||
test("echoes an optimistic prompt and replaces it with the admission echo", async () => {
|
||||
const events = createEventStream()
|
||||
const sessionID = "session-optimistic-echo"
|
||||
const calls = createFetch(undefined, events)
|
||||
const { app, data } = await renderData(calls.fetch)
|
||||
|
||||
try {
|
||||
data.session.optimistic.prompt({
|
||||
sessionID,
|
||||
messageID: "msg_optimistic",
|
||||
delivery: "steer",
|
||||
text: "Hello",
|
||||
files: [{ data: "", mime: "text/plain", source: { type: "uri", uri: "file:///tmp/a.ts" }, name: "a.ts" }],
|
||||
})
|
||||
|
||||
const echoed = data.session.message.get(sessionID, "msg_optimistic")
|
||||
expect(echoed?.type === "user" && echoed.text).toBe("Hello")
|
||||
expect(echoed?.type === "user" && echoed.files?.[0]?.data).toBe("")
|
||||
expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual(["msg_optimistic"])
|
||||
expect(data.session.input.has(sessionID, "msg_optimistic")).toBe(true)
|
||||
|
||||
// Admission materializes attachments, so the echo must be replaced in place.
|
||||
emitEvent(events, {
|
||||
id: "evt_admitted",
|
||||
created: 9,
|
||||
type: "session.inbox.enqueued",
|
||||
durable: durable(sessionID),
|
||||
data: {
|
||||
sessionID,
|
||||
inboxID: "msg_optimistic",
|
||||
item: {
|
||||
type: "user",
|
||||
payload: {
|
||||
text: "Hello",
|
||||
files: [{ data: "QUJD", mime: "text/plain", source: { type: "uri", uri: "file:///tmp/a.ts" }, name: "a.ts" }],
|
||||
},
|
||||
delivery: "steer",
|
||||
},
|
||||
},
|
||||
})
|
||||
await wait(() => {
|
||||
const message = data.session.message.get(sessionID, "msg_optimistic")
|
||||
return message?.type === "user" && message.files?.[0]?.data === "QUJD"
|
||||
})
|
||||
expect(data.session.message.list(sessionID)).toHaveLength(1)
|
||||
expect(data.session.pending.list(sessionID)).toHaveLength(1)
|
||||
expect(data.session.pending.list(sessionID)[0]?.timeCreated).toBe(9)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("rolls back a failed optimistic prompt", async () => {
|
||||
const events = createEventStream()
|
||||
const sessionID = "session-optimistic-rollback"
|
||||
const calls = createFetch(undefined, events)
|
||||
const { app, data } = await renderData(calls.fetch)
|
||||
|
||||
try {
|
||||
data.session.optimistic.prompt({
|
||||
sessionID,
|
||||
messageID: "msg_failed",
|
||||
delivery: "queue",
|
||||
text: "Will fail",
|
||||
})
|
||||
expect(data.session.message.get(sessionID, "msg_failed")).toBeDefined()
|
||||
|
||||
data.session.optimistic.rollback(sessionID, "msg_failed")
|
||||
expect(data.session.message.get(sessionID, "msg_failed")).toBeUndefined()
|
||||
expect(data.session.pending.list(sessionID)).toHaveLength(0)
|
||||
expect(data.session.input.has(sessionID, "msg_failed")).toBe(false)
|
||||
|
||||
// Rollback of an unknown or already-settled echo is a no-op.
|
||||
data.session.optimistic.rollback(sessionID, "msg_failed")
|
||||
expect(data.session.message.list(sessionID)).toHaveLength(0)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("optimistic prompts survive sync replaces until the server confirms them", async () => {
|
||||
const events = createEventStream()
|
||||
const sessionID = "session-optimistic-sync"
|
||||
let serverMessages: SessionMessageInfo[] = []
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: serverMessages, cursor: {} })
|
||||
if (url.pathname === `/api/session/${sessionID}/inbox`) return json({ data: [] })
|
||||
}, events)
|
||||
const { app, data } = await renderData(calls.fetch)
|
||||
|
||||
try {
|
||||
data.session.optimistic.prompt({
|
||||
sessionID,
|
||||
messageID: "msg_pending",
|
||||
delivery: "steer",
|
||||
text: "Survive the sync",
|
||||
})
|
||||
|
||||
// A wholesale replace from an empty server page keeps the unconfirmed echo.
|
||||
await data.session.message.sync(sessionID)
|
||||
expect(data.session.message.get(sessionID, "msg_pending")).toBeDefined()
|
||||
|
||||
await data.session.pending.sync(sessionID)
|
||||
expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual(["msg_pending"])
|
||||
expect(data.session.input.has(sessionID, "msg_pending")).toBe(true)
|
||||
|
||||
// A fetched page containing the ID is server confirmation: the projected copy
|
||||
// wins and later rollback attempts become no-ops.
|
||||
serverMessages = [{ id: "msg_pending", type: "user", text: "Survive the sync", time: { created: 5 } }]
|
||||
data.session.message.invalidate(sessionID)
|
||||
await data.session.message.sync(sessionID)
|
||||
const confirmed = data.session.message.get(sessionID, "msg_pending")
|
||||
expect(confirmed?.type === "user" && confirmed.time.created).toBe(5)
|
||||
expect(data.session.message.list(sessionID)).toHaveLength(1)
|
||||
|
||||
data.session.optimistic.rollback(sessionID, "msg_pending")
|
||||
expect(data.session.message.get(sessionID, "msg_pending")).toBeDefined()
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("cancellation clears the optimistic echo for good", async () => {
|
||||
const events = createEventStream()
|
||||
const sessionID = "session-optimistic-cancel"
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
|
||||
}, events)
|
||||
const { app, data } = await renderData(calls.fetch)
|
||||
|
||||
try {
|
||||
data.session.optimistic.prompt({
|
||||
sessionID,
|
||||
messageID: "msg_cancelled",
|
||||
delivery: "queue",
|
||||
text: "Cancel me",
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_cancelled",
|
||||
created: 2,
|
||||
type: "session.inbox.cancelled",
|
||||
durable: durable(sessionID),
|
||||
data: { sessionID, inboxID: "msg_cancelled" },
|
||||
})
|
||||
await wait(() => data.session.message.get(sessionID, "msg_cancelled") === undefined)
|
||||
expect(data.session.pending.list(sessionID)).toHaveLength(0)
|
||||
|
||||
// The ledger entry is gone too: a sync replace must not resurrect the echo.
|
||||
await data.session.message.sync(sessionID)
|
||||
expect(data.session.message.get(sessionID, "msg_cancelled")).toBeUndefined()
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("optimistic session creation seeds info and suppresses initial reads", async () => {
|
||||
const events = createEventStream()
|
||||
const sessionID = "ses_optimistic"
|
||||
const fetched: string[] = []
|
||||
const calls = createFetch((url) => {
|
||||
if (!url.pathname.startsWith(`/api/session/${sessionID}`)) return
|
||||
fetched.push(url.pathname)
|
||||
if (url.pathname === `/api/session/${sessionID}`)
|
||||
return json({
|
||||
data: {
|
||||
id: sessionID,
|
||||
projectID: "proj_test",
|
||||
location: { directory },
|
||||
agent: "build",
|
||||
title: "Server title",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 1 },
|
||||
},
|
||||
})
|
||||
}, events)
|
||||
const { app, data } = await renderData(calls.fetch)
|
||||
|
||||
try {
|
||||
data.session.optimistic.create({
|
||||
sessionID,
|
||||
projectID: "proj_test",
|
||||
location: { directory },
|
||||
agent: "build",
|
||||
model: { providerID: "provider", id: "model" },
|
||||
})
|
||||
const seeded = data.session.get(sessionID)
|
||||
expect(seeded?.projectID).toBe("proj_test")
|
||||
expect(seeded?.agent).toBe("build")
|
||||
|
||||
// Seeded reads are marked complete: the server does not know the session yet.
|
||||
await data.session.sync(sessionID, { children: true })
|
||||
await data.session.message.sync(sessionID)
|
||||
await data.session.pending.sync(sessionID)
|
||||
expect(fetched).toEqual([])
|
||||
|
||||
// The session.created echo invalidates the info read and loads server truth.
|
||||
emitEvent(events, {
|
||||
id: "evt_created",
|
||||
created: 2,
|
||||
type: "session.created",
|
||||
durable: durable(sessionID),
|
||||
data: {
|
||||
sessionID,
|
||||
projectID: "proj_test",
|
||||
location: { directory },
|
||||
slug: "server-slug",
|
||||
agent: "build",
|
||||
version: "test",
|
||||
},
|
||||
})
|
||||
await wait(() => data.session.get(sessionID)?.title === "Server title")
|
||||
expect(fetched).toEqual([`/api/session/${sessionID}`])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("rollbackCreate removes the seeded session and re-enables reads", async () => {
|
||||
const events = createEventStream()
|
||||
const sessionID = "ses_rollback"
|
||||
const fetched: string[] = []
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname !== `/api/session/${sessionID}/message`) return
|
||||
fetched.push(url.pathname)
|
||||
return json({ data: [], cursor: {} })
|
||||
}, events)
|
||||
const { app, data } = await renderData(calls.fetch)
|
||||
|
||||
try {
|
||||
data.session.optimistic.create({
|
||||
sessionID,
|
||||
projectID: "proj_test",
|
||||
location: { directory },
|
||||
})
|
||||
data.session.optimistic.prompt({
|
||||
sessionID,
|
||||
messageID: "msg_first",
|
||||
delivery: "steer",
|
||||
text: "First prompt",
|
||||
})
|
||||
expect(data.session.message.list(sessionID)).toHaveLength(1)
|
||||
|
||||
data.session.optimistic.rollback(sessionID, "msg_first")
|
||||
data.session.optimistic.rollbackCreate(sessionID)
|
||||
expect(data.session.get(sessionID)).toBeUndefined()
|
||||
expect(data.session.message.list(sessionID)).toHaveLength(0)
|
||||
expect(data.session.pending.list(sessionID)).toHaveLength(0)
|
||||
|
||||
// The seed's completed read markers are gone with it.
|
||||
await data.session.message.sync(sessionID)
|
||||
expect(fetched).toEqual([`/api/session/${sessionID}/message`])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("revert commit preserves unconfirmed optimistic prompts", async () => {
|
||||
const events = createEventStream()
|
||||
const sessionID = "session-optimistic-revert"
|
||||
const calls = createFetch(undefined, events)
|
||||
const { app, data } = await renderData(calls.fetch)
|
||||
|
||||
try {
|
||||
for (const [seq, id] of [
|
||||
[0, "msg_1"],
|
||||
[1, "msg_2"],
|
||||
] as const) {
|
||||
emitEvent(events, {
|
||||
id: `evt_seed_${id}`,
|
||||
created: seq + 1,
|
||||
type: "session.inbox.enqueued",
|
||||
durable: durable(sessionID, seq),
|
||||
data: { sessionID, inboxID: id, item: { type: "user", payload: { text: id }, delivery: "steer" } },
|
||||
})
|
||||
}
|
||||
await wait(() => data.session.message.list(sessionID).length === 2)
|
||||
|
||||
data.session.optimistic.prompt({
|
||||
sessionID,
|
||||
messageID: "msg_9",
|
||||
delivery: "steer",
|
||||
text: "After the revert boundary",
|
||||
})
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_revert",
|
||||
created: 4,
|
||||
type: "session.revert.committed",
|
||||
durable: durable(sessionID, 2),
|
||||
data: { sessionID, to: "msg_2" },
|
||||
})
|
||||
await wait(() => data.session.message.get(sessionID, "msg_2") === undefined)
|
||||
expect(data.session.message.get(sessionID, "msg_1")).toBeDefined()
|
||||
expect(data.session.message.get(sessionID, "msg_9")).toBeDefined()
|
||||
expect(data.session.input.has(sessionID, "msg_9")).toBe(true)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user