mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-16 01:19:19 -04:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6cb2f00d15 | |||
| 73700065bf | |||
| 07a82a442e | |||
| 55be895e14 | |||
| 58a36a1560 | |||
| 3d782efee4 | |||
| c054bb183e | |||
| 0d7904c91f | |||
| 75ec0b454c | |||
| f4baba2824 | |||
| 79fc74afbf | |||
| 30dfe5352c | |||
| f725443e30 | |||
| cae205a3d9 | |||
| d35c6f04ed | |||
| 4d021b4660 | |||
| f63f912178 | |||
| 6564d1442a | |||
| a5f3e9e735 | |||
| d24a24a2ca | |||
| 8610d90838 | |||
| 57b050e9fc | |||
| 51091be7e4 | |||
| c8584ec0c8 | |||
| 7301c5e798 | |||
| 41f70bfbb1 |
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@opencode-ai/core": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Simplify interrupt continuation: the steer-scoped resume decision now lives in SessionExecution as a post-cleanup inbox check, and the run coordinator drops its continuation state machine. Wakes arriving during cancellation cleanup now restart a normal full drain, and interrupting an idle session with continue now resumes pending steering input.
|
||||||
@@ -86,7 +86,7 @@ async function writeProtocolStream(session: CDPSession, handle: string, file: st
|
|||||||
try {
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
const chunk = await session.send("IO.read", { handle })
|
const chunk = await session.send("IO.read", { handle })
|
||||||
await output.write(chunk.base64Encoded ? Buffer.from(chunk.data, "base64") : chunk.data)
|
await (chunk.base64Encoded ? output.write(Buffer.from(chunk.data, "base64")) : output.write(chunk.data))
|
||||||
if (chunk.eof) break
|
if (chunk.eof) break
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -125,17 +125,20 @@ export async function installTimelineStreamProbe(
|
|||||||
const scrollTo = Element.prototype.scrollTo
|
const scrollTo = Element.prototype.scrollTo
|
||||||
const scrollTop = Object.getOwnPropertyDescriptor(Element.prototype, "scrollTop")!
|
const scrollTop = Object.getOwnPropertyDescriptor(Element.prototype, "scrollTop")!
|
||||||
if (profileVisual) {
|
if (profileVisual) {
|
||||||
Element.prototype.scrollTo = function (...args) {
|
function measuredScrollTo(this: Element, options?: ScrollToOptions): void
|
||||||
|
function measuredScrollTo(this: Element, x: number, y: number): void
|
||||||
|
function measuredScrollTo(this: Element, first?: number | ScrollToOptions, second?: number) {
|
||||||
state.scroll.calls += 1
|
state.scroll.calls += 1
|
||||||
const top = typeof args[0] === "object" ? args[0]?.top : args[1]
|
const top = typeof first === "object" ? first?.top : second
|
||||||
if (typeof top === "number") {
|
if (typeof top === "number") {
|
||||||
const target = Math.min(top, this.scrollHeight - this.clientHeight)
|
const target = Math.min(top, this.scrollHeight - this.clientHeight)
|
||||||
if (Math.abs(this.scrollTop - target) < 1) state.scroll.callNoops += 1
|
if (Math.abs(this.scrollTop - target) < 1) state.scroll.callNoops += 1
|
||||||
}
|
}
|
||||||
if (state.scroll.lastCallFrame === state.scroll.frame) state.scroll.sameFrameCalls += 1
|
if (state.scroll.lastCallFrame === state.scroll.frame) state.scroll.sameFrameCalls += 1
|
||||||
state.scroll.lastCallFrame = state.scroll.frame
|
state.scroll.lastCallFrame = state.scroll.frame
|
||||||
return scrollTo.apply(this, args)
|
Reflect.apply(scrollTo, this, typeof first === "number" ? [first, second] : [first])
|
||||||
}
|
}
|
||||||
|
Element.prototype.scrollTo = measuredScrollTo
|
||||||
Object.defineProperty(Element.prototype, "scrollTop", {
|
Object.defineProperty(Element.prototype, "scrollTop", {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
get: scrollTop.get,
|
get: scrollTop.get,
|
||||||
|
|||||||
@@ -267,18 +267,19 @@ const childMessages = Array.from({ length: 4 }, (_, index) => [
|
|||||||
userMessage(childID, index + 2000, 120),
|
userMessage(childID, index + 2000, 120),
|
||||||
assistantMessage(childID, index + 2000, id("msg_user", index + 2000), [textPart(index + 2000, 0, 240)]),
|
assistantMessage(childID, index + 2000, id("msg_user", index + 2000), [textPart(index + 2000, 0, 240)]),
|
||||||
]).flat()
|
]).flat()
|
||||||
|
const messages: Record<string, Message[]> = {
|
||||||
|
[sourceID]: sourceMessages,
|
||||||
|
[targetID]: targetMessages,
|
||||||
|
[childID]: childMessages,
|
||||||
|
}
|
||||||
|
|
||||||
function renderable(part: MessagePart) {
|
function renderable(part: MessagePart) {
|
||||||
if (part.type === "tool" && part.tool === "todowrite") return false
|
if (part.type === "tool" && part.tool === "todowrite") return false
|
||||||
if (part.type === "text") return !!part.text.trim()
|
if (part.type === "text") return !!part.text?.trim()
|
||||||
if (part.type === "reasoning") return !!part.text.trim()
|
if (part.type === "reasoning") return !!part.text?.trim()
|
||||||
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
|
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
|
||||||
}
|
}
|
||||||
|
|
||||||
function orderedParts(message: Message) {
|
|
||||||
return message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
export const fixture = {
|
export const fixture = {
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
@@ -333,7 +334,7 @@ export const fixture = {
|
|||||||
sourceID,
|
sourceID,
|
||||||
targetID,
|
targetID,
|
||||||
childID,
|
childID,
|
||||||
messages: { [sourceID]: sourceMessages, [targetID]: targetMessages, [childID]: childMessages },
|
messages,
|
||||||
expected: {
|
expected: {
|
||||||
sourceTitle: "Uncommitted changes inquiry",
|
sourceTitle: "Uncommitted changes inquiry",
|
||||||
targetTitle: "Example Game: sample jump movement & sample physics analysis",
|
targetTitle: "Example Game: sample jump movement & sample physics analysis",
|
||||||
@@ -345,16 +346,12 @@ export const fixture = {
|
|||||||
.filter((message) => message.info.role === "user")
|
.filter((message) => message.info.role === "user")
|
||||||
.map((message) => message.info.id),
|
.map((message) => message.info.id),
|
||||||
childMessageIDs: childMessages.filter((message) => message.info.role === "user").map((message) => message.info.id),
|
childMessageIDs: childMessages.filter((message) => message.info.role === "user").map((message) => message.info.id),
|
||||||
targetPartIDs: targetMessages.flatMap((message) =>
|
targetPartIDs: targetMessages.flatMap((message) => message.parts.filter(renderable).map((part) => part.id)),
|
||||||
orderedParts(message)
|
|
||||||
.filter(renderable)
|
|
||||||
.map((part) => part.id),
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pageMessages(sessionID: string, limit: number, before?: string) {
|
export function pageMessages(sessionID: string, limit: number, before?: string) {
|
||||||
const messages = fixture.messages[sessionID as keyof typeof fixture.messages] ?? []
|
const messages = fixture.messages[sessionID] ?? []
|
||||||
const end = before
|
const end = before
|
||||||
? Math.max(
|
? Math.max(
|
||||||
0,
|
0,
|
||||||
@@ -364,6 +361,6 @@ export function pageMessages(sessionID: string, limit: number, before?: string)
|
|||||||
const start = Math.max(0, end - limit)
|
const start = Math.max(0, end - limit)
|
||||||
return {
|
return {
|
||||||
items: messages.slice(start, end),
|
items: messages.slice(start, end),
|
||||||
cursor: start > 0 ? messages[start]!.info.id : undefined,
|
cursor: start > 0 ? messages[start].info.id : undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,7 +220,8 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
|
|||||||
}
|
}
|
||||||
if (url.pathname === "/api/project/current")
|
if (url.pathname === "/api/project/current")
|
||||||
return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory })
|
return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory })
|
||||||
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
|
if (url.pathname === "/api/session")
|
||||||
|
return json(route, { data: sessions.map((session) => currentSession(session)), cursor: {} })
|
||||||
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
||||||
const currentSessionInfo = sessions.find((session) => url.pathname === `/api/session/${session.id}`)
|
const currentSessionInfo = sessions.find((session) => url.pathname === `/api/session/${session.id}`)
|
||||||
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
|
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
|
||||||
|
|||||||
@@ -16,9 +16,8 @@ import { mockOpenCodeServer } from "../utils/mock-server"
|
|||||||
import { installSseTransport } from "../utils/sse-transport"
|
import { installSseTransport } from "../utils/sse-transport"
|
||||||
import { expectSessionTitle } from "../utils/waits"
|
import { expectSessionTitle } from "../utils/waits"
|
||||||
|
|
||||||
const initialPageSize = 20
|
const messagePageSize = 200
|
||||||
const historyPageSize = 200
|
const messages = Array.from({ length: messagePageSize / 2 + 1 }, (_, index) => {
|
||||||
const messages = Array.from({ length: initialPageSize + 1 }, (_, index) => {
|
|
||||||
const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user`
|
const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user`
|
||||||
return [
|
return [
|
||||||
userMessage(undefined, { id, created: 1700000000000 + index * 2_000 }),
|
userMessage(undefined, { id, created: 1700000000000 + index * 2_000 }),
|
||||||
@@ -26,7 +25,7 @@ const messages = Array.from({ length: initialPageSize + 1 }, (_, index) => {
|
|||||||
id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
|
id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
|
||||||
parentID: id,
|
parentID: id,
|
||||||
created: 1700000001000 + index * 2_000,
|
created: 1700000001000 + index * 2_000,
|
||||||
completed: index < initialPageSize,
|
completed: index < messagePageSize / 2,
|
||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
}).flat()
|
}).flat()
|
||||||
@@ -160,21 +159,18 @@ for (const scenario of scenarios) {
|
|||||||
await expect(page.locator(`[data-timeline-part-id="${userPartID}"]`)).toBeVisible()
|
await expect(page.locator(`[data-timeline-part-id="${userPartID}"]`)).toBeVisible()
|
||||||
const viewport = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
const viewport = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||||
await viewport.hover()
|
await viewport.hover()
|
||||||
const deadline = Date.now() + 10_000
|
const deadline = Date.now() + 30_000
|
||||||
while (requests.filter((request) => request.phase === "start").length < 2) {
|
while (requests.filter((request) => request.phase === "start").length < 2) {
|
||||||
if (Date.now() >= deadline) throw new Error("Timed out scrolling to the history boundary")
|
if (Date.now() >= deadline) throw new Error("Timed out scrolling to the history boundary")
|
||||||
await page.mouse.wheel(0, -240)
|
await page.mouse.wheel(0, -1_200)
|
||||||
await page.waitForTimeout(20)
|
await page.waitForTimeout(20)
|
||||||
}
|
}
|
||||||
expect(requests.filter((request) => request.phase === "end")).toHaveLength(1)
|
expect(requests.filter((request) => request.phase === "end")).toHaveLength(1)
|
||||||
expect(sequence.slice(0, 3)).toEqual([
|
expect(sequence.slice(0, 3)).toEqual([
|
||||||
"messages:start:latest",
|
"messages:start:latest",
|
||||||
"messages:end:latest",
|
"messages:end:latest",
|
||||||
`messages:start:${messages.at(-initialPageSize)!.info.id}`,
|
`messages:start:${messages.at(-messagePageSize)!.info.id}`,
|
||||||
])
|
])
|
||||||
await expect(page.locator('[data-timeline-part-id*="_history_root_assistant:text:0"]')).toHaveCount(
|
|
||||||
initialPageSize / 2,
|
|
||||||
)
|
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
;(
|
;(
|
||||||
window as Window & {
|
window as Window & {
|
||||||
@@ -186,15 +182,12 @@ for (const scenario of scenarios) {
|
|||||||
expect(await visibleContentHidden(page)).toBe(false)
|
expect(await visibleContentHidden(page)).toBe(false)
|
||||||
const beforeHistory = await probeSamples(page)
|
const beforeHistory = await probeSamples(page)
|
||||||
history.resolve()
|
history.resolve()
|
||||||
await expect
|
|
||||||
.poll(() => page.locator('[data-timeline-part-id*="_history_root_assistant:text:0"]').count())
|
|
||||||
.toBeGreaterThan(initialPageSize / 2)
|
|
||||||
await expect.poll(() => requests.filter((request) => request.phase === "end").length).toBe(2)
|
await expect.poll(() => requests.filter((request) => request.phase === "end").length).toBe(2)
|
||||||
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
||||||
await waitForProbeSamples(page, beforeHistory)
|
await waitForProbeSamples(page, beforeHistory)
|
||||||
expect(pages).toEqual([
|
expect(pages).toEqual([
|
||||||
{ before: undefined, limit: initialPageSize },
|
{ before: undefined, limit: messagePageSize },
|
||||||
{ before: messages.at(-initialPageSize)!.info.id, limit: historyPageSize },
|
{ before: messages.at(-messagePageSize)!.info.id, limit: messagePageSize },
|
||||||
])
|
])
|
||||||
expect(roots).toEqual([])
|
expect(roots).toEqual([])
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ test("moves busy through retry and recovery to final idle content", async ({ pag
|
|||||||
file: "src/retry.ts",
|
file: "src/retry.ts",
|
||||||
additions: 1,
|
additions: 1,
|
||||||
deletions: 1,
|
deletions: 1,
|
||||||
|
status: "modified",
|
||||||
patch: "@@ -1 +1 @@\n-export const retry = false\n+export const retry = true",
|
patch: "@@ -1 +1 @@\n-export const retry = false\n+export const retry = true",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,28 +1,27 @@
|
|||||||
import { expect, test } from "@playwright/test"
|
import { expect, test } from "@playwright/test"
|
||||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||||
import { session, sessionID, setupTimeline } from "../performance/timeline-stability/fixture"
|
import { session, sessionID, setupTimeline } from "../performance/timeline-stability/fixture"
|
||||||
|
|
||||||
const user = { id: "msg_user", type: "user", text: "Run it", time: { created: 1 } } satisfies SessionMessageInfo
|
const user = { id: "msg_user", type: "user", text: "Run it", time: { created: 1 } } satisfies SessionMessageInfo
|
||||||
|
|
||||||
const assistant = (completed: boolean, tool = false, childID?: string) =>
|
const assistant = (completed: boolean, tool = false, childID?: string): SessionMessageAssistant => ({
|
||||||
({
|
id: "msg_assistant",
|
||||||
id: "msg_assistant",
|
type: "assistant",
|
||||||
type: "assistant",
|
agent: "build",
|
||||||
agent: "build",
|
model: { id: "model", providerID: "provider" },
|
||||||
model: { id: "model", providerID: "provider" },
|
content: tool
|
||||||
content: tool
|
? [
|
||||||
? [
|
{
|
||||||
{
|
type: "tool",
|
||||||
type: "tool",
|
id: "call_subagent",
|
||||||
id: "call_subagent",
|
name: "subagent",
|
||||||
name: "subagent",
|
state: { status: "running", input: {}, metadata: childID ? { sessionID: childID } : {} },
|
||||||
state: { status: "running", input: {}, metadata: childID ? { sessionID: childID } : {} },
|
time: { created: 2 },
|
||||||
time: { created: 2 },
|
},
|
||||||
},
|
]
|
||||||
]
|
: [{ type: "text", text: "Working" }],
|
||||||
: [{ type: "text", text: "Working" }],
|
time: { created: 2, ...(completed ? { completed: 3 } : {}) },
|
||||||
time: { created: 2, ...(completed ? { completed: 3 } : {}) },
|
})
|
||||||
}) satisfies SessionMessageInfo
|
|
||||||
|
|
||||||
test("renders current protocol notices in CLI order", async ({ page }) => {
|
test("renders current protocol notices in CLI order", async ({ page }) => {
|
||||||
const ownerWarnings: string[] = []
|
const ownerWarnings: string[] = []
|
||||||
|
|||||||
@@ -280,6 +280,7 @@ function summaryDiff(index: number) {
|
|||||||
file: `src/diff-${index}.ts`,
|
file: `src/diff-${index}.ts`,
|
||||||
additions: 1,
|
additions: 1,
|
||||||
deletions: 1,
|
deletions: 1,
|
||||||
|
status: "modified" as const,
|
||||||
patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`,
|
patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,11 +23,10 @@ test("groups singleton and separated context operations at correct boundaries",
|
|||||||
]
|
]
|
||||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
||||||
|
|
||||||
await expect(
|
await expect(page.locator('[data-timeline-part-ids="prt_boundary_01_read"]')).toBeVisible()
|
||||||
page.locator('[data-timeline-part-ids="prt_boundary_01_read,prt_boundary_03_glob,prt_boundary_04_grep"]'),
|
await expect(page.locator('[data-timeline-part-ids="prt_boundary_03_glob,prt_boundary_04_grep"]')).toBeVisible()
|
||||||
).toBeVisible()
|
|
||||||
await expect(page.locator('[data-timeline-part-ids="prt_boundary_06_list"]')).toBeVisible()
|
await expect(page.locator('[data-timeline-part-ids="prt_boundary_06_list"]')).toBeVisible()
|
||||||
await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(4)
|
await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(5)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => {
|
test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => {
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ test("allows paint rounding for every framed row but not fixed turn gaps", async
|
|||||||
file: "src/summary.ts",
|
file: "src/summary.ts",
|
||||||
additions: 1,
|
additions: 1,
|
||||||
deletions: 1,
|
deletions: 1,
|
||||||
|
status: "modified",
|
||||||
patch: "@@ -1 +1 @@\n-export const value = 1\n+export const value = 2",
|
patch: "@@ -1 +1 @@\n-export const value = 1\n+export const value = 2",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -83,6 +83,40 @@ test("labels all web search provider variants", async ({ page }) => {
|
|||||||
await expect(page.getByRole("button", { name: /^Web Search/ })).toBeVisible()
|
await expect(page.getByRole("button", { name: /^Web Search/ })).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("labels V2 read tools from their path input", async ({ page }) => {
|
||||||
|
const id = "prt_read_path"
|
||||||
|
await setupTimeline(page, {
|
||||||
|
messages: [userMessage(), assistantMessage([toolPart(id, "read", "completed", { path: "src/a.ts" })])],
|
||||||
|
})
|
||||||
|
|
||||||
|
const group = page.locator(`[data-timeline-part-ids="${id}"]`)
|
||||||
|
await group.locator('[data-slot="collapsible-trigger"]').click()
|
||||||
|
await expect(group.locator('[data-slot="basic-tool-tool-subtitle"]')).toHaveText("a.ts")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("labels V2 skill tools from IDs and result metadata", async ({ page }) => {
|
||||||
|
const pending = "prt_skill_id"
|
||||||
|
const completed = "prt_skill_name"
|
||||||
|
await setupTimeline(page, {
|
||||||
|
messages: [
|
||||||
|
userMessage(),
|
||||||
|
assistantMessage([
|
||||||
|
toolPart(pending, "skill", "running", { id: "sample-skill" }),
|
||||||
|
toolPart(completed, "skill", "completed", { id: "opencode" }, { metadata: { name: "OpenCode" } }),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(page.locator(`[data-timeline-part-id="${pending}"] [data-component="text-shimmer"]`)).toHaveAttribute(
|
||||||
|
"aria-label",
|
||||||
|
"sample-skill",
|
||||||
|
)
|
||||||
|
await expect(page.locator(`[data-timeline-part-id="${completed}"] [data-component="text-shimmer"]`)).toHaveAttribute(
|
||||||
|
"aria-label",
|
||||||
|
"OpenCode",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
function questionInput() {
|
function questionInput() {
|
||||||
return { questions: [{ header: "Stability", question: "Keep it stable?", options: [] }] }
|
return { questions: [{ header: "Stability", question: "Keep it stable?", options: [] }] }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ type EventPayload = {
|
|||||||
payload: Record<string, unknown>
|
payload: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
test.use({ viewport: { width: 1440, height: 900 }, reducedMotion: "no-preference" })
|
test.use({ viewport: { width: 1440, height: 900 } })
|
||||||
|
|
||||||
test("animates todo opening without replaying it across session tabs", async ({ page }) => {
|
test("animates todo opening without replaying it across session tabs", async ({ page }) => {
|
||||||
test.setTimeout(90_000)
|
test.setTimeout(90_000)
|
||||||
@@ -57,7 +57,6 @@ test("animates todo opening without replaying it across session tabs", async ({
|
|||||||
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
||||||
},
|
},
|
||||||
sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)],
|
sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)],
|
||||||
sessionStatus: { [sourceID]: { type: "busy" } },
|
|
||||||
pageMessages: () => ({ items: [] }),
|
pageMessages: () => ({ items: [] }),
|
||||||
events: () => events.splice(0, 1),
|
events: () => events.splice(0, 1),
|
||||||
eventRetry: 16,
|
eventRetry: 16,
|
||||||
|
|||||||
@@ -90,7 +90,8 @@ async function mockServer(page: Page) {
|
|||||||
if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname))
|
if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname))
|
||||||
return new Promise(() => {})
|
return new Promise(() => {})
|
||||||
if (url.pathname === "/api/event") return sse(route)
|
if (url.pathname === "/api/event") return sse(route)
|
||||||
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
|
if (url.pathname === "/api/session")
|
||||||
|
return json(route, { data: sessions.map((session) => currentSession(session)), cursor: {} })
|
||||||
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
||||||
const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`)
|
const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`)
|
||||||
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
|
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
|
||||||
|
|||||||
@@ -222,30 +222,29 @@ function turn(index: number): Message[] {
|
|||||||
return [user, assistantMessage(targetID, index, user.info.id, parts)]
|
return [user, assistantMessage(targetID, index, user.info.id, parts)]
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat()
|
const targetMessages = Array.from({ length: 101 }, (_, index) => turn(index)).flat()
|
||||||
const sourceMessages = Array.from({ length: 12 }, (_, index) => [
|
const sourceMessages = Array.from({ length: 12 }, (_, index) => [
|
||||||
userMessage(sourceID, index + 1000, 120),
|
userMessage(sourceID, index + 1000, 120),
|
||||||
assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [textPart(index + 1000, 0, 240)]),
|
assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [textPart(index + 1000, 0, 240)]),
|
||||||
]).flat()
|
]).flat()
|
||||||
|
const messages: Record<string, Message[]> = { [sourceID]: sourceMessages, [targetID]: targetMessages }
|
||||||
|
|
||||||
function renderable(part: MessagePart) {
|
function renderable(part: MessagePart) {
|
||||||
if (part.type === "tool" && part.tool === "todowrite") return false
|
if (part.type === "tool" && part.tool === "todowrite") return false
|
||||||
if (part.type === "text") return !!part.text.trim()
|
if (part.type === "text") return !!part.text?.trim()
|
||||||
if (part.type === "reasoning") return !!part.text.trim()
|
if (part.type === "reasoning") return !!part.text?.trim()
|
||||||
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
|
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentPartIDs(message: Message) {
|
function currentPartIDs(message: Message) {
|
||||||
const ordinals = { text: 0, reasoning: 0 }
|
const ordinals = { text: 0, reasoning: 0 }
|
||||||
return message.parts
|
return message.parts.flatMap((part) => {
|
||||||
.flatMap((part) => {
|
if (!renderable(part)) return []
|
||||||
if (!renderable(part)) return []
|
if (part.type === "text") return [`${message.info.id}:text:${ordinals.text++}`]
|
||||||
if (part.type === "text") return [`${message.info.id}:text:${ordinals.text++}`]
|
if (part.type === "reasoning") return [`${message.info.id}:reasoning:${ordinals.reasoning++}`]
|
||||||
if (part.type === "reasoning") return [`${message.info.id}:reasoning:${ordinals.reasoning++}`]
|
if (part.type === "tool") return [typeof part.callID === "string" ? part.callID : part.id]
|
||||||
if (part.type === "tool") return [typeof part.callID === "string" ? part.callID : part.id]
|
return []
|
||||||
return []
|
})
|
||||||
})
|
|
||||||
.sort()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fixture = {
|
export const fixture = {
|
||||||
@@ -292,7 +291,7 @@ export const fixture = {
|
|||||||
],
|
],
|
||||||
sourceID,
|
sourceID,
|
||||||
targetID,
|
targetID,
|
||||||
messages: { [sourceID]: sourceMessages, [targetID]: targetMessages },
|
messages,
|
||||||
expected: {
|
expected: {
|
||||||
sourceTitle: "Uncommitted changes inquiry",
|
sourceTitle: "Uncommitted changes inquiry",
|
||||||
targetTitle: "Example Game: sample jump movement & sample physics analysis",
|
targetTitle: "Example Game: sample jump movement & sample physics analysis",
|
||||||
@@ -306,7 +305,7 @@ export const fixture = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function pageMessages(sessionID: string, limit: number, before?: string) {
|
export function pageMessages(sessionID: string, limit: number, before?: string) {
|
||||||
const messages = fixture.messages[sessionID as keyof typeof fixture.messages] ?? []
|
const messages = fixture.messages[sessionID] ?? []
|
||||||
const end = before
|
const end = before
|
||||||
? Math.max(
|
? Math.max(
|
||||||
0,
|
0,
|
||||||
@@ -316,6 +315,6 @@ export function pageMessages(sessionID: string, limit: number, before?: string)
|
|||||||
const start = Math.max(0, end - limit)
|
const start = Math.max(0, end - limit)
|
||||||
return {
|
return {
|
||||||
items: messages.slice(start, end),
|
items: messages.slice(start, end),
|
||||||
cursor: start > 0 ? messages[start]!.info.id : undefined,
|
cursor: start > 0 ? messages[start].info.id : undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ test.describe("smoke: session timeline", () => {
|
|||||||
provider: fixture.provider,
|
provider: fixture.provider,
|
||||||
directory: fixture.directory,
|
directory: fixture.directory,
|
||||||
project: fixture.project,
|
project: fixture.project,
|
||||||
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }),
|
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID] ?? [] }),
|
||||||
})
|
})
|
||||||
await configureSmokePage(page, fixture.directory)
|
await configureSmokePage(page, fixture.directory)
|
||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
@@ -188,7 +188,11 @@ test.describe("smoke: session timeline", () => {
|
|||||||
const bottom = root
|
const bottom = root
|
||||||
.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
|
.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
|
||||||
?.getBoundingClientRect()
|
?.getBoundingClientRect()
|
||||||
samples.push({ ids: visible, last: visible.includes(last), bottomError: bottom?.bottom - view.bottom })
|
samples.push({
|
||||||
|
ids: visible,
|
||||||
|
last: visible.includes(last),
|
||||||
|
bottomError: bottom ? bottom.bottom - view.bottom : undefined,
|
||||||
|
})
|
||||||
if (
|
if (
|
||||||
!firstPaint &&
|
!firstPaint &&
|
||||||
visible.includes(last) &&
|
visible.includes(last) &&
|
||||||
@@ -263,7 +267,7 @@ test.describe("smoke: session timeline", () => {
|
|||||||
provider: fixture.provider,
|
provider: fixture.provider,
|
||||||
directory: fixture.directory,
|
directory: fixture.directory,
|
||||||
project: fixture.project,
|
project: fixture.project,
|
||||||
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }),
|
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID] ?? [] }),
|
||||||
})
|
})
|
||||||
await configureSmokePage(page, fixture.directory)
|
await configureSmokePage(page, fixture.directory)
|
||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
@@ -723,7 +727,7 @@ function expectCompleteScroll(
|
|||||||
).toEqual([])
|
).toEqual([])
|
||||||
expect(new Set(expectedPartIDs).size).toBe(expectedPartIDs.length)
|
expect(new Set(expectedPartIDs).size).toBe(expectedPartIDs.length)
|
||||||
expect(new Set(expectedMessageIDs).size).toBe(expectedMessageIDs.length)
|
expect(new Set(expectedMessageIDs).size).toBe(expectedMessageIDs.length)
|
||||||
expect(expectedPartIDs.length).toBe(331)
|
expect(expectedPartIDs.length).toBe(465)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectHomeProject(page: Page, projectName: string) {
|
async function selectHomeProject(page: Page, projectName: string) {
|
||||||
|
|||||||
@@ -1,21 +1,11 @@
|
|||||||
{
|
{
|
||||||
"extends": "../tsconfig.json",
|
"extends": "../tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"composite": false,
|
||||||
|
"emitDeclarationOnly": false,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"rootDir": "..",
|
"rootDir": "..",
|
||||||
"types": ["node", "bun"]
|
"types": ["node", "bun"]
|
||||||
},
|
},
|
||||||
"include": [
|
"include": ["./**/*.ts", "./**/*.tsx", "../src/types.ts"]
|
||||||
"./performance/timeline-stability/**/*.spec.ts",
|
|
||||||
"./performance/timeline-stability/fixture.test.ts",
|
|
||||||
"./performance/timeline-stability/fixture.ts",
|
|
||||||
"./performance/unit/visual-stability.test.ts",
|
|
||||||
"./reproduction/timeline-suspense/**/*.ts",
|
|
||||||
"./reproduction/timeline-suspense/**/*.tsx",
|
|
||||||
"../src/types.ts",
|
|
||||||
"../src/pages/session/timeline/observe-element-offset.ts",
|
|
||||||
"./regression/new-session-panel-corner.spec.ts",
|
|
||||||
"./regression/session-timeline-context-resize.spec.ts",
|
|
||||||
"./utils/**/*.ts"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,321 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import type { Prompt } from "@/context/prompt"
|
||||||
|
import { buildPromptRequest } from "./build-prompt-request"
|
||||||
|
|
||||||
|
describe("buildPromptRequest", () => {
|
||||||
|
test("builds text, files, and agents from the prompt", () => {
|
||||||
|
const prompt: Prompt = [
|
||||||
|
{ type: "text", content: "hello", start: 0, end: 5 },
|
||||||
|
{
|
||||||
|
type: "file",
|
||||||
|
path: "src/foo.ts",
|
||||||
|
content: "@src/foo.ts",
|
||||||
|
start: 5,
|
||||||
|
end: 16,
|
||||||
|
selection: { startLine: 4, startChar: 1, endLine: 6, endChar: 1 },
|
||||||
|
},
|
||||||
|
{ type: "agent", name: "planner", content: "@planner", start: 16, end: 24 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = buildPromptRequest({
|
||||||
|
prompt,
|
||||||
|
context: [{ key: "ctx:1", type: "file", path: "src/bar.ts", comment: "check this" }],
|
||||||
|
images: [
|
||||||
|
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
|
||||||
|
],
|
||||||
|
text: "hello @src/foo.ts @planner",
|
||||||
|
sessionDirectory: "/repo",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.text).toContain("hello @src/foo.ts @planner")
|
||||||
|
expect(result.text).toContain("check this")
|
||||||
|
expect(result.displayText).toBe("hello @src/foo.ts @planner")
|
||||||
|
expect(result.comments).toMatchObject([{ path: "src/bar.ts", comment: "check this" }])
|
||||||
|
expect(result.agents).toEqual([{ name: "planner", mention: { start: 16, end: 24, text: "@planner" } }])
|
||||||
|
expect(result.files.some((file) => file.uri.startsWith("file:///repo/src/foo.ts"))).toBe(true)
|
||||||
|
expect(result.files.find((file) => file.uri.startsWith("file:///repo/src/foo.ts"))?.mention).toEqual({
|
||||||
|
start: 5,
|
||||||
|
end: 16,
|
||||||
|
text: "@src/foo.ts",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps multiple uploaded attachments in order", () => {
|
||||||
|
const result = buildPromptRequest({
|
||||||
|
prompt: [{ type: "text", content: "check these", start: 0, end: 11 }],
|
||||||
|
context: [],
|
||||||
|
images: [
|
||||||
|
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
|
||||||
|
{
|
||||||
|
type: "image",
|
||||||
|
id: "img_2",
|
||||||
|
filename: "b.pdf",
|
||||||
|
mime: "application/pdf",
|
||||||
|
dataUrl: "data:application/pdf;base64,BBB",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
text: "check these",
|
||||||
|
sessionDirectory: "/repo",
|
||||||
|
})
|
||||||
|
|
||||||
|
const uploads = result.files.filter((file) => file.uri.startsWith("data:"))
|
||||||
|
|
||||||
|
expect(uploads).toHaveLength(2)
|
||||||
|
expect(uploads.map((file) => file.name)).toEqual(["a.png", "b.pdf"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves an external attachment source path for the model", () => {
|
||||||
|
const result = buildPromptRequest({
|
||||||
|
prompt: [],
|
||||||
|
context: [],
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
type: "image",
|
||||||
|
id: "img_external",
|
||||||
|
filename: "opencode.global.dat",
|
||||||
|
sourcePath: "C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
|
||||||
|
mime: "text/plain",
|
||||||
|
dataUrl: "data:text/plain;base64,AAA",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
text: "inspect this",
|
||||||
|
sessionDirectory: "C:\\Repos\\sst\\opencode",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.files[0]?.name).toBe(
|
||||||
|
"C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves reference aliases as directory files", () => {
|
||||||
|
const result = buildPromptRequest({
|
||||||
|
prompt: [
|
||||||
|
{
|
||||||
|
type: "file",
|
||||||
|
path: "/repo/../docs",
|
||||||
|
content: "@docs",
|
||||||
|
start: 0,
|
||||||
|
end: 5,
|
||||||
|
mime: "application/x-directory",
|
||||||
|
filename: "docs",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
context: [],
|
||||||
|
images: [],
|
||||||
|
text: "@docs",
|
||||||
|
sessionDirectory: "/repo/app",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.files[0]).toEqual({
|
||||||
|
uri: "file:///repo/../docs",
|
||||||
|
mime: "application/x-directory",
|
||||||
|
name: "docs",
|
||||||
|
mention: { start: 0, end: 5, text: "@docs" },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("deduplicates context files when prompt already includes same path", () => {
|
||||||
|
const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
|
||||||
|
|
||||||
|
const result = buildPromptRequest({
|
||||||
|
prompt,
|
||||||
|
context: [
|
||||||
|
{ key: "ctx:dup", type: "file", path: "src/foo.ts" },
|
||||||
|
{ key: "ctx:comment", type: "file", path: "src/foo.ts", comment: "focus here" },
|
||||||
|
],
|
||||||
|
images: [],
|
||||||
|
text: "@src/foo.ts",
|
||||||
|
sessionDirectory: "/repo",
|
||||||
|
})
|
||||||
|
|
||||||
|
const fooFiles = result.files.filter((file) => file.uri.startsWith("file:///repo/src/foo.ts"))
|
||||||
|
|
||||||
|
expect(fooFiles).toHaveLength(2)
|
||||||
|
expect(result.text).toContain("focus here")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("adds files for @mentions inside comment text", () => {
|
||||||
|
const result = buildPromptRequest({
|
||||||
|
prompt: [{ type: "text", content: "look", start: 0, end: 4 }],
|
||||||
|
context: [
|
||||||
|
{
|
||||||
|
key: "ctx:comment-mention",
|
||||||
|
type: "file",
|
||||||
|
path: "src/review.ts",
|
||||||
|
comment: "Compare with @src/shared.ts and @src/review.ts.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
images: [],
|
||||||
|
text: "look",
|
||||||
|
sessionDirectory: "/repo",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.files).toHaveLength(2)
|
||||||
|
expect(result.files.some((file) => file.uri === "file:///repo/src/review.ts")).toBe(true)
|
||||||
|
expect(result.files.some((file) => file.uri === "file:///repo/src/shared.ts")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("handles Windows paths correctly (simulated on macOS)", () => {
|
||||||
|
const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }]
|
||||||
|
|
||||||
|
const result = buildPromptRequest({
|
||||||
|
prompt,
|
||||||
|
context: [],
|
||||||
|
images: [],
|
||||||
|
text: "@src\\foo.ts",
|
||||||
|
sessionDirectory: "D:\\projects\\myapp", // Windows path
|
||||||
|
})
|
||||||
|
|
||||||
|
const file = result.files[0]
|
||||||
|
expect(file).toBeDefined()
|
||||||
|
// URL should be parseable
|
||||||
|
expect(() => new URL(file!.uri)).not.toThrow()
|
||||||
|
// Should not have encoded backslashes in wrong place
|
||||||
|
expect(file!.uri).not.toContain("%5C")
|
||||||
|
// Should have normalized to forward slashes
|
||||||
|
expect(file!.uri).toContain("/src/foo.ts")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("handles Windows absolute path with special characters", () => {
|
||||||
|
const prompt: Prompt = [{ type: "file", path: "file#name.txt", content: "@file#name.txt", start: 0, end: 14 }]
|
||||||
|
|
||||||
|
const result = buildPromptRequest({
|
||||||
|
prompt,
|
||||||
|
context: [],
|
||||||
|
images: [],
|
||||||
|
text: "@file#name.txt",
|
||||||
|
sessionDirectory: "C:\\Users\\test\\Documents", // Windows path
|
||||||
|
})
|
||||||
|
|
||||||
|
const file = result.files[0]
|
||||||
|
expect(file).toBeDefined()
|
||||||
|
// URL should be parseable
|
||||||
|
expect(() => new URL(file!.uri)).not.toThrow()
|
||||||
|
// Special chars should be encoded
|
||||||
|
expect(file!.uri).toContain("file%23name.txt")
|
||||||
|
// Should have Windows drive letter properly encoded
|
||||||
|
expect(file!.uri).toMatch(/file:\/\/\/[A-Z]:/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("handles Linux absolute paths correctly", () => {
|
||||||
|
const prompt: Prompt = [{ type: "file", path: "src/app.ts", content: "@src/app.ts", start: 0, end: 10 }]
|
||||||
|
|
||||||
|
const result = buildPromptRequest({
|
||||||
|
prompt,
|
||||||
|
context: [],
|
||||||
|
images: [],
|
||||||
|
text: "@src/app.ts",
|
||||||
|
sessionDirectory: "/home/user/project",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.files[0]?.uri).toBe("file:///home/user/project/src/app.ts")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("handles macOS paths correctly", () => {
|
||||||
|
const prompt: Prompt = [{ type: "file", path: "README.md", content: "@README.md", start: 0, end: 9 }]
|
||||||
|
|
||||||
|
const result = buildPromptRequest({
|
||||||
|
prompt,
|
||||||
|
context: [],
|
||||||
|
images: [],
|
||||||
|
text: "@README.md",
|
||||||
|
sessionDirectory: "/Users/kelvin/Projects/opencode",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.files[0]?.uri).toBe("file:///Users/kelvin/Projects/opencode/README.md")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("handles context files with Windows paths", () => {
|
||||||
|
const result = buildPromptRequest({
|
||||||
|
prompt: [],
|
||||||
|
context: [
|
||||||
|
{ key: "ctx:1", type: "file", path: "src\\utils\\helper.ts" },
|
||||||
|
{ key: "ctx:2", type: "file", path: "test\\unit.test.ts", comment: "check tests" },
|
||||||
|
],
|
||||||
|
images: [],
|
||||||
|
text: "test",
|
||||||
|
sessionDirectory: "D:\\workspace\\app",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.files).toHaveLength(2)
|
||||||
|
|
||||||
|
// All file URLs should be valid
|
||||||
|
result.files.forEach((file) => {
|
||||||
|
expect(() => new URL(file.uri)).not.toThrow()
|
||||||
|
expect(file.uri).not.toContain("%5C") // No encoded backslashes
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("handles absolute Windows paths (user manually specifies full path)", () => {
|
||||||
|
const prompt: Prompt = [
|
||||||
|
{ type: "file", path: "D:\\other\\project\\file.ts", content: "@D:\\other\\project\\file.ts", start: 0, end: 25 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = buildPromptRequest({
|
||||||
|
prompt,
|
||||||
|
context: [],
|
||||||
|
images: [],
|
||||||
|
text: "@D:\\other\\project\\file.ts",
|
||||||
|
sessionDirectory: "C:\\current\\project",
|
||||||
|
})
|
||||||
|
|
||||||
|
const file = result.files[0]
|
||||||
|
expect(file).toBeDefined()
|
||||||
|
// Should handle absolute path that differs from sessionDirectory
|
||||||
|
expect(() => new URL(file!.uri)).not.toThrow()
|
||||||
|
expect(file!.uri).toContain("/D:/other/project/file.ts")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("handles selection with query parameters on Windows", () => {
|
||||||
|
const prompt: Prompt = [
|
||||||
|
{
|
||||||
|
type: "file",
|
||||||
|
path: "src\\App.tsx",
|
||||||
|
content: "@src\\App.tsx",
|
||||||
|
start: 0,
|
||||||
|
end: 11,
|
||||||
|
selection: { startLine: 10, startChar: 0, endLine: 20, endChar: 5 },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = buildPromptRequest({
|
||||||
|
prompt,
|
||||||
|
context: [],
|
||||||
|
images: [],
|
||||||
|
text: "@src\\App.tsx",
|
||||||
|
sessionDirectory: "C:\\project",
|
||||||
|
})
|
||||||
|
|
||||||
|
const file = result.files[0]
|
||||||
|
expect(file).toBeDefined()
|
||||||
|
// Should have query parameters
|
||||||
|
expect(file!.uri).toContain("?start=10&end=20")
|
||||||
|
// Should be valid URL
|
||||||
|
expect(() => new URL(file!.uri)).not.toThrow()
|
||||||
|
// Query params should parse correctly
|
||||||
|
const url = new URL(file!.uri)
|
||||||
|
expect(url.searchParams.get("start")).toBe("10")
|
||||||
|
expect(url.searchParams.get("end")).toBe("20")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("handles file paths with dots and special segments on Windows", () => {
|
||||||
|
const prompt: Prompt = [
|
||||||
|
{ type: "file", path: "..\\..\\shared\\util.ts", content: "@..\\..\\shared\\util.ts", start: 0, end: 21 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = buildPromptRequest({
|
||||||
|
prompt,
|
||||||
|
context: [],
|
||||||
|
images: [],
|
||||||
|
text: "@..\\..\\shared\\util.ts",
|
||||||
|
sessionDirectory: "C:\\projects\\myapp\\src",
|
||||||
|
})
|
||||||
|
|
||||||
|
const file = result.files[0]
|
||||||
|
expect(file).toBeDefined()
|
||||||
|
// Should be valid URL
|
||||||
|
expect(() => new URL(file!.uri)).not.toThrow()
|
||||||
|
// Should preserve .. segments (backend normalizes)
|
||||||
|
expect(file!.uri).toContain("/..")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
|
import type { FileSelection } from "@/context/file"
|
||||||
|
import { encodeFilePath } from "@/context/file/path"
|
||||||
|
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
|
||||||
|
import { formatCommentNote, type PromptComment } from "@/utils/comment-note"
|
||||||
|
|
||||||
|
// Network fields feed both boundaries; display fields keep desktop-only rendering details in the local echo.
|
||||||
|
type PromptRequest = {
|
||||||
|
text: string
|
||||||
|
displayText: string
|
||||||
|
files: { uri: string; mime: string; name?: string; mention?: { start: number; end: number; text: string } }[]
|
||||||
|
agents: { name: string; mention?: { start: number; end: number; text: string } }[]
|
||||||
|
comments: PromptComment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContextFile = {
|
||||||
|
key: string
|
||||||
|
type: "file"
|
||||||
|
path: string
|
||||||
|
selection?: FileSelection
|
||||||
|
comment?: string
|
||||||
|
commentID?: string
|
||||||
|
commentOrigin?: "review" | "file"
|
||||||
|
preview?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type BuildPromptRequestInput = {
|
||||||
|
prompt: Prompt
|
||||||
|
context: ContextFile[]
|
||||||
|
images: (Omit<ImageAttachmentPart, "blob"> & { dataUrl: string })[]
|
||||||
|
text: string
|
||||||
|
sessionDirectory: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const absolute = (directory: string, path: string) => {
|
||||||
|
if (path.startsWith("/")) return path
|
||||||
|
if (/^[A-Za-z]:[\\/]/.test(path) || /^[A-Za-z]:$/.test(path)) return path
|
||||||
|
if (path.startsWith("\\\\") || path.startsWith("//")) return path
|
||||||
|
return `${directory.replace(/[\\/]+$/, "")}/${path}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileQuery = (selection: FileSelection | undefined) =>
|
||||||
|
selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
|
||||||
|
|
||||||
|
const mention = /(^|[\s([{"'])@(\S+)/g
|
||||||
|
|
||||||
|
const parseCommentMentions = (comment: string) => {
|
||||||
|
return Array.from(comment.matchAll(mention)).flatMap((match) => {
|
||||||
|
const path = (match[2] ?? "").replace(/[.,!?;:)}\]"']+$/, "")
|
||||||
|
if (!path) return []
|
||||||
|
return [path]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
|
||||||
|
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
|
||||||
|
|
||||||
|
export function buildPromptRequest(input: BuildPromptRequestInput): PromptRequest {
|
||||||
|
const files = input.prompt.filter(isFileAttachment).map((attachment) => {
|
||||||
|
const path = absolute(input.sessionDirectory, attachment.path)
|
||||||
|
return {
|
||||||
|
uri: attachment.url ?? `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`,
|
||||||
|
mime: attachment.mime ?? "text/plain",
|
||||||
|
name: attachment.filename ?? getFilename(attachment.path),
|
||||||
|
mention: { start: attachment.start, end: attachment.end, text: attachment.content },
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const agents = input.prompt.filter(isAgentAttachment).map((attachment) => ({
|
||||||
|
name: attachment.name,
|
||||||
|
mention: { start: attachment.start, end: attachment.end, text: attachment.content },
|
||||||
|
}))
|
||||||
|
|
||||||
|
const used = new Set(files.map((file) => file.uri))
|
||||||
|
const comments: PromptComment[] = []
|
||||||
|
const context = input.context.flatMap((item) => {
|
||||||
|
const path = absolute(input.sessionDirectory, item.path)
|
||||||
|
const uri = `file://${encodeFilePath(path)}${fileQuery(item.selection)}`
|
||||||
|
const comment = item.comment?.trim()
|
||||||
|
if (!comment && used.has(uri)) return []
|
||||||
|
used.add(uri)
|
||||||
|
|
||||||
|
const file = { uri, mime: "text/plain", name: getFilename(item.path) }
|
||||||
|
if (!comment) return [file]
|
||||||
|
|
||||||
|
comments.push({
|
||||||
|
path: item.path,
|
||||||
|
selection: item.selection,
|
||||||
|
comment,
|
||||||
|
preview: item.preview,
|
||||||
|
origin: item.commentOrigin,
|
||||||
|
})
|
||||||
|
const mentions = parseCommentMentions(comment).flatMap((path) => {
|
||||||
|
const uri = `file://${encodeFilePath(absolute(input.sessionDirectory, path))}`
|
||||||
|
if (used.has(uri)) return []
|
||||||
|
used.add(uri)
|
||||||
|
return [{ uri, mime: "text/plain", name: getFilename(path) }]
|
||||||
|
})
|
||||||
|
return [file, ...mentions]
|
||||||
|
})
|
||||||
|
|
||||||
|
const images = input.images.map((attachment) => ({
|
||||||
|
uri: attachment.dataUrl,
|
||||||
|
mime: attachment.mime,
|
||||||
|
name: attachment.sourcePath ?? attachment.filename,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: [...(input.text.trim() ? [input.text] : []), ...comments.map(formatCommentNote)].join("\n"),
|
||||||
|
displayText: input.text,
|
||||||
|
files: [...files, ...context, ...images],
|
||||||
|
agents,
|
||||||
|
comments,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,396 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
import type { Prompt } from "@/context/prompt"
|
|
||||||
import { buildRequestParts } from "./build-request-parts"
|
|
||||||
|
|
||||||
describe("buildRequestParts", () => {
|
|
||||||
test("builds typed request and optimistic parts without cast path", () => {
|
|
||||||
const prompt: Prompt = [
|
|
||||||
{ type: "text", content: "hello", start: 0, end: 5 },
|
|
||||||
{
|
|
||||||
type: "file",
|
|
||||||
path: "src/foo.ts",
|
|
||||||
content: "@src/foo.ts",
|
|
||||||
start: 5,
|
|
||||||
end: 16,
|
|
||||||
selection: { startLine: 4, startChar: 1, endLine: 6, endChar: 1 },
|
|
||||||
},
|
|
||||||
{ type: "agent", name: "planner", content: "@planner", start: 16, end: 24 },
|
|
||||||
]
|
|
||||||
|
|
||||||
const result = buildRequestParts({
|
|
||||||
prompt,
|
|
||||||
context: [{ key: "ctx:1", type: "file", path: "src/bar.ts", comment: "check this" }],
|
|
||||||
images: [
|
|
||||||
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
|
|
||||||
],
|
|
||||||
text: "hello @src/foo.ts @planner",
|
|
||||||
messageID: "msg_1",
|
|
||||||
sessionID: "ses_1",
|
|
||||||
sessionDirectory: "/repo",
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result.requestParts[0]?.type).toBe("text")
|
|
||||||
expect(result.requestParts.some((part) => part.type === "agent")).toBe(true)
|
|
||||||
expect(
|
|
||||||
result.requestParts.some((part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts")),
|
|
||||||
).toBe(true)
|
|
||||||
expect(result.requestParts.some((part) => part.type === "text" && part.synthetic)).toBe(true)
|
|
||||||
expect(
|
|
||||||
result.requestParts.some(
|
|
||||||
(part) =>
|
|
||||||
part.type === "text" &&
|
|
||||||
part.synthetic &&
|
|
||||||
part.metadata?.opencodeComment &&
|
|
||||||
(part.metadata.opencodeComment as { comment?: string }).comment === "check this",
|
|
||||||
),
|
|
||||||
).toBe(true)
|
|
||||||
|
|
||||||
expect(result.optimisticParts).toHaveLength(result.requestParts.length)
|
|
||||||
expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("keeps multiple uploaded attachments in order", () => {
|
|
||||||
const result = buildRequestParts({
|
|
||||||
prompt: [{ type: "text", content: "check these", start: 0, end: 11 }],
|
|
||||||
context: [],
|
|
||||||
images: [
|
|
||||||
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
|
|
||||||
{
|
|
||||||
type: "image",
|
|
||||||
id: "img_2",
|
|
||||||
filename: "b.pdf",
|
|
||||||
mime: "application/pdf",
|
|
||||||
dataUrl: "data:application/pdf;base64,BBB",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
text: "check these",
|
|
||||||
messageID: "msg_multi",
|
|
||||||
sessionID: "ses_multi",
|
|
||||||
sessionDirectory: "/repo",
|
|
||||||
})
|
|
||||||
|
|
||||||
const files = result.requestParts.filter((part) => part.type === "file" && part.url.startsWith("data:"))
|
|
||||||
|
|
||||||
expect(files).toHaveLength(2)
|
|
||||||
expect(files.map((part) => (part.type === "file" ? part.filename : ""))).toEqual(["a.png", "b.pdf"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("preserves an external attachment source path for the model", () => {
|
|
||||||
const result = buildRequestParts({
|
|
||||||
prompt: [],
|
|
||||||
context: [],
|
|
||||||
images: [
|
|
||||||
{
|
|
||||||
type: "image",
|
|
||||||
id: "img_external",
|
|
||||||
filename: "opencode.global.dat",
|
|
||||||
sourcePath: "C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
|
|
||||||
mime: "text/plain",
|
|
||||||
dataUrl: "data:text/plain;base64,AAA",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
text: "inspect this",
|
|
||||||
messageID: "msg_external",
|
|
||||||
sessionID: "ses_external",
|
|
||||||
sessionDirectory: "C:\\Repos\\sst\\opencode",
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result.requestParts.find((part) => part.type === "file")?.filename).toBe(
|
|
||||||
"C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("preserves reference aliases as directory file parts", () => {
|
|
||||||
const result = buildRequestParts({
|
|
||||||
prompt: [
|
|
||||||
{
|
|
||||||
type: "file",
|
|
||||||
path: "/repo/../docs",
|
|
||||||
content: "@docs",
|
|
||||||
start: 0,
|
|
||||||
end: 5,
|
|
||||||
mime: "application/x-directory",
|
|
||||||
filename: "docs",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
context: [],
|
|
||||||
images: [],
|
|
||||||
text: "@docs",
|
|
||||||
messageID: "msg_reference",
|
|
||||||
sessionID: "ses_reference",
|
|
||||||
sessionDirectory: "/repo/app",
|
|
||||||
})
|
|
||||||
|
|
||||||
const filePart = result.requestParts.find((part) => part.type === "file")
|
|
||||||
expect(filePart).toBeDefined()
|
|
||||||
if (filePart?.type === "file") {
|
|
||||||
expect(filePart.mime).toBe("application/x-directory")
|
|
||||||
expect(filePart.filename).toBe("docs")
|
|
||||||
expect(filePart.url).toBe("file:///repo/../docs")
|
|
||||||
expect(filePart.source?.type).toBe("file")
|
|
||||||
if (filePart.source?.type === "file") {
|
|
||||||
expect(filePart.source.path).toBe("/repo/../docs")
|
|
||||||
expect(filePart.source.text.value).toBe("@docs")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("deduplicates context files when prompt already includes same path", () => {
|
|
||||||
const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
|
|
||||||
|
|
||||||
const result = buildRequestParts({
|
|
||||||
prompt,
|
|
||||||
context: [
|
|
||||||
{ key: "ctx:dup", type: "file", path: "src/foo.ts" },
|
|
||||||
{ key: "ctx:comment", type: "file", path: "src/foo.ts", comment: "focus here" },
|
|
||||||
],
|
|
||||||
images: [],
|
|
||||||
text: "@src/foo.ts",
|
|
||||||
messageID: "msg_2",
|
|
||||||
sessionID: "ses_2",
|
|
||||||
sessionDirectory: "/repo",
|
|
||||||
})
|
|
||||||
|
|
||||||
const fooFiles = result.requestParts.filter(
|
|
||||||
(part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts"),
|
|
||||||
)
|
|
||||||
const synthetic = result.requestParts.filter((part) => part.type === "text" && part.synthetic)
|
|
||||||
|
|
||||||
expect(fooFiles).toHaveLength(2)
|
|
||||||
expect(synthetic).toHaveLength(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("adds file parts for @mentions inside comment text", () => {
|
|
||||||
const result = buildRequestParts({
|
|
||||||
prompt: [{ type: "text", content: "look", start: 0, end: 4 }],
|
|
||||||
context: [
|
|
||||||
{
|
|
||||||
key: "ctx:comment-mention",
|
|
||||||
type: "file",
|
|
||||||
path: "src/review.ts",
|
|
||||||
comment: "Compare with @src/shared.ts and @src/review.ts.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
images: [],
|
|
||||||
text: "look",
|
|
||||||
messageID: "msg_comment_mentions",
|
|
||||||
sessionID: "ses_comment_mentions",
|
|
||||||
sessionDirectory: "/repo",
|
|
||||||
})
|
|
||||||
|
|
||||||
const files = result.requestParts.filter((part) => part.type === "file")
|
|
||||||
expect(files).toHaveLength(2)
|
|
||||||
expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/review.ts")).toBe(true)
|
|
||||||
expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/shared.ts")).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("handles Windows paths correctly (simulated on macOS)", () => {
|
|
||||||
const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }]
|
|
||||||
|
|
||||||
const result = buildRequestParts({
|
|
||||||
prompt,
|
|
||||||
context: [],
|
|
||||||
images: [],
|
|
||||||
text: "@src\\foo.ts",
|
|
||||||
messageID: "msg_win_1",
|
|
||||||
sessionID: "ses_win_1",
|
|
||||||
sessionDirectory: "D:\\projects\\myapp", // Windows path
|
|
||||||
})
|
|
||||||
|
|
||||||
// Should create valid file URLs
|
|
||||||
const filePart = result.requestParts.find((part) => part.type === "file")
|
|
||||||
expect(filePart).toBeDefined()
|
|
||||||
if (filePart?.type === "file") {
|
|
||||||
// URL should be parseable
|
|
||||||
expect(() => new URL(filePart.url)).not.toThrow()
|
|
||||||
// Should not have encoded backslashes in wrong place
|
|
||||||
expect(filePart.url).not.toContain("%5C")
|
|
||||||
// Should have normalized to forward slashes
|
|
||||||
expect(filePart.url).toContain("/src/foo.ts")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("handles Windows absolute path with special characters", () => {
|
|
||||||
const prompt: Prompt = [{ type: "file", path: "file#name.txt", content: "@file#name.txt", start: 0, end: 14 }]
|
|
||||||
|
|
||||||
const result = buildRequestParts({
|
|
||||||
prompt,
|
|
||||||
context: [],
|
|
||||||
images: [],
|
|
||||||
text: "@file#name.txt",
|
|
||||||
messageID: "msg_win_2",
|
|
||||||
sessionID: "ses_win_2",
|
|
||||||
sessionDirectory: "C:\\Users\\test\\Documents", // Windows path
|
|
||||||
})
|
|
||||||
|
|
||||||
const filePart = result.requestParts.find((part) => part.type === "file")
|
|
||||||
expect(filePart).toBeDefined()
|
|
||||||
if (filePart?.type === "file") {
|
|
||||||
// URL should be parseable
|
|
||||||
expect(() => new URL(filePart.url)).not.toThrow()
|
|
||||||
// Special chars should be encoded
|
|
||||||
expect(filePart.url).toContain("file%23name.txt")
|
|
||||||
// Should have Windows drive letter properly encoded
|
|
||||||
expect(filePart.url).toMatch(/file:\/\/\/[A-Z]:/)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("handles Linux absolute paths correctly", () => {
|
|
||||||
const prompt: Prompt = [{ type: "file", path: "src/app.ts", content: "@src/app.ts", start: 0, end: 10 }]
|
|
||||||
|
|
||||||
const result = buildRequestParts({
|
|
||||||
prompt,
|
|
||||||
context: [],
|
|
||||||
images: [],
|
|
||||||
text: "@src/app.ts",
|
|
||||||
messageID: "msg_linux_1",
|
|
||||||
sessionID: "ses_linux_1",
|
|
||||||
sessionDirectory: "/home/user/project",
|
|
||||||
})
|
|
||||||
|
|
||||||
const filePart = result.requestParts.find((part) => part.type === "file")
|
|
||||||
expect(filePart).toBeDefined()
|
|
||||||
if (filePart?.type === "file") {
|
|
||||||
// URL should be parseable
|
|
||||||
expect(() => new URL(filePart.url)).not.toThrow()
|
|
||||||
// Should be a normal Unix path
|
|
||||||
expect(filePart.url).toBe("file:///home/user/project/src/app.ts")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("handles macOS paths correctly", () => {
|
|
||||||
const prompt: Prompt = [{ type: "file", path: "README.md", content: "@README.md", start: 0, end: 9 }]
|
|
||||||
|
|
||||||
const result = buildRequestParts({
|
|
||||||
prompt,
|
|
||||||
context: [],
|
|
||||||
images: [],
|
|
||||||
text: "@README.md",
|
|
||||||
messageID: "msg_mac_1",
|
|
||||||
sessionID: "ses_mac_1",
|
|
||||||
sessionDirectory: "/Users/kelvin/Projects/opencode",
|
|
||||||
})
|
|
||||||
|
|
||||||
const filePart = result.requestParts.find((part) => part.type === "file")
|
|
||||||
expect(filePart).toBeDefined()
|
|
||||||
if (filePart?.type === "file") {
|
|
||||||
// URL should be parseable
|
|
||||||
expect(() => new URL(filePart.url)).not.toThrow()
|
|
||||||
// Should be a normal Unix path
|
|
||||||
expect(filePart.url).toBe("file:///Users/kelvin/Projects/opencode/README.md")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("handles context files with Windows paths", () => {
|
|
||||||
const prompt: Prompt = []
|
|
||||||
|
|
||||||
const result = buildRequestParts({
|
|
||||||
prompt,
|
|
||||||
context: [
|
|
||||||
{ key: "ctx:1", type: "file", path: "src\\utils\\helper.ts" },
|
|
||||||
{ key: "ctx:2", type: "file", path: "test\\unit.test.ts", comment: "check tests" },
|
|
||||||
],
|
|
||||||
images: [],
|
|
||||||
text: "test",
|
|
||||||
messageID: "msg_win_ctx",
|
|
||||||
sessionID: "ses_win_ctx",
|
|
||||||
sessionDirectory: "D:\\workspace\\app",
|
|
||||||
})
|
|
||||||
|
|
||||||
const fileParts = result.requestParts.filter((part) => part.type === "file")
|
|
||||||
expect(fileParts).toHaveLength(2)
|
|
||||||
|
|
||||||
// All file URLs should be valid
|
|
||||||
fileParts.forEach((part) => {
|
|
||||||
if (part.type === "file") {
|
|
||||||
expect(() => new URL(part.url)).not.toThrow()
|
|
||||||
expect(part.url).not.toContain("%5C") // No encoded backslashes
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("handles absolute Windows paths (user manually specifies full path)", () => {
|
|
||||||
const prompt: Prompt = [
|
|
||||||
{ type: "file", path: "D:\\other\\project\\file.ts", content: "@D:\\other\\project\\file.ts", start: 0, end: 25 },
|
|
||||||
]
|
|
||||||
|
|
||||||
const result = buildRequestParts({
|
|
||||||
prompt,
|
|
||||||
context: [],
|
|
||||||
images: [],
|
|
||||||
text: "@D:\\other\\project\\file.ts",
|
|
||||||
messageID: "msg_abs",
|
|
||||||
sessionID: "ses_abs",
|
|
||||||
sessionDirectory: "C:\\current\\project",
|
|
||||||
})
|
|
||||||
|
|
||||||
const filePart = result.requestParts.find((part) => part.type === "file")
|
|
||||||
expect(filePart).toBeDefined()
|
|
||||||
if (filePart?.type === "file") {
|
|
||||||
// Should handle absolute path that differs from sessionDirectory
|
|
||||||
expect(() => new URL(filePart.url)).not.toThrow()
|
|
||||||
expect(filePart.url).toContain("/D:/other/project/file.ts")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("handles selection with query parameters on Windows", () => {
|
|
||||||
const prompt: Prompt = [
|
|
||||||
{
|
|
||||||
type: "file",
|
|
||||||
path: "src\\App.tsx",
|
|
||||||
content: "@src\\App.tsx",
|
|
||||||
start: 0,
|
|
||||||
end: 11,
|
|
||||||
selection: { startLine: 10, startChar: 0, endLine: 20, endChar: 5 },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const result = buildRequestParts({
|
|
||||||
prompt,
|
|
||||||
context: [],
|
|
||||||
images: [],
|
|
||||||
text: "@src\\App.tsx",
|
|
||||||
messageID: "msg_sel",
|
|
||||||
sessionID: "ses_sel",
|
|
||||||
sessionDirectory: "C:\\project",
|
|
||||||
})
|
|
||||||
|
|
||||||
const filePart = result.requestParts.find((part) => part.type === "file")
|
|
||||||
expect(filePart).toBeDefined()
|
|
||||||
if (filePart?.type === "file") {
|
|
||||||
// Should have query parameters
|
|
||||||
expect(filePart.url).toContain("?start=10&end=20")
|
|
||||||
// Should be valid URL
|
|
||||||
expect(() => new URL(filePart.url)).not.toThrow()
|
|
||||||
// Query params should parse correctly
|
|
||||||
const url = new URL(filePart.url)
|
|
||||||
expect(url.searchParams.get("start")).toBe("10")
|
|
||||||
expect(url.searchParams.get("end")).toBe("20")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("handles file paths with dots and special segments on Windows", () => {
|
|
||||||
const prompt: Prompt = [
|
|
||||||
{ type: "file", path: "..\\..\\shared\\util.ts", content: "@..\\..\\shared\\util.ts", start: 0, end: 21 },
|
|
||||||
]
|
|
||||||
|
|
||||||
const result = buildRequestParts({
|
|
||||||
prompt,
|
|
||||||
context: [],
|
|
||||||
images: [],
|
|
||||||
text: "@..\\..\\shared\\util.ts",
|
|
||||||
messageID: "msg_dots",
|
|
||||||
sessionID: "ses_dots",
|
|
||||||
sessionDirectory: "C:\\projects\\myapp\\src",
|
|
||||||
})
|
|
||||||
|
|
||||||
const filePart = result.requestParts.find((part) => part.type === "file")
|
|
||||||
expect(filePart).toBeDefined()
|
|
||||||
if (filePart?.type === "file") {
|
|
||||||
// Should be valid URL
|
|
||||||
expect(() => new URL(filePart.url)).not.toThrow()
|
|
||||||
// Should preserve .. segments (backend normalizes)
|
|
||||||
expect(filePart.url).toContain("/..")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
|
||||||
import type { AgentPart as MessageAgentPart, FilePart, Part, TextPart } from "@/types"
|
|
||||||
import type { FileSelection } from "@/context/file"
|
|
||||||
import { encodeFilePath } from "@/context/file/path"
|
|
||||||
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
|
|
||||||
import { Identifier } from "@/utils/id"
|
|
||||||
import { createCommentMetadata, formatCommentNote } from "@/utils/comment-note"
|
|
||||||
|
|
||||||
type PromptRequestPart =
|
|
||||||
| (Omit<TextPart, "id" | "sessionID" | "messageID"> & { id: string })
|
|
||||||
| (Omit<FilePart, "id" | "sessionID" | "messageID"> & { id: string })
|
|
||||||
| (Omit<MessageAgentPart, "id" | "sessionID" | "messageID"> & { id: string })
|
|
||||||
|
|
||||||
type ContextFile = {
|
|
||||||
key: string
|
|
||||||
type: "file"
|
|
||||||
path: string
|
|
||||||
selection?: FileSelection
|
|
||||||
comment?: string
|
|
||||||
commentID?: string
|
|
||||||
commentOrigin?: "review" | "file"
|
|
||||||
preview?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type BuildRequestPartsInput = {
|
|
||||||
prompt: Prompt
|
|
||||||
context: ContextFile[]
|
|
||||||
images: (Omit<ImageAttachmentPart, "blob"> & { dataUrl: string })[]
|
|
||||||
text: string
|
|
||||||
messageID: string
|
|
||||||
sessionID: string
|
|
||||||
sessionDirectory: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const absolute = (directory: string, path: string) => {
|
|
||||||
if (path.startsWith("/")) return path
|
|
||||||
if (/^[A-Za-z]:[\\/]/.test(path) || /^[A-Za-z]:$/.test(path)) return path
|
|
||||||
if (path.startsWith("\\\\") || path.startsWith("//")) return path
|
|
||||||
return `${directory.replace(/[\\/]+$/, "")}/${path}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileQuery = (selection: FileSelection | undefined) =>
|
|
||||||
selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
|
|
||||||
|
|
||||||
const mention = /(^|[\s([{"'])@(\S+)/g
|
|
||||||
|
|
||||||
const parseCommentMentions = (comment: string) => {
|
|
||||||
return Array.from(comment.matchAll(mention)).flatMap((match) => {
|
|
||||||
const path = (match[2] ?? "").replace(/[.,!?;:)}\]"']+$/, "")
|
|
||||||
if (!path) return []
|
|
||||||
return [path]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
|
|
||||||
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
|
|
||||||
|
|
||||||
const toOptimisticPart = (part: PromptRequestPart, sessionID: string, messageID: string): Part => {
|
|
||||||
if (part.type === "text") {
|
|
||||||
return {
|
|
||||||
id: part.id,
|
|
||||||
type: "text",
|
|
||||||
text: part.text,
|
|
||||||
synthetic: part.synthetic,
|
|
||||||
ignored: part.ignored,
|
|
||||||
time: part.time,
|
|
||||||
metadata: part.metadata,
|
|
||||||
sessionID,
|
|
||||||
messageID,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (part.type === "file") {
|
|
||||||
return {
|
|
||||||
id: part.id,
|
|
||||||
type: "file",
|
|
||||||
mime: part.mime,
|
|
||||||
filename: part.filename,
|
|
||||||
url: part.url,
|
|
||||||
source: part.source,
|
|
||||||
sessionID,
|
|
||||||
messageID,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: part.id,
|
|
||||||
type: "agent",
|
|
||||||
name: part.name,
|
|
||||||
source: part.source,
|
|
||||||
sessionID,
|
|
||||||
messageID,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildRequestParts(input: BuildRequestPartsInput) {
|
|
||||||
const requestParts: PromptRequestPart[] = input.text.trim()
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
id: Identifier.ascending("part"),
|
|
||||||
type: "text",
|
|
||||||
text: input.text,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []
|
|
||||||
|
|
||||||
const files = input.prompt.filter(isFileAttachment).map((attachment) => {
|
|
||||||
const path = absolute(input.sessionDirectory, attachment.path)
|
|
||||||
const source = attachment.source
|
|
||||||
? {
|
|
||||||
...attachment.source,
|
|
||||||
text: {
|
|
||||||
value: attachment.content,
|
|
||||||
start: attachment.start,
|
|
||||||
end: attachment.end,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
type: "file" as const,
|
|
||||||
text: {
|
|
||||||
value: attachment.content,
|
|
||||||
start: attachment.start,
|
|
||||||
end: attachment.end,
|
|
||||||
},
|
|
||||||
path,
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: Identifier.ascending("part"),
|
|
||||||
type: "file",
|
|
||||||
mime: attachment.mime ?? "text/plain",
|
|
||||||
url: attachment.url ?? `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`,
|
|
||||||
filename: attachment.filename ?? getFilename(attachment.path),
|
|
||||||
source,
|
|
||||||
} satisfies PromptRequestPart
|
|
||||||
})
|
|
||||||
|
|
||||||
const agents = input.prompt.filter(isAgentAttachment).map((attachment) => {
|
|
||||||
return {
|
|
||||||
id: Identifier.ascending("part"),
|
|
||||||
type: "agent",
|
|
||||||
name: attachment.name,
|
|
||||||
source: {
|
|
||||||
value: attachment.content,
|
|
||||||
start: attachment.start,
|
|
||||||
end: attachment.end,
|
|
||||||
},
|
|
||||||
} satisfies PromptRequestPart
|
|
||||||
})
|
|
||||||
|
|
||||||
const used = new Set(files.map((part) => part.url))
|
|
||||||
const context = input.context.flatMap((item) => {
|
|
||||||
const path = absolute(input.sessionDirectory, item.path)
|
|
||||||
const url = `file://${encodeFilePath(path)}${fileQuery(item.selection)}`
|
|
||||||
const comment = item.comment?.trim()
|
|
||||||
if (!comment && used.has(url)) return []
|
|
||||||
used.add(url)
|
|
||||||
|
|
||||||
const filePart = {
|
|
||||||
id: Identifier.ascending("part"),
|
|
||||||
type: "file",
|
|
||||||
mime: "text/plain",
|
|
||||||
url,
|
|
||||||
filename: getFilename(item.path),
|
|
||||||
} satisfies PromptRequestPart
|
|
||||||
|
|
||||||
if (!comment) return [filePart]
|
|
||||||
|
|
||||||
const mentions = parseCommentMentions(comment).flatMap((path) => {
|
|
||||||
const url = `file://${encodeFilePath(absolute(input.sessionDirectory, path))}`
|
|
||||||
if (used.has(url)) return []
|
|
||||||
used.add(url)
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: Identifier.ascending("part"),
|
|
||||||
type: "file",
|
|
||||||
mime: "text/plain",
|
|
||||||
url,
|
|
||||||
filename: getFilename(path),
|
|
||||||
} satisfies PromptRequestPart,
|
|
||||||
]
|
|
||||||
})
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: Identifier.ascending("part"),
|
|
||||||
type: "text",
|
|
||||||
text: formatCommentNote({ path: item.path, selection: item.selection, comment }),
|
|
||||||
synthetic: true,
|
|
||||||
metadata: createCommentMetadata({
|
|
||||||
path: item.path,
|
|
||||||
selection: item.selection,
|
|
||||||
comment,
|
|
||||||
preview: item.preview,
|
|
||||||
origin: item.commentOrigin,
|
|
||||||
}),
|
|
||||||
} satisfies PromptRequestPart,
|
|
||||||
filePart,
|
|
||||||
...mentions,
|
|
||||||
]
|
|
||||||
})
|
|
||||||
|
|
||||||
const images = input.images.map((attachment) => {
|
|
||||||
return {
|
|
||||||
id: Identifier.ascending("part"),
|
|
||||||
type: "file",
|
|
||||||
mime: attachment.mime,
|
|
||||||
url: attachment.dataUrl,
|
|
||||||
filename: attachment.sourcePath ?? attachment.filename,
|
|
||||||
} satisfies PromptRequestPart
|
|
||||||
})
|
|
||||||
|
|
||||||
requestParts.push(...files, ...context, ...agents, ...images)
|
|
||||||
|
|
||||||
return {
|
|
||||||
requestParts,
|
|
||||||
optimisticParts: requestParts.map((part) => toOptimisticPart(part, input.sessionID, input.messageID)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,15 +11,17 @@ type SessionCreateInput = {
|
|||||||
model?: { id: string; providerID: string; variant?: string }
|
model?: { id: string; providerID: string; variant?: string }
|
||||||
location?: { directory: string }
|
location?: { directory: string }
|
||||||
}
|
}
|
||||||
const optimistic: Array<{
|
const admitted: Array<{
|
||||||
directory?: string
|
directory?: string
|
||||||
sessionID?: string
|
sessionID: string
|
||||||
message: {
|
messageID: string
|
||||||
agent: string
|
text: string
|
||||||
model: { providerID: string; modelID: string }
|
displayText: string
|
||||||
variant?: string
|
agent: string
|
||||||
}
|
model: { providerID: string; modelID: string; variant?: string }
|
||||||
|
comments: unknown[]
|
||||||
}> = []
|
}> = []
|
||||||
|
const confirmed: unknown[] = []
|
||||||
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
|
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
|
||||||
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
|
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
|
||||||
const sentShellDirectories: string[] = []
|
const sentShellDirectories: string[] = []
|
||||||
@@ -35,9 +37,11 @@ const switchedModels: Array<{
|
|||||||
const sessionRequestOrder: string[] = []
|
const sessionRequestOrder: string[] = []
|
||||||
const updatedDrafts: Array<{ draftID: string; worktree?: string }> = []
|
const updatedDrafts: Array<{ draftID: string; worktree?: string }> = []
|
||||||
const syncedServers: string[] = []
|
const syncedServers: string[] = []
|
||||||
const optimisticServers: string[] = []
|
const admittedServers: string[] = []
|
||||||
const promptCaptures: Array<{ scope?: unknown; target?: unknown }> = []
|
const promptCaptures: Array<{ scope?: unknown; target?: unknown }> = []
|
||||||
let serverSessionSyncs = 0
|
let serverSessionSyncs = 0
|
||||||
|
let restoredPrompts = 0
|
||||||
|
let clearEchoCalls = 0
|
||||||
|
|
||||||
let params: { id?: string } = {}
|
let params: { id?: string } = {}
|
||||||
let search: { draftId?: string } = {}
|
let search: { draftId?: string } = {}
|
||||||
@@ -47,6 +51,8 @@ let createSessionGate: Promise<void> | undefined
|
|||||||
let createWorktreeGate: Promise<void> | undefined
|
let createWorktreeGate: Promise<void> | undefined
|
||||||
let worktreeFailure: Error | undefined
|
let worktreeFailure: Error | undefined
|
||||||
let locationFailure: Error | undefined
|
let locationFailure: Error | undefined
|
||||||
|
let promptFailure: Error | undefined
|
||||||
|
let clearEchoResult = true
|
||||||
let worktreeCreates = 0
|
let worktreeCreates = 0
|
||||||
let activeSDK = "server-a"
|
let activeSDK = "server-a"
|
||||||
let activeServerSync = "server-a"
|
let activeServerSync = "server-a"
|
||||||
@@ -74,7 +80,7 @@ const prompt = {
|
|||||||
set: () => undefined,
|
set: () => undefined,
|
||||||
},
|
},
|
||||||
reset: () => undefined,
|
reset: () => undefined,
|
||||||
set: () => undefined,
|
set: () => restoredPrompts++,
|
||||||
context: {
|
context: {
|
||||||
add: () => undefined,
|
add: () => undefined,
|
||||||
remove: () => undefined,
|
remove: () => undefined,
|
||||||
@@ -116,7 +122,16 @@ const clientFor = (directory: string) => {
|
|||||||
sessionRequestOrder.push("prompt")
|
sessionRequestOrder.push("prompt")
|
||||||
sentPrompts.push(sessionDirectories[(input as { sessionID: string }).sessionID] ?? directory)
|
sentPrompts.push(sessionDirectories[(input as { sessionID: string }).sessionID] ?? directory)
|
||||||
promptInputs.push(input)
|
promptInputs.push(input)
|
||||||
return { data: undefined }
|
if (promptFailure) throw promptFailure
|
||||||
|
const prompt = input as { sessionID: string; id: string; text: string }
|
||||||
|
return {
|
||||||
|
id: prompt.id,
|
||||||
|
sessionID: prompt.sessionID,
|
||||||
|
timeCreated: 1,
|
||||||
|
type: "user" as const,
|
||||||
|
delivery: "steer" as const,
|
||||||
|
payload: { text: prompt.text },
|
||||||
|
}
|
||||||
},
|
},
|
||||||
switchAgent: async (input: { sessionID: string; agent: string }) => {
|
switchAgent: async (input: { sessionID: string; agent: string }) => {
|
||||||
sessionRequestOrder.push("agent")
|
sessionRequestOrder.push("agent")
|
||||||
@@ -235,16 +250,27 @@ beforeAll(async () => {
|
|||||||
return {
|
return {
|
||||||
data: { command: commands, project: "project" },
|
data: { command: commands, project: "project" },
|
||||||
session: {
|
session: {
|
||||||
optimistic: {
|
inbox: {
|
||||||
add: (value: {
|
echo: (value: {
|
||||||
directory?: string
|
directory?: string
|
||||||
sessionID?: string
|
sessionID: string
|
||||||
message: { agent: string; model: { providerID: string; modelID: string; variant?: string } }
|
messageID: string
|
||||||
|
text: string
|
||||||
|
displayText: string
|
||||||
|
agent: string
|
||||||
|
model: { providerID: string; modelID: string; variant?: string }
|
||||||
|
comments: unknown[]
|
||||||
}) => {
|
}) => {
|
||||||
optimisticServers.push(server)
|
admittedServers.push(server)
|
||||||
optimistic.push(value)
|
admitted.push(value)
|
||||||
|
},
|
||||||
|
confirm: (value: unknown) => {
|
||||||
|
confirmed.push(value)
|
||||||
|
},
|
||||||
|
clearEcho: () => {
|
||||||
|
clearEchoCalls++
|
||||||
|
return clearEchoResult
|
||||||
},
|
},
|
||||||
remove: () => undefined,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
set: () => undefined,
|
set: () => undefined,
|
||||||
@@ -304,7 +330,8 @@ beforeAll(async () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
createdSessions.length = 0
|
createdSessions.length = 0
|
||||||
optimistic.length = 0
|
admitted.length = 0
|
||||||
|
confirmed.length = 0
|
||||||
promotedDrafts.length = 0
|
promotedDrafts.length = 0
|
||||||
updatedDrafts.length = 0
|
updatedDrafts.length = 0
|
||||||
sentCommands.length = 0
|
sentCommands.length = 0
|
||||||
@@ -314,8 +341,10 @@ beforeEach(() => {
|
|||||||
switchedModels.length = 0
|
switchedModels.length = 0
|
||||||
sessionRequestOrder.length = 0
|
sessionRequestOrder.length = 0
|
||||||
syncedServers.length = 0
|
syncedServers.length = 0
|
||||||
optimisticServers.length = 0
|
admittedServers.length = 0
|
||||||
promptCaptures.length = 0
|
promptCaptures.length = 0
|
||||||
|
restoredPrompts = 0
|
||||||
|
clearEchoCalls = 0
|
||||||
params = {}
|
params = {}
|
||||||
search = {}
|
search = {}
|
||||||
sentShell.length = 0
|
sentShell.length = 0
|
||||||
@@ -333,6 +362,8 @@ beforeEach(() => {
|
|||||||
createWorktreeGate = undefined
|
createWorktreeGate = undefined
|
||||||
worktreeFailure = undefined
|
worktreeFailure = undefined
|
||||||
locationFailure = undefined
|
locationFailure = undefined
|
||||||
|
promptFailure = undefined
|
||||||
|
clearEchoResult = true
|
||||||
worktreeCreates = 0
|
worktreeCreates = 0
|
||||||
for (const key of Object.keys(draftServers)) delete draftServers[key]
|
for (const key of Object.keys(draftServers)) delete draftServers[key]
|
||||||
for (const key of Object.keys(sessionDirectories)) delete sessionDirectories[key]
|
for (const key of Object.keys(sessionDirectories)) delete sessionDirectories[key]
|
||||||
@@ -421,7 +452,7 @@ describe("prompt submit worktree selection", () => {
|
|||||||
expect(updatedDrafts).toEqual([{ draftID: "draft-1", worktree: undefined }])
|
expect(updatedDrafts).toEqual([{ draftID: "draft-1", worktree: undefined }])
|
||||||
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server-a", sessionId: "session-1" }])
|
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server-a", sessionId: "session-1" }])
|
||||||
expect(syncedServers.every((server) => server === "server-a")).toBe(true)
|
expect(syncedServers.every((server) => server === "server-a")).toBe(true)
|
||||||
expect(optimisticServers).toEqual(["server-a"])
|
expect(admittedServers).toEqual(["server-a"])
|
||||||
expect(promptCaptures.at(-1)?.target).toEqual({ server: "project-server-a", scope: ServerScope.local })
|
expect(promptCaptures.at(-1)?.target).toEqual({ server: "project-server-a", scope: ServerScope.local })
|
||||||
expect(submitted).toBe(0)
|
expect(submitted).toBe(0)
|
||||||
})
|
})
|
||||||
@@ -441,13 +472,15 @@ describe("prompt submit worktree selection", () => {
|
|||||||
await submit.handleSubmit(event)
|
await submit.handleSubmit(event)
|
||||||
await Bun.sleep(0)
|
await Bun.sleep(0)
|
||||||
|
|
||||||
expect(optimistic).toHaveLength(1)
|
expect(admitted).toHaveLength(1)
|
||||||
expect(optimistic[0]).toMatchObject({
|
expect(admitted[0]).toMatchObject({
|
||||||
message: {
|
sessionID: "session-1",
|
||||||
agent: "agent",
|
text: "ls",
|
||||||
model: { providerID: "provider", modelID: "model", variant: "high" },
|
agent: "agent",
|
||||||
},
|
model: { providerID: "provider", modelID: "model", variant: "high" },
|
||||||
})
|
})
|
||||||
|
expect(admitted[0]?.messageID).toStartWith("msg_")
|
||||||
|
expect(confirmed).toMatchObject([{ id: admitted[0]?.messageID, sessionID: "session-1" }])
|
||||||
expect(sentPrompts).toEqual(["/repo/main"])
|
expect(sentPrompts).toEqual(["/repo/main"])
|
||||||
expect(switchedAgents).toEqual([{ sessionID: "session-1", agent: "agent" }])
|
expect(switchedAgents).toEqual([{ sessionID: "session-1", agent: "agent" }])
|
||||||
expect(switchedModels).toEqual([
|
expect(switchedModels).toEqual([
|
||||||
@@ -466,6 +499,22 @@ describe("prompt submit worktree selection", () => {
|
|||||||
expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_")
|
expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("keeps a confirmed echo when the prompt response is lost", async () => {
|
||||||
|
params = { id: "session-1" }
|
||||||
|
promptFailure = new Error("connection lost")
|
||||||
|
clearEchoResult = false
|
||||||
|
const submit = makeSubmit({
|
||||||
|
info: () => ({ id: "session-1", agent: "agent", model: { id: "model", providerID: "provider" } }),
|
||||||
|
})
|
||||||
|
|
||||||
|
await submit.handleSubmit(event)
|
||||||
|
await settle()
|
||||||
|
|
||||||
|
expect(admitted).toHaveLength(1)
|
||||||
|
expect(clearEchoCalls).toBe(1)
|
||||||
|
expect(restoredPrompts).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
test("submits slash commands through the current session API", async () => {
|
test("submits slash commands through the current session API", async () => {
|
||||||
params = { id: "session-1" }
|
params = { id: "session-1" }
|
||||||
variant = "high"
|
variant = "high"
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import type { Message } from "@/types"
|
|
||||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { Binary } from "@opencode-ai/core/util/binary"
|
import { Binary } from "@opencode-ai/core/util/binary"
|
||||||
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||||
import { batch, startTransition, type Accessor } from "solid-js"
|
import { startTransition, type Accessor } from "solid-js"
|
||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
import { useServerSync, type ServerSync } from "@/context/server-sync"
|
import { useServerSync, type ServerSync } from "@/context/server-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
@@ -15,7 +14,7 @@ import { useSDK, type DirectorySDK } from "@/context/sdk"
|
|||||||
import { useSync, type DirectorySync } from "@/context/sync"
|
import { useSync, type DirectorySync } from "@/context/sync"
|
||||||
import { Identifier } from "@/utils/id"
|
import { Identifier } from "@/utils/id"
|
||||||
import { getDirectory } from "@opencode-ai/core/util/path"
|
import { getDirectory } from "@opencode-ai/core/util/path"
|
||||||
import { buildRequestParts } from "./build-request-parts"
|
import { buildPromptRequest } from "./build-prompt-request"
|
||||||
import { setCursorPosition } from "./editor-dom"
|
import { setCursorPosition } from "./editor-dom"
|
||||||
import { formatServerError } from "@/utils/server-errors"
|
import { formatServerError } from "@/utils/server-errors"
|
||||||
import { ScopedKey } from "@/utils/server-scope"
|
import { ScopedKey } from "@/utils/server-scope"
|
||||||
@@ -100,43 +99,22 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
|||||||
dataUrl: await blobDataUrl(attachment.blob, attachment.mime),
|
dataUrl: await blobDataUrl(attachment.blob, attachment.mime),
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
const { requestParts, optimisticParts } = buildRequestParts({
|
const request = buildPromptRequest({
|
||||||
prompt: input.draft.prompt,
|
prompt: input.draft.prompt,
|
||||||
context: input.draft.context,
|
context: input.draft.context,
|
||||||
images: encodedImages,
|
images: encodedImages,
|
||||||
text,
|
text,
|
||||||
sessionID: input.draft.sessionID,
|
|
||||||
messageID,
|
|
||||||
sessionDirectory: input.draft.sessionDirectory,
|
sessionDirectory: input.draft.sessionDirectory,
|
||||||
})
|
})
|
||||||
|
|
||||||
const message: Message = {
|
setBusy()
|
||||||
id: messageID,
|
input.sync.session.inbox.echo({
|
||||||
|
directory: input.draft.sessionDirectory,
|
||||||
sessionID: input.draft.sessionID,
|
sessionID: input.draft.sessionID,
|
||||||
role: "user",
|
messageID,
|
||||||
time: { created: Date.now() },
|
|
||||||
agent: input.draft.agent,
|
agent: input.draft.agent,
|
||||||
model: { ...input.draft.model, variant: input.draft.variant },
|
model: { ...input.draft.model, variant: input.draft.variant },
|
||||||
}
|
...request,
|
||||||
|
|
||||||
const add = () =>
|
|
||||||
input.sync.session.optimistic.add({
|
|
||||||
directory: input.draft.sessionDirectory,
|
|
||||||
sessionID: input.draft.sessionID,
|
|
||||||
message,
|
|
||||||
parts: optimisticParts,
|
|
||||||
})
|
|
||||||
|
|
||||||
const remove = () =>
|
|
||||||
input.sync.session.optimistic.remove({
|
|
||||||
directory: input.draft.sessionDirectory,
|
|
||||||
sessionID: input.draft.sessionID,
|
|
||||||
messageID,
|
|
||||||
})
|
|
||||||
|
|
||||||
batch(() => {
|
|
||||||
setBusy()
|
|
||||||
add()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -159,40 +137,23 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
await input.api.prompt({
|
const admitted = await input.api.prompt({
|
||||||
sessionID: input.draft.sessionID,
|
sessionID: input.draft.sessionID,
|
||||||
id: messageID,
|
id: messageID,
|
||||||
text: requestParts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
|
text: request.text,
|
||||||
files: requestParts.flatMap((part) => {
|
files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
|
||||||
if (part.type !== "file") return []
|
agents: request.agents,
|
||||||
const text = part.source?.text
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
uri: part.url,
|
|
||||||
name: part.filename,
|
|
||||||
mention: text ? { start: text.start, end: text.end, text: text.value } : undefined,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}),
|
|
||||||
agents: requestParts.flatMap((part) =>
|
|
||||||
part.type === "agent"
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
name: part.name,
|
|
||||||
mention: part.source
|
|
||||||
? { start: part.source.start, end: part.source.end, text: part.source.value }
|
|
||||||
: undefined,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [],
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
|
input.sync.session.inbox.confirm(admitted)
|
||||||
return true
|
return true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
batch(() => {
|
const failed = input.sync.session.inbox.clearEcho({
|
||||||
setIdle()
|
directory: input.draft.sessionDirectory,
|
||||||
remove()
|
sessionID: input.draft.sessionID,
|
||||||
|
messageID,
|
||||||
})
|
})
|
||||||
|
if (!failed) return true
|
||||||
|
setIdle()
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -538,14 +499,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
|
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
|
||||||
const messageID = Identifier.ascending("message")
|
const messageID = Identifier.ascending("message")
|
||||||
|
|
||||||
const removeOptimisticMessage = () => {
|
|
||||||
submissionSync.session.optimistic.remove({
|
|
||||||
directory: sessionDirectory,
|
|
||||||
sessionID: session.id,
|
|
||||||
messageID,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const item of commentItems) submission.target().context.remove(item.key)
|
for (const item of commentItems) submission.target().context.remove(item.key)
|
||||||
clearInput()
|
clearInput()
|
||||||
|
|
||||||
@@ -565,7 +518,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||||
description: errorMessage(err),
|
description: errorMessage(err),
|
||||||
})
|
})
|
||||||
removeOptimisticMessage()
|
|
||||||
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -119,9 +119,11 @@ export function createProviderConnectionController(options: {
|
|||||||
const finish = async () => {
|
const finish = async () => {
|
||||||
cancelPolling()
|
cancelPolling()
|
||||||
const directory = options.directory()
|
const directory = options.directory()
|
||||||
await queryClient
|
const key = directory ? pathKey(directory) : null
|
||||||
.refetchQueries(serverSync.queryOptions.providers(directory ? pathKey(directory) : null))
|
await Promise.all([
|
||||||
.catch(() => undefined)
|
queryClient.refetchQueries(serverSync.queryOptions.providers(key)).catch(() => undefined),
|
||||||
|
queryClient.refetchQueries(serverSync.queryOptions.integrations(key)).catch(() => undefined),
|
||||||
|
])
|
||||||
if (polling.disposed) return
|
if (polling.disposed) return
|
||||||
options.onComplete()
|
options.onComplete()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||||
|
import { useIntegrations } from "@/hooks/use-integrations"
|
||||||
import { createMemo, type Component, For, Show } from "solid-js"
|
import { createMemo, type Component, For, Show } from "solid-js"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
@@ -40,7 +41,9 @@ export const SettingsProvidersV2: Component<{
|
|||||||
const serverSdk = useServerSDK()
|
const serverSdk = useServerSDK()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const providers = useProviders(() => props.directory)
|
const providers = useProviders(() => props.directory)
|
||||||
|
const integrations = useIntegrations(() => props.directory)
|
||||||
const providerConnect = useProviderConnectController({ onBack: props.onBack })
|
const providerConnect = useProviderConnectController({ onBack: props.onBack })
|
||||||
|
const integration = (providerID: string) => integrations.list().find((item) => item.id === providerID)
|
||||||
|
|
||||||
const connect = (provider?: string) => {
|
const connect = (provider?: string) => {
|
||||||
providerConnect.select(provider)
|
providerConnect.select(provider)
|
||||||
@@ -73,7 +76,14 @@ export const SettingsProvidersV2: Component<{
|
|||||||
return items
|
return items
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Connection state comes from the integration list like the TUI: credential
|
||||||
|
// connections mean an API key or OAuth grant, env connections mean detected
|
||||||
|
// environment variables, and a connectionless integration is config-provided.
|
||||||
const source = (item: ProviderItem): ProviderSource | undefined => {
|
const source = (item: ProviderItem): ProviderSource | undefined => {
|
||||||
|
const current = integration(item.id)
|
||||||
|
if (current?.connections.some((connection) => connection.type === "credential")) return "api"
|
||||||
|
if (current?.connections.some((connection) => connection.type === "env")) return "env"
|
||||||
|
if (current) return "config"
|
||||||
if (!("source" in item)) return
|
if (!("source" in item)) return
|
||||||
const value = item.source
|
const value = item.source
|
||||||
if (value === "env" || value === "api" || value === "config" || value === "custom") return value
|
if (value === "env" || value === "api" || value === "config" || value === "custom") return value
|
||||||
@@ -92,7 +102,11 @@ export const SettingsProvidersV2: Component<{
|
|||||||
return language.t("settings.providers.tag.other")
|
return language.t("settings.providers.tag.other")
|
||||||
}
|
}
|
||||||
|
|
||||||
const canDisconnect = (item: ProviderItem) => source(item) !== "env" && !isConfigCustom(item.id)
|
const canDisconnect = (item: ProviderItem) => {
|
||||||
|
const current = integration(item.id)
|
||||||
|
if (current) return current.connections.some((connection) => connection.type === "credential")
|
||||||
|
return source(item) !== "env" && !isConfigCustom(item.id)
|
||||||
|
}
|
||||||
|
|
||||||
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,8 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-link"] {
|
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-link"],
|
||||||
|
[data-titlebar-tab]:is(:hover, [data-active="true"]):not([data-editing="true"]) [data-slot="tab-link"] {
|
||||||
--tab-title-fade-offset: 4px;
|
--tab-title-fade-offset: 4px;
|
||||||
-webkit-mask-image: linear-gradient(
|
-webkit-mask-image: linear-gradient(
|
||||||
to right,
|
to right,
|
||||||
@@ -86,7 +87,8 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]):dir(rtl) [data-slot="tab-link"] {
|
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]):dir(rtl) [data-slot="tab-link"],
|
||||||
|
[data-titlebar-tab]:is(:hover, [data-active="true"]):not([data-editing="true"]):dir(rtl) [data-slot="tab-link"] {
|
||||||
-webkit-mask-image: linear-gradient(
|
-webkit-mask-image: linear-gradient(
|
||||||
to left,
|
to left,
|
||||||
black 0,
|
black 0,
|
||||||
@@ -103,8 +105,7 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-titlebar-tab][data-title-overflow="true"]:is(:hover, [data-active="true"]):not([data-editing="true"])
|
[data-titlebar-tab]:is(:hover, [data-active="true"]):not([data-editing="true"]) [data-slot="tab-link"] {
|
||||||
[data-slot="tab-link"] {
|
|
||||||
--tab-title-fade-offset: 24px;
|
--tab-title-fade-offset: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Binary } from "@opencode-ai/core/util/binary"
|
import { Binary } from "@opencode-ai/core/util/binary"
|
||||||
import type { Message, Part } from "@/types"
|
import type { SessionInboxInfo, SessionInfo } from "@opencode-ai/client/promise"
|
||||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
|
||||||
import { createMemo } from "solid-js"
|
import { createMemo } from "solid-js"
|
||||||
import { produce, reconcile, type SetStoreFunction } from "solid-js/store"
|
import { produce, reconcile, type SetStoreFunction } from "solid-js/store"
|
||||||
import type { createServerSdkContext } from "./server-sdk"
|
import type { createServerSdkContext } from "./server-sdk"
|
||||||
import type { createServerSyncContextInner } from "./server-sync"
|
import type { createServerSyncContextInner } from "./server-sync"
|
||||||
|
import type { PromptEcho } from "./server-session"
|
||||||
import type { State } from "./global-sync/types"
|
import type { State } from "./global-sync/types"
|
||||||
|
|
||||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||||
@@ -82,34 +82,16 @@ export const createDirSyncContext = (
|
|||||||
const session = serverSync.session.get(sessionID)
|
const session = serverSync.session.get(sessionID)
|
||||||
if (session?.location.directory === directory) return session
|
if (session?.location.directory === directory) return session
|
||||||
},
|
},
|
||||||
optimistic: {
|
inbox: {
|
||||||
add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
|
echo(input: PromptEcho & { directory?: string }) {
|
||||||
serverSync.session.optimistic.add(input)
|
serverSync.session.inbox.echo(input)
|
||||||
},
|
},
|
||||||
remove(input: { directory?: string; sessionID: string; messageID: string }) {
|
confirm(input: SessionInboxInfo) {
|
||||||
serverSync.session.optimistic.remove(input)
|
return serverSync.session.inbox.confirm(input)
|
||||||
|
},
|
||||||
|
clearEcho(input: { directory?: string; sessionID: string; messageID: string }) {
|
||||||
|
return serverSync.session.inbox.clearEcho(input)
|
||||||
},
|
},
|
||||||
},
|
|
||||||
addOptimisticMessage(input: {
|
|
||||||
sessionID: string
|
|
||||||
messageID: string
|
|
||||||
parts: Part[]
|
|
||||||
agent: string
|
|
||||||
model: { providerID: string; modelID: string }
|
|
||||||
variant?: string
|
|
||||||
}) {
|
|
||||||
serverSync.session.optimistic.add({
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
message: {
|
|
||||||
id: input.messageID,
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
role: "user",
|
|
||||||
time: { created: Date.now() },
|
|
||||||
agent: input.agent,
|
|
||||||
model: { ...input.model, variant: input.variant },
|
|
||||||
},
|
|
||||||
parts: input.parts,
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
async sync(sessionID: string, options?: { force?: boolean }) {
|
async sync(sessionID: string, options?: { force?: boolean }) {
|
||||||
await serverSync.session.sync(sessionID, options)
|
await serverSync.session.sync(sessionID, options)
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ describe("encodeFilePath", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("should handle mixed separator path (Windows + Unix)", () => {
|
test("should handle mixed separator path (Windows + Unix)", () => {
|
||||||
// This is what happens in build-request-parts.ts when concatenating paths
|
// This is what happens in build-prompt-request.ts when concatenating paths
|
||||||
const mixedPath = "D:\\dev\\projects\\opencode/README.bs.md"
|
const mixedPath = "D:\\dev\\projects\\opencode/README.bs.md"
|
||||||
const result = encodeFilePath(mixedPath)
|
const result = encodeFilePath(mixedPath)
|
||||||
const fileUrl = `file://${result}`
|
const fileUrl = `file://${result}`
|
||||||
|
|||||||
@@ -287,7 +287,8 @@ export function createServerNotificationState(input: { sdk: ServerSDK; sync: Ser
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
const directory = e.name
|
const directory = event.current?.location?.directory
|
||||||
|
if (!directory) return
|
||||||
const time = Date.now()
|
const time = Date.now()
|
||||||
if (event.type === "session.execution.failed") {
|
if (event.type === "session.execution.failed") {
|
||||||
handleSessionError(directory, event, time)
|
handleSessionError(directory, event, time)
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ export function createServerPermissionState(input: { sdk: ServerSDK; sync: Serve
|
|||||||
const handlePermission = (e: PermissionEvent) => {
|
const handlePermission = (e: PermissionEvent) => {
|
||||||
const event = e.details
|
const event = e.details
|
||||||
if (event?.type !== "permission.asked") return
|
if (event?.type !== "permission.asked") return
|
||||||
void respondPending(event.properties, e.name)
|
void respondPending(event.properties, event.current?.location?.directory)
|
||||||
}
|
}
|
||||||
|
|
||||||
const unsubscribe = input.sdk.event.listen((event) => {
|
const unsubscribe = input.sdk.event.listen((event) => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||||
import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
|
import { adaptServerEvent, coalesceServerEvents, resumeStreamAfterPageShow } from "./server-sdk"
|
||||||
|
|
||||||
describe("resumeStreamAfterPageShow", () => {
|
describe("resumeStreamAfterPageShow", () => {
|
||||||
test("restarts a stream only after a back-forward cache restore", () => {
|
test("restarts a stream only after a back-forward cache restore", () => {
|
||||||
@@ -45,23 +45,21 @@ describe("adaptServerEvent", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("current event buffering", () => {
|
describe("current event buffering", () => {
|
||||||
const delta = (id: string, value: string, ordinal = 0) => ({
|
const delta = (id: string, value: string, ordinal = 0) =>
|
||||||
directory: "/repo",
|
adaptServerEvent({
|
||||||
payload: adaptServerEvent({
|
|
||||||
id,
|
id,
|
||||||
created: 1,
|
created: 1,
|
||||||
type: "session.text.delta",
|
type: "session.text.delta",
|
||||||
location: { directory: "/repo" },
|
location: { directory: "/repo" },
|
||||||
data: { sessionID: "ses", assistantMessageID: "msg", ordinal, delta: value },
|
data: { sessionID: "ses", assistantMessageID: "msg", ordinal, delta: value },
|
||||||
} as OpenCodeEvent),
|
} as OpenCodeEvent)
|
||||||
})
|
|
||||||
|
|
||||||
test("merges adjacent text deltas for the same message and ordinal", () => {
|
test("merges adjacent text deltas for the same message and ordinal", () => {
|
||||||
const result = coalesceServerEvents([delta("evt_1", "hello "), delta("evt_2", "world")])
|
const result = coalesceServerEvents([delta("evt_1", "hello "), delta("evt_2", "world")])
|
||||||
|
|
||||||
expect(result).toHaveLength(1)
|
expect(result).toHaveLength(1)
|
||||||
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
|
expect(result[0]?.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
|
||||||
expect(result[0]?.payload.properties).toMatchObject({ delta: "hello world" })
|
expect(result[0]?.properties).toMatchObject({ delta: "hello world" })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("coalesces current tool input deltas by tool ID", () => {
|
test("coalesces current tool input deltas by tool ID", () => {
|
||||||
@@ -74,26 +72,19 @@ describe("current event buffering", () => {
|
|||||||
data: { sessionID: "ses", assistantMessageID: "msg", id, delta },
|
data: { sessionID: "ses", assistantMessageID: "msg", id, delta },
|
||||||
} as OpenCodeEvent)
|
} as OpenCodeEvent)
|
||||||
const result = coalesceServerEvents([
|
const result = coalesceServerEvents([
|
||||||
{ directory: "/repo", payload: current("evt_1", "call_1", "{") },
|
current("evt_1", "call_1", "{"),
|
||||||
{ directory: "/repo", payload: current("evt_2", "call_1", "}") },
|
current("evt_2", "call_1", "}"),
|
||||||
{ directory: "/repo", payload: current("evt_3", "call_2", "[]") },
|
current("evt_3", "call_2", "[]"),
|
||||||
])
|
])
|
||||||
|
|
||||||
expect(result).toHaveLength(2)
|
expect(result).toHaveLength(2)
|
||||||
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { id: "call_1", delta: "{}" } })
|
expect(result[0]?.current).toMatchObject({ id: "evt_2", data: { id: "call_1", delta: "{}" } })
|
||||||
expect(result[1]?.payload.current).toMatchObject({ id: "evt_3", data: { id: "call_2", delta: "[]" } })
|
expect(result[1]?.current).toMatchObject({ id: "evt_3", data: { id: "call_2", delta: "[]" } })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("preserves boundaries between distinct delta streams", () => {
|
test("preserves boundaries between distinct delta streams", () => {
|
||||||
const events = [delta("evt_1", "a"), delta("evt_2", "b", 1), delta("evt_3", "c")]
|
const events = [delta("evt_1", "a"), delta("evt_2", "b", 1), delta("evt_3", "c")]
|
||||||
|
|
||||||
expect(coalesceServerEvents(events).map((event) => event.payload.current?.id)).toEqual(["evt_1", "evt_2", "evt_3"])
|
expect(coalesceServerEvents(events).map((event) => event.current?.id)).toEqual(["evt_1", "evt_2", "evt_3"])
|
||||||
})
|
|
||||||
|
|
||||||
test("preserves current event order when enqueuing", () => {
|
|
||||||
const events: Parameters<typeof enqueueServerEvent>[0] = []
|
|
||||||
;[delta("evt_1", "a"), delta("evt_2", "b", 1)].forEach((event) => enqueueServerEvent(events, event))
|
|
||||||
|
|
||||||
expect(events.map((event) => event.payload.current?.id)).toEqual(["evt_1", "evt_2"])
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { ServerScope } from "@/utils/server-scope"
|
|||||||
import { useServer } from "./server"
|
import { useServer } from "./server"
|
||||||
|
|
||||||
export type ServerEvent = Event & { id?: string; current?: OpenCodeEvent }
|
export type ServerEvent = Event & { id?: string; current?: OpenCodeEvent }
|
||||||
type QueuedServerEvent = { directory: string; payload: ServerEvent }
|
type ServerEventMap = { [Type in ServerEvent["type"]]: Extract<ServerEvent, { type: Type }> }
|
||||||
type CurrentDelta = Extract<
|
type CurrentDelta = Extract<
|
||||||
OpenCodeEvent,
|
OpenCodeEvent,
|
||||||
{ type: "session.text.delta" | "session.reasoning.delta" | "session.tool.input.delta" | "session.compaction.delta" }
|
{ type: "session.text.delta" | "session.reasoning.delta" | "session.tool.input.delta" | "session.compaction.delta" }
|
||||||
@@ -22,22 +22,17 @@ export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
|
|||||||
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
|
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServerEvent) {
|
export function coalesceServerEvents(events: ServerEvent[]) {
|
||||||
queue.push(event)
|
const output: ServerEvent[] = []
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
export function coalesceServerEvents(events: QueuedServerEvent[]) {
|
|
||||||
const output: QueuedServerEvent[] = []
|
|
||||||
events.forEach((event) => {
|
events.forEach((event) => {
|
||||||
const current = currentDelta(event.payload.current)
|
const current = currentDelta(event.current)
|
||||||
if (current) {
|
if (current) {
|
||||||
const previous = output[output.length - 1]
|
const previous = output[output.length - 1]
|
||||||
const prior = currentDelta(previous?.payload.current)
|
const prior = currentDelta(previous?.current)
|
||||||
if (
|
if (
|
||||||
previous &&
|
previous &&
|
||||||
prior &&
|
prior &&
|
||||||
previous.directory === event.directory &&
|
prior.location?.directory === current.location?.directory &&
|
||||||
currentDeltaKey(prior) === currentDeltaKey(current)
|
currentDeltaKey(prior) === currentDeltaKey(current)
|
||||||
) {
|
) {
|
||||||
const fragment = currentDeltaFragment(prior) + currentDeltaFragment(current)
|
const fragment = currentDeltaFragment(prior) + currentDeltaFragment(current)
|
||||||
@@ -46,13 +41,10 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) {
|
|||||||
? { ...current.data, text: fragment }
|
? { ...current.data, text: fragment }
|
||||||
: { ...current.data, delta: fragment }
|
: { ...current.data, delta: fragment }
|
||||||
output[output.length - 1] = {
|
output[output.length - 1] = {
|
||||||
directory: event.directory,
|
...event,
|
||||||
payload: {
|
properties: data,
|
||||||
...event.payload,
|
current: { ...current, data } as CurrentDelta,
|
||||||
properties: data,
|
} as ServerEvent
|
||||||
current: { ...current, data } as CurrentDelta,
|
|
||||||
} as ServerEvent,
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
output.push(event)
|
output.push(event)
|
||||||
@@ -89,7 +81,8 @@ export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: ()
|
|||||||
start()
|
start()
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<{ [key: string]: ServerEvent }>>
|
type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<ServerEventMap>>
|
||||||
|
type ServerLocationEventEmitter = ReturnType<typeof createGlobalEmitter<{ [directory: string]: ServerEvent }>>
|
||||||
export type ServerConnectionStatus = "connecting" | "connected" | "reconnecting"
|
export type ServerConnectionStatus = "connecting" | "connected" | "reconnecting"
|
||||||
type ServerSDKBase = {
|
type ServerSDKBase = {
|
||||||
server: ServerConnection.Any
|
server: ServerConnection.Any
|
||||||
@@ -104,6 +97,9 @@ type ServerSDKBase = {
|
|||||||
event: {
|
event: {
|
||||||
on: ServerEventEmitter["on"]
|
on: ServerEventEmitter["on"]
|
||||||
listen: ServerEventEmitter["listen"]
|
listen: ServerEventEmitter["listen"]
|
||||||
|
location: {
|
||||||
|
on: ServerLocationEventEmitter["on"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,18 +119,16 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||||||
})()
|
})()
|
||||||
|
|
||||||
const eventApi = createApiForServer({ server: server.http, fetch: eventFetch })
|
const eventApi = createApiForServer({ server: server.http, fetch: eventFetch })
|
||||||
const emitter = createGlobalEmitter<{
|
const emitter = createGlobalEmitter<ServerEventMap>()
|
||||||
[key: string]: ServerEvent
|
const locations = createGlobalEmitter<{ [directory: string]: ServerEvent }>()
|
||||||
}>()
|
|
||||||
|
|
||||||
type Queued = QueuedServerEvent
|
|
||||||
const FLUSH_FRAME_MS = 16
|
const FLUSH_FRAME_MS = 16
|
||||||
const STREAM_YIELD_MS = 8
|
const STREAM_YIELD_MS = 8
|
||||||
const CONNECT_TIMEOUT_MS = 2_000
|
const CONNECT_TIMEOUT_MS = 2_000
|
||||||
const RECONNECT_DELAY_MS = 1_000
|
const RECONNECT_DELAY_MS = 1_000
|
||||||
|
|
||||||
let queue: Queued[] = []
|
let queue: ServerEvent[] = []
|
||||||
let buffer: Queued[] = []
|
let buffer: ServerEvent[] = []
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
let last = 0
|
let last = 0
|
||||||
|
|
||||||
@@ -152,7 +146,11 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||||||
last = Date.now()
|
last = Date.now()
|
||||||
const output = coalesceServerEvents(events)
|
const output = coalesceServerEvents(events)
|
||||||
batch(() => {
|
batch(() => {
|
||||||
output.forEach((event) => emitter.emit(event.directory, event.payload))
|
output.forEach((event) => {
|
||||||
|
emitter.emit(event.type, event)
|
||||||
|
const directory = event.current?.location?.directory
|
||||||
|
if (directory) locations.emit(directory, event)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
buffer.length = 0
|
buffer.length = 0
|
||||||
@@ -165,8 +163,8 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||||||
}
|
}
|
||||||
|
|
||||||
function publish(event: OpenCodeEvent) {
|
function publish(event: OpenCodeEvent) {
|
||||||
const directory = event.location?.directory ?? "global"
|
queue.push(adaptServerEvent(event))
|
||||||
if (enqueueServerEvent(queue, { directory, payload: adaptServerEvent(event) })) schedule()
|
schedule()
|
||||||
}
|
}
|
||||||
|
|
||||||
function wait(delay: number, signal: AbortSignal) {
|
function wait(delay: number, signal: AbortSignal) {
|
||||||
@@ -313,6 +311,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||||||
queue = []
|
queue = []
|
||||||
buffer = []
|
buffer = []
|
||||||
emitter.clear()
|
emitter.clear()
|
||||||
|
locations.clear()
|
||||||
})
|
})
|
||||||
|
|
||||||
const api = createApiForServer({ server: server.http, fetch: platform.fetch })
|
const api = createApiForServer({ server: server.http, fetch: platform.fetch })
|
||||||
@@ -330,6 +329,9 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||||||
event: {
|
event: {
|
||||||
on: emitter.on.bind(emitter),
|
on: emitter.on.bind(emitter),
|
||||||
listen: emitter.listen.bind(emitter),
|
listen: emitter.listen.bind(emitter),
|
||||||
|
location: {
|
||||||
|
on: locations.on.bind(locations),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -365,7 +367,7 @@ export type DirectorySDK = {
|
|||||||
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): DirectorySDK {
|
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): DirectorySDK {
|
||||||
const emitter = createGlobalEmitter<SDKEventMap>()
|
const emitter = createGlobalEmitter<SDKEventMap>()
|
||||||
|
|
||||||
const unsub = serverSDK.event.on(directory, (event) => {
|
const unsub = serverSDK.event.location.on(directory, (event) => {
|
||||||
emitter.emit(event.type, event)
|
emitter.emit(event.type, event)
|
||||||
})
|
})
|
||||||
onCleanup(unsub)
|
onCleanup(unsub)
|
||||||
|
|||||||
@@ -6,6 +6,32 @@ const event = (input: object) => input as OpenCodeEvent
|
|||||||
const base = { created: 1, location: { directory: "/repo" }, durable: { aggregateID: "ses_1", seq: 1, version: 1 } }
|
const base = { created: 1, location: { directory: "/repo" }, durable: { aggregateID: "ses_1", seq: 1, version: 1 } }
|
||||||
|
|
||||||
describe("v2 session reducer", () => {
|
describe("v2 session reducer", () => {
|
||||||
|
test("moves a repeated inbox payload to the current event position", () => {
|
||||||
|
const reducer = createV2SessionReducer()
|
||||||
|
const result = reducer.reduce(
|
||||||
|
[
|
||||||
|
{ id: "msg_user", type: "user", text: "local", time: { created: 0 } },
|
||||||
|
{ id: "msg_agent", type: "agent-switched", agent: "review", time: { created: 1 } },
|
||||||
|
],
|
||||||
|
event({
|
||||||
|
...base,
|
||||||
|
id: "evt_admitted",
|
||||||
|
type: "session.inbox.enqueued",
|
||||||
|
data: {
|
||||||
|
sessionID: "ses_1",
|
||||||
|
inboxID: "msg_user",
|
||||||
|
item: { type: "user", delivery: "steer", payload: { text: "durable" } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result?.messages).toEqual([
|
||||||
|
{ id: "msg_agent", type: "agent-switched", agent: "review", time: { created: 1 } },
|
||||||
|
{ id: "msg_user", type: "user", text: "durable", time: { created: 1 } },
|
||||||
|
])
|
||||||
|
expect(result?.touched).toEqual(["msg_user"])
|
||||||
|
})
|
||||||
|
|
||||||
test("projects promoted input and streaming assistant content", () => {
|
test("projects promoted input and streaming assistant content", () => {
|
||||||
const reducer = createV2SessionReducer()
|
const reducer = createV2SessionReducer()
|
||||||
let messages: SessionMessageInfo[] = []
|
let messages: SessionMessageInfo[] = []
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import type { OpenCodeEvent, SessionInboxItem, SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
|
import type {
|
||||||
|
OpenCodeEvent,
|
||||||
|
SessionInboxInfo,
|
||||||
|
SessionInboxItem,
|
||||||
|
SessionInfo,
|
||||||
|
SessionMessageInfo,
|
||||||
|
} from "@opencode-ai/client/promise"
|
||||||
|
|
||||||
type Assistant = Extract<SessionMessageInfo, { type: "assistant" }>
|
type Assistant = Extract<SessionMessageInfo, { type: "assistant" }>
|
||||||
type Compaction = Extract<SessionMessageInfo, { type: "compaction" }>
|
type Compaction = Extract<SessionMessageInfo, { type: "compaction" }>
|
||||||
@@ -29,12 +35,14 @@ export function createV2SessionReducer() {
|
|||||||
})
|
})
|
||||||
const append = (message: SessionMessageInfo) =>
|
const append = (message: SessionMessageInfo) =>
|
||||||
result(source.some((item) => item.id === message.id) ? [...source] : [...source, message], [message.id])
|
result(source.some((item) => item.id === message.id) ? [...source] : [...source, message], [message.id])
|
||||||
|
const replace = (message: SessionMessageInfo) =>
|
||||||
|
result([...source.filter((item) => item.id !== message.id), message], [message.id])
|
||||||
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "session.inbox.enqueued":
|
case "session.inbox.enqueued":
|
||||||
pending.set(key(sessionID, event.data.inboxID), event.data.item)
|
pending.set(key(sessionID, event.data.inboxID), event.data.item)
|
||||||
if (event.data.item.type === "user")
|
if (event.data.item.type === "user")
|
||||||
return append({
|
return replace({
|
||||||
id: event.data.inboxID,
|
id: event.data.inboxID,
|
||||||
type: "user",
|
type: "user",
|
||||||
metadata: event.data.item.payload.metadata,
|
metadata: event.data.item.payload.metadata,
|
||||||
@@ -44,7 +52,7 @@ export function createV2SessionReducer() {
|
|||||||
time: { created: event.created },
|
time: { created: event.created },
|
||||||
})
|
})
|
||||||
if (event.data.item.type !== "synthetic") return result([...source])
|
if (event.data.item.type !== "synthetic") return result([...source])
|
||||||
return append({
|
return replace({
|
||||||
id: event.data.inboxID,
|
id: event.data.inboxID,
|
||||||
type: "synthetic",
|
type: "synthetic",
|
||||||
metadata: event.data.item.payload.metadata,
|
metadata: event.data.item.payload.metadata,
|
||||||
@@ -480,6 +488,9 @@ export function createV2SessionReducer() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
reduce,
|
reduce,
|
||||||
|
confirm(item: SessionInboxInfo) {
|
||||||
|
pending.set(key(item.sessionID, item.id), item)
|
||||||
|
},
|
||||||
clear(sessionID: string) {
|
clear(sessionID: string) {
|
||||||
for (const id of pending.keys()) {
|
for (const id of pending.keys()) {
|
||||||
if (id.startsWith(`${sessionID}:`)) pending.delete(id)
|
if (id.startsWith(`${sessionID}:`)) pending.delete(id)
|
||||||
|
|||||||
@@ -185,6 +185,16 @@ const textPart = (messageID: string, input: Partial<TextPart> = {}): TextPart =>
|
|||||||
id: `${messageID}:text:${input.id === "pending" ? 1 : 0}`,
|
id: `${messageID}:text:${input.id === "pending" ? 1 : 0}`,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const promptEcho = (messageID: string, text = "hello") => ({
|
||||||
|
sessionID: "child",
|
||||||
|
messageID,
|
||||||
|
text,
|
||||||
|
displayText: text,
|
||||||
|
agent: "build",
|
||||||
|
model: { providerID: "provider", modelID: "model" },
|
||||||
|
comments: [],
|
||||||
|
})
|
||||||
|
|
||||||
const response = (data: MessageResponse["data"] = [], cursor?: string): MessageResponse => ({
|
const response = (data: MessageResponse["data"] = [], cursor?: string): MessageResponse => ({
|
||||||
data,
|
data,
|
||||||
response: { headers: new Headers(cursor ? { "x-next-cursor": cursor } : undefined) },
|
response: { headers: new Headers(cursor ? { "x-next-cursor": cursor } : undefined) },
|
||||||
@@ -299,6 +309,26 @@ function setup(sessions: Record<string, SessionInfo>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("server session", () => {
|
describe("server session", () => {
|
||||||
|
test("hydrates session info after a native session.created event", async () => {
|
||||||
|
const ctx = setup({ created: session("created") })
|
||||||
|
|
||||||
|
ctx.store.apply({
|
||||||
|
type: "session.created",
|
||||||
|
properties: {
|
||||||
|
sessionID: "created",
|
||||||
|
projectID: "project",
|
||||||
|
location: { directory: "/repo" },
|
||||||
|
slug: "created",
|
||||||
|
version: "test",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(ctx.store.get("created")).toBeUndefined()
|
||||||
|
await ctx.store.resolve("created")
|
||||||
|
expect(ctx.store.get("created")?.location.directory).toBe("/repo")
|
||||||
|
expect(ctx.get).toEqual([{ sessionID: "created" }])
|
||||||
|
})
|
||||||
|
|
||||||
test("projects V2 session events into current and legacy message state", () => {
|
test("projects V2 session events into current and legacy message state", () => {
|
||||||
const ctx = setup({ child: session("child") })
|
const ctx = setup({ child: session("child") })
|
||||||
ctx.store.remember(session("child"))
|
ctx.store.remember(session("child"))
|
||||||
@@ -340,14 +370,38 @@ describe("server session", () => {
|
|||||||
location: { directory: "/repo" },
|
location: { directory: "/repo" },
|
||||||
data: { sessionID: "child", assistantMessageID: "msg_2_assistant", ordinal: 0, delta: "world" },
|
data: { sessionID: "child", assistantMessageID: "msg_2_assistant", ordinal: 0, delta: "world" },
|
||||||
})
|
})
|
||||||
|
apply({
|
||||||
|
id: "evt_tool_z",
|
||||||
|
created: 5,
|
||||||
|
type: "session.tool.input.started",
|
||||||
|
durable: { aggregateID: "child", seq: 3, version: 1 },
|
||||||
|
location: { directory: "/repo" },
|
||||||
|
data: { sessionID: "child", assistantMessageID: "msg_2_assistant", id: "call_z", name: "shell" },
|
||||||
|
})
|
||||||
|
apply({
|
||||||
|
id: "evt_tool_a",
|
||||||
|
created: 6,
|
||||||
|
type: "session.tool.input.started",
|
||||||
|
durable: { aggregateID: "child", seq: 4, version: 1 },
|
||||||
|
location: { directory: "/repo" },
|
||||||
|
data: { sessionID: "child", assistantMessageID: "msg_2_assistant", id: "call_a", name: "shell" },
|
||||||
|
})
|
||||||
|
|
||||||
expect(ctx.store.data.session_message.child?.at(-1)).toMatchObject({
|
expect(ctx.store.data.session_message.child?.at(-1)).toMatchObject({
|
||||||
id: "msg_2_assistant",
|
id: "msg_2_assistant",
|
||||||
type: "assistant",
|
type: "assistant",
|
||||||
content: [{ type: "text", text: "world" }],
|
content: [
|
||||||
|
{ type: "text", text: "world" },
|
||||||
|
{ type: "tool", id: "call_z" },
|
||||||
|
{ type: "tool", id: "call_a" },
|
||||||
|
],
|
||||||
})
|
})
|
||||||
expect(ctx.store.data.message.child?.map((message) => message.id)).toEqual(["msg_1_user", "msg_2_assistant"])
|
expect(ctx.store.data.message.child?.map((message) => message.id)).toEqual(["msg_1_user", "msg_2_assistant"])
|
||||||
expect(ctx.store.data.part.msg_2_assistant).toMatchObject([{ type: "text", text: "world" }])
|
expect(ctx.store.data.part.msg_2_assistant?.map((part) => part.id)).toEqual([
|
||||||
|
"msg_2_assistant:text:0",
|
||||||
|
"call_z",
|
||||||
|
"call_a",
|
||||||
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("projects V2 pending inputs and forms", () => {
|
test("projects V2 pending inputs and forms", () => {
|
||||||
@@ -564,7 +618,7 @@ describe("server session", () => {
|
|||||||
await ctx.store.sync("root")
|
await ctx.store.sync("root")
|
||||||
|
|
||||||
expect(ctx.get).toEqual([{ sessionID: "root" }])
|
expect(ctx.get).toEqual([{ sessionID: "root" }])
|
||||||
expect(ctx.messages).toEqual([{ sessionID: "root", limit: 20, order: "desc" }])
|
expect(ctx.messages).toEqual([{ sessionID: "root", limit: 200, order: "desc" }])
|
||||||
expect(ctx.store.data.message.root).toEqual([])
|
expect(ctx.store.data.message.root).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -575,8 +629,32 @@ describe("server session", () => {
|
|||||||
ctx.store.invalidate()
|
ctx.store.invalidate()
|
||||||
await ctx.store.sync("root")
|
await ctx.store.sync("root")
|
||||||
|
|
||||||
|
expect(ctx.store.data.message.root).toEqual([])
|
||||||
expect(ctx.get).toHaveLength(2)
|
expect(ctx.get).toHaveLength(2)
|
||||||
expect(ctx.messages).toHaveLength(2)
|
expect(ctx.messages).toEqual([
|
||||||
|
{ sessionID: "root", limit: 200, order: "desc" },
|
||||||
|
{ sessionID: "root", limit: 200, order: "desc" },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps a fixed page size after the local message cache exceeds the API limit", async () => {
|
||||||
|
const client = messageClient(response(), response())
|
||||||
|
const store = createServerSession(client)
|
||||||
|
await store.sync("child")
|
||||||
|
Array.from({ length: 428 }, (_, index) =>
|
||||||
|
store.apply({
|
||||||
|
type: "message.updated",
|
||||||
|
properties: { info: userMessage(`message-${index}`, { time: { created: index } }) },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(store.data.message.child).toHaveLength(428)
|
||||||
|
await store.sync("child", { force: true })
|
||||||
|
|
||||||
|
expect(client.requests).toEqual([
|
||||||
|
{ sessionID: "child", limit: 200, order: "desc" },
|
||||||
|
{ sessionID: "child", limit: 200, order: "desc" },
|
||||||
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("loads current session content through the current message API", async () => {
|
test("loads current session content through the current message API", async () => {
|
||||||
@@ -601,11 +679,50 @@ describe("server session", () => {
|
|||||||
|
|
||||||
await store.sync("root")
|
await store.sync("root")
|
||||||
|
|
||||||
expect(requests).toEqual([{ sessionID: "root", limit: 20, order: "desc" }])
|
expect(requests).toEqual([{ sessionID: "root", limit: 200, order: "desc" }])
|
||||||
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
|
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
|
||||||
expect(store.data.message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
|
expect(store.data.message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("preserves assistant content order from message history", async () => {
|
||||||
|
const source = [
|
||||||
|
{ id: "msg_user", type: "user", text: "inspect it", time: { created: 1 } },
|
||||||
|
{
|
||||||
|
id: "msg_assistant",
|
||||||
|
type: "assistant",
|
||||||
|
agent: "build",
|
||||||
|
model: { id: "model", providerID: "provider" },
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "I will inspect it." },
|
||||||
|
{
|
||||||
|
type: "tool",
|
||||||
|
id: "call_z",
|
||||||
|
name: "shell",
|
||||||
|
state: { status: "streaming", input: "" },
|
||||||
|
time: { created: 2 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "tool",
|
||||||
|
id: "call_a",
|
||||||
|
name: "shell",
|
||||||
|
state: { status: "streaming", input: "" },
|
||||||
|
time: { created: 3 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
time: { created: 2 },
|
||||||
|
},
|
||||||
|
] satisfies SessionMessageInfo[]
|
||||||
|
const messageApi = {
|
||||||
|
list: async () => ({ data: source.toReversed(), cursor: { previous: null, next: null } }),
|
||||||
|
} as unknown as MessageApi
|
||||||
|
const store = createServerSession({} as SessionApi, messageApi)
|
||||||
|
store.remember(session("root"))
|
||||||
|
|
||||||
|
await store.sync("root")
|
||||||
|
|
||||||
|
expect(store.data.part.msg_assistant?.map((part) => part.id)).toEqual(["msg_assistant:text:0", "call_z", "call_a"])
|
||||||
|
})
|
||||||
|
|
||||||
test("extends a current page to include the user for split assistant turns", async () => {
|
test("extends a current page to include the user for split assistant turns", async () => {
|
||||||
const user = { id: "msg_1_user", type: "user", text: "hello", time: { created: 1 } } as const
|
const user = { id: "msg_1_user", type: "user", text: "hello", time: { created: 1 } } as const
|
||||||
const assistant = (id: string, created: number) => ({
|
const assistant = (id: string, created: number) => ({
|
||||||
@@ -638,8 +755,8 @@ describe("server session", () => {
|
|||||||
await store.sync("root")
|
await store.sync("root")
|
||||||
|
|
||||||
expect(requests).toEqual([
|
expect(requests).toEqual([
|
||||||
{ sessionID: "root", limit: 20, order: "desc" },
|
{ sessionID: "root", limit: 200, order: "desc" },
|
||||||
{ sessionID: "root", limit: 20, cursor: "older" },
|
{ sessionID: "root", limit: 200, cursor: "older" },
|
||||||
])
|
])
|
||||||
expect(store.data.message.root.map((message) => message.id)).toEqual([
|
expect(store.data.message.root.map((message) => message.id)).toEqual([
|
||||||
user.id,
|
user.id,
|
||||||
@@ -648,6 +765,26 @@ describe("server session", () => {
|
|||||||
expect(assistants.map((item) => store.data.part[item.id]?.[0]?.type)).toEqual(["text", "text", "text"])
|
expect(assistants.map((item) => store.data.part[item.id]?.[0]?.type)).toEqual(["text", "text", "text"])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("loads older messages by cursor with the fixed page size", async () => {
|
||||||
|
const older = userMessage("message-1")
|
||||||
|
const latest = userMessage("message-2", { time: { created: 2 } })
|
||||||
|
const client = messageClient(
|
||||||
|
response([{ info: latest, parts: [] }], "older"),
|
||||||
|
response([{ info: older, parts: [] }]),
|
||||||
|
)
|
||||||
|
const store = createServerSession(client)
|
||||||
|
await store.sync("child")
|
||||||
|
|
||||||
|
await store.history.loadMore("child")
|
||||||
|
|
||||||
|
expect(client.requests).toEqual([
|
||||||
|
{ sessionID: "child", limit: 200, order: "desc" },
|
||||||
|
{ sessionID: "child", limit: 200, cursor: "older" },
|
||||||
|
])
|
||||||
|
expect(store.data.message.child).toEqual([older, latest])
|
||||||
|
expect(store.history.more("child")).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
// V2 messages are ordered projections and do not expose V1 assistant parent IDs.
|
// V2 messages are ordered projections and do not expose V1 assistant parent IDs.
|
||||||
describe.skip("V1 assistant parent projections", () => {
|
describe.skip("V1 assistant parent projections", () => {
|
||||||
test("backfills an assistant-only initial page through its user root", async () => {
|
test("backfills an assistant-only initial page through its user root", async () => {
|
||||||
@@ -661,7 +798,7 @@ describe("server session", () => {
|
|||||||
|
|
||||||
await store.sync("child")
|
await store.sync("child")
|
||||||
|
|
||||||
expect(client.requests).toEqual([{ sessionID: "child", limit: 20, order: "desc" }])
|
expect(client.requests).toEqual([{ sessionID: "child", limit: 200, order: "desc" }])
|
||||||
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }])
|
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }])
|
||||||
expect(store.data.message.child).toEqual([user, ...assistants])
|
expect(store.data.message.child).toEqual([user, ...assistants])
|
||||||
expect(store.history.more("child")).toBe(false)
|
expect(store.history.more("child")).toBe(false)
|
||||||
@@ -710,19 +847,17 @@ describe("server session", () => {
|
|||||||
expect(store.data.part[parent.id]).toBeUndefined()
|
expect(store.data.part[parent.id]).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("does not let an optimistic user suppress initial root backfill", async () => {
|
test("does not let an admitted user suppress initial root backfill", async () => {
|
||||||
const user = userMessage("message-1")
|
const user = userMessage("message-1")
|
||||||
const part = textPart(user.id)
|
|
||||||
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
|
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
|
||||||
const client = rootMessageClient(
|
const client = rootMessageClient(
|
||||||
[response(assistants.map((info) => ({ info, parts: [] })))],
|
[response(assistants.map((info) => ({ info, parts: [] })))],
|
||||||
[singleResponse(user)],
|
[singleResponse(user)],
|
||||||
)
|
)
|
||||||
const store = createServerSession(client)
|
const store = createServerSession(client)
|
||||||
store.optimistic.add({ sessionID: "child", message: user, parts: [part] })
|
store.inbox.echo(promptEcho(user.id, "text"))
|
||||||
|
|
||||||
await store.sync("child")
|
await store.sync("child")
|
||||||
store.optimistic.remove({ sessionID: "child", messageID: user.id })
|
|
||||||
|
|
||||||
expect(client.requests).toHaveLength(1)
|
expect(client.requests).toHaveLength(1)
|
||||||
expect(client.rootRequests).toHaveLength(1)
|
expect(client.rootRequests).toHaveLength(1)
|
||||||
@@ -783,28 +918,6 @@ describe("server session", () => {
|
|||||||
expect(store.data.part[stale.id]).toEqual([freshPart])
|
expect(store.data.part[stale.id]).toEqual([freshPart])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("refreshes a confirmed optimistic parent while preserving pending parts", async () => {
|
|
||||||
const stale = userMessage("message-1", { summary: { title: "stale", diffs: [] } })
|
|
||||||
const fresh = { ...stale, summary: { title: "fresh", diffs: [] } }
|
|
||||||
const confirmed = textPart(stale.id, { id: "confirmed", text: "stale" })
|
|
||||||
const refreshed = { ...confirmed, text: "fresh" }
|
|
||||||
const pending = textPart(stale.id, { id: "pending", text: "pending" })
|
|
||||||
const assistant = assistantMessage("message-2", stale.id)
|
|
||||||
const client = rootMessageClient(
|
|
||||||
[response([{ info: stale, parts: [confirmed] }]), response([{ info: assistant, parts: [] }])],
|
|
||||||
[singleResponse(fresh, [refreshed])],
|
|
||||||
)
|
|
||||||
const store = createServerSession(client)
|
|
||||||
store.optimistic.add({ sessionID: "child", message: stale, parts: [confirmed, pending] })
|
|
||||||
await store.sync("child")
|
|
||||||
|
|
||||||
await store.sync("child", { force: true })
|
|
||||||
|
|
||||||
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }])
|
|
||||||
expect(store.data.message.child).toEqual([fresh, assistant])
|
|
||||||
expect(store.data.part[stale.id]).toEqual([refreshed, pending])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("uses a parent received by SSE during the replacement load", async () => {
|
test("uses a parent received by SSE during the replacement load", async () => {
|
||||||
const pending = deferredResponse()
|
const pending = deferredResponse()
|
||||||
const user = userMessage("message-1")
|
const user = userMessage("message-1")
|
||||||
@@ -1040,30 +1153,6 @@ describe("server session", () => {
|
|||||||
expect(store.data.part[message.id]).toBeUndefined()
|
expect(store.data.part[message.id]).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("preserves optimistic parts re-added after removal during a refresh", async () => {
|
|
||||||
const pending = deferredResponse()
|
|
||||||
const message = userMessage("message")
|
|
||||||
const stale = textPart(message.id, { id: "stale", text: "stale" })
|
|
||||||
const part = textPart(message.id, { id: "optimistic", text: "optimistic" })
|
|
||||||
const store = createServerSession(
|
|
||||||
messageClient(response([{ info: message, parts: [] }]), pending.promise, response()),
|
|
||||||
)
|
|
||||||
await store.sync("child")
|
|
||||||
const refreshing = store.sync("child", { force: true })
|
|
||||||
|
|
||||||
store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
|
|
||||||
store.optimistic.add({ sessionID: "child", message, parts: [part] })
|
|
||||||
pending.resolve(response([{ info: message, parts: [stale] }]))
|
|
||||||
await refreshing
|
|
||||||
|
|
||||||
expect(store.data.message.child).toEqual([message])
|
|
||||||
expect(store.data.part[message.id]).toEqual([part])
|
|
||||||
|
|
||||||
await store.sync("child", { force: true })
|
|
||||||
expect(store.data.message.child).toEqual([message])
|
|
||||||
expect(store.data.part[message.id]).toEqual([part])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("drops stale event content omitted by a complete initial page", async () => {
|
test("drops stale event content omitted by a complete initial page", async () => {
|
||||||
const stale = userMessage("stale")
|
const stale = userMessage("stale")
|
||||||
const store = createServerSession(messageClient(response()))
|
const store = createServerSession(messageClient(response()))
|
||||||
@@ -1085,170 +1174,309 @@ describe("server session", () => {
|
|||||||
expect(store.data.message.child).toEqual([live, fetched])
|
expect(store.data.message.child).toEqual([live, fetched])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("does not restore removed optimistic content on refresh", async () => {
|
test("echoes a prompt without changing durable message order", () => {
|
||||||
const message = userMessage("message")
|
|
||||||
const part = textPart(message.id, { text: "removed" })
|
|
||||||
const kept = { ...message, id: "kept" }
|
|
||||||
const keptPart = { ...part, id: "kept-part", messageID: kept.id }
|
|
||||||
const store = createServerSession(messageClient(response([{ info: kept, parts: [] }])))
|
|
||||||
store.optimistic.add({ sessionID: "child", message, parts: [part] })
|
|
||||||
store.optimistic.add({ sessionID: "child", message: kept, parts: [keptPart] })
|
|
||||||
|
|
||||||
store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
|
|
||||||
store.apply({
|
|
||||||
type: "message.part.removed",
|
|
||||||
properties: { sessionID: "child", messageID: kept.id, partID: keptPart.id },
|
|
||||||
})
|
|
||||||
await store.sync("child", { force: true })
|
|
||||||
|
|
||||||
expect(store.data.message.child).toEqual([kept])
|
|
||||||
expect(store.data.part[message.id]).toBeUndefined()
|
|
||||||
expect(store.data.part[kept.id]).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("replaces confirmed optimistic content with the initial page", async () => {
|
|
||||||
const optimistic = userMessage("message")
|
|
||||||
const fetched = { ...optimistic, time: { created: 2 } }
|
|
||||||
const store = createServerSession(messageClient(response([{ info: fetched, parts: [] }])))
|
|
||||||
store.optimistic.add({ sessionID: "child", message: optimistic, parts: [] })
|
|
||||||
|
|
||||||
await store.sync("child")
|
|
||||||
|
|
||||||
expect(store.data.message.child).toEqual([fetched])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("replaces a confirmed optimistic part with fetched content", async () => {
|
|
||||||
const pending = deferredResponse()
|
|
||||||
const message = userMessage("message")
|
|
||||||
const optimistic = textPart(message.id, { text: "optimistic" })
|
|
||||||
const fetched = { ...optimistic, text: "fetched" }
|
|
||||||
const store = createServerSession(messageClient(pending.promise))
|
|
||||||
const loading = store.sync("child")
|
|
||||||
|
|
||||||
store.optimistic.add({ sessionID: "child", message, parts: [optimistic] })
|
|
||||||
pending.resolve(response([{ info: message, parts: [fetched] }]))
|
|
||||||
await loading
|
|
||||||
|
|
||||||
expect(store.data.part[message.id]).toEqual([fetched])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("rolls back only unconfirmed optimistic parts", async () => {
|
|
||||||
const pending = deferredResponse()
|
|
||||||
const message = userMessage("message")
|
|
||||||
const confirmed = textPart(message.id, { id: "confirmed", text: "confirmed" })
|
|
||||||
const pendingPart = textPart(message.id, { id: "pending", text: "pending" })
|
|
||||||
const store = createServerSession(messageClient(pending.promise))
|
|
||||||
const loading = store.sync("child")
|
|
||||||
store.optimistic.add({ sessionID: "child", message, parts: [confirmed, pendingPart] })
|
|
||||||
|
|
||||||
pending.resolve(response([{ info: message, parts: [confirmed] }]))
|
|
||||||
await loading
|
|
||||||
store.optimistic.remove({ sessionID: "child", messageID: message.id })
|
|
||||||
|
|
||||||
expect(store.data.message.child).toEqual([message])
|
|
||||||
expect(store.data.part[message.id]).toEqual([confirmed])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("updates confirmed optimistic parts from later pages", async () => {
|
|
||||||
const message = userMessage("message")
|
|
||||||
const confirmed = textPart(message.id, { id: "confirmed", text: "first" })
|
|
||||||
const updated = { ...confirmed, text: "updated" }
|
|
||||||
const pendingPart = textPart(message.id, { id: "pending", text: "pending" })
|
|
||||||
const store = createServerSession(
|
|
||||||
messageClient(response([{ info: message, parts: [confirmed] }]), response([{ info: message, parts: [updated] }])),
|
|
||||||
)
|
|
||||||
store.optimistic.add({ sessionID: "child", message, parts: [confirmed, pendingPart] })
|
|
||||||
await store.sync("child")
|
|
||||||
|
|
||||||
await store.sync("child", { force: true })
|
|
||||||
store.optimistic.remove({ sessionID: "child", messageID: message.id })
|
|
||||||
|
|
||||||
expect(store.data.part[message.id]).toEqual([updated])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("does not restore a confirmed optimistic part after its removal event", async () => {
|
|
||||||
const message = userMessage("message")
|
|
||||||
const confirmed = textPart(message.id, { id: "confirmed", text: "confirmed" })
|
|
||||||
const pendingPart = textPart(message.id, { id: "pending", text: "pending" })
|
|
||||||
const store = createServerSession(
|
|
||||||
messageClient(response([{ info: message, parts: [confirmed] }]), response([{ info: message, parts: [] }])),
|
|
||||||
)
|
|
||||||
store.optimistic.add({ sessionID: "child", message, parts: [confirmed, pendingPart] })
|
|
||||||
await store.sync("child")
|
|
||||||
store.apply({
|
|
||||||
type: "message.part.removed",
|
|
||||||
properties: { sessionID: "child", messageID: message.id, partID: confirmed.id },
|
|
||||||
})
|
|
||||||
|
|
||||||
await store.sync("child", { force: true })
|
|
||||||
|
|
||||||
expect(store.data.part[message.id]).toEqual([pendingPart])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("clears delta buffers when removing optimistic content", () => {
|
|
||||||
const message = userMessage("message")
|
|
||||||
const part = textPart(message.id, { text: "optimistic" })
|
|
||||||
const store = setup({ child: session("child") }).store
|
const store = setup({ child: session("child") }).store
|
||||||
store.optimistic.add({ sessionID: "child", message, parts: [part] })
|
|
||||||
store.apply({
|
store.inbox.echo({
|
||||||
type: "message.part.delta",
|
...promptEcho("msg_prompt"),
|
||||||
properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: " delta" },
|
text: "hello\nThe user made the following comment regarding line 4 of src/foo.ts: check this",
|
||||||
|
files: [{ uri: "file:///repo/src/foo.ts", mime: "text/plain", name: "foo.ts" }],
|
||||||
|
agents: [{ name: "explore" }],
|
||||||
|
comments: [
|
||||||
|
{
|
||||||
|
path: "src/foo.ts",
|
||||||
|
selection: { startLine: 4, startChar: 1, endLine: 4, endChar: 5 },
|
||||||
|
comment: "check this",
|
||||||
|
preview: "const value = 1",
|
||||||
|
origin: "review",
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
store.optimistic.remove({ sessionID: "child", messageID: message.id })
|
expect(store.data.pending.child).toMatchObject([{ id: "msg_prompt", type: "user", delivery: "steer" }])
|
||||||
|
expect(store.data.input.child).toEqual(["msg_prompt"])
|
||||||
|
expect(store.data.session_message.child).toBeUndefined()
|
||||||
|
expect(store.data.message.child?.map((message) => message.id)).toEqual(["msg_prompt"])
|
||||||
|
expect(store.data.part.msg_prompt).toMatchObject([
|
||||||
|
{ id: "msg_prompt:text:0", type: "text", text: "hello" },
|
||||||
|
{ id: "msg_prompt:file:0", type: "file", filename: "foo.ts" },
|
||||||
|
{ id: "msg_prompt:agent:0", type: "agent", name: "explore" },
|
||||||
|
{
|
||||||
|
id: "msg_prompt:comment:0",
|
||||||
|
type: "text",
|
||||||
|
synthetic: true,
|
||||||
|
metadata: {
|
||||||
|
opencodeComment: {
|
||||||
|
path: "src/foo.ts",
|
||||||
|
selection: { startLine: 4, startChar: 1, endLine: 4, endChar: 5 },
|
||||||
|
comment: "check this",
|
||||||
|
preview: "const value = 1",
|
||||||
|
origin: "review",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
expect(store.data.part[message.id]).toBeUndefined()
|
store.applyV2({
|
||||||
expect(store.data.part_text_accum_delta[part.id]).toBeUndefined()
|
id: "evt_prompt",
|
||||||
|
created: 2,
|
||||||
|
type: "session.inbox.enqueued",
|
||||||
|
durable: { aggregateID: "child", seq: 1, version: 1 },
|
||||||
|
data: {
|
||||||
|
sessionID: "child",
|
||||||
|
inboxID: "msg_prompt",
|
||||||
|
item: {
|
||||||
|
type: "user",
|
||||||
|
delivery: "steer",
|
||||||
|
payload: {
|
||||||
|
text: "hello\nThe user made the following comment regarding line 4 of src/foo.ts: check this",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as OpenCodeEvent)
|
||||||
|
|
||||||
|
expect(store.data.part.msg_prompt).toMatchObject([
|
||||||
|
{ id: "msg_prompt:text:0", type: "text", text: "hello" },
|
||||||
|
{ id: "msg_prompt:comment:0", type: "text", synthetic: true },
|
||||||
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("removes projected messages when rolling back optimistic content", () => {
|
test("preserves a local echo while message history omits pending input", async () => {
|
||||||
const message = userMessage("message")
|
const store = createServerSession(messageClient(response()))
|
||||||
const store = setup({ child: session("child") }).store
|
store.inbox.echo(promptEcho("msg_prompt"))
|
||||||
store.optimistic.add({ sessionID: "child", message, parts: [] })
|
store.inbox.confirm({
|
||||||
|
id: "msg_prompt",
|
||||||
|
sessionID: "child",
|
||||||
|
timeCreated: 1,
|
||||||
|
type: "user",
|
||||||
|
delivery: "steer",
|
||||||
|
payload: { text: "hello" },
|
||||||
|
})
|
||||||
|
|
||||||
store.optimistic.remove({ sessionID: "child", messageID: message.id })
|
await store.sync("child")
|
||||||
|
|
||||||
|
expect(store.data.message.child?.map((message) => message.id)).toEqual(["msg_prompt"])
|
||||||
|
expect(store.data.part.msg_prompt).toMatchObject([{ type: "text", text: "hello" }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves local comment presentation through message refresh", async () => {
|
||||||
|
const note = "The user made the following comment regarding line 4 of src/foo.ts: check this"
|
||||||
|
const message = userMessage("msg_prompt")
|
||||||
|
const store = createServerSession(
|
||||||
|
messageClient(response([{ info: message, parts: [textPart(message.id, { text: note })] }])),
|
||||||
|
)
|
||||||
|
store.inbox.echo({
|
||||||
|
...promptEcho(message.id),
|
||||||
|
text: `hello\n${note}`,
|
||||||
|
comments: [
|
||||||
|
{
|
||||||
|
path: "src/foo.ts",
|
||||||
|
selection: { startLine: 4, startChar: 1, endLine: 4, endChar: 5 },
|
||||||
|
comment: "check this",
|
||||||
|
origin: "review",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
await store.sync("child")
|
||||||
|
|
||||||
|
expect(store.data.part.msg_prompt).toMatchObject([
|
||||||
|
{ id: "msg_prompt:text:0", type: "text", text: "hello" },
|
||||||
|
{ id: "msg_prompt:comment:0", type: "text", synthetic: true },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("retires an admitted echo absent from authoritative reconnect state", async () => {
|
||||||
|
const store = createServerSession(messageClient(response()))
|
||||||
|
store.inbox.echo(promptEcho("msg_prompt"))
|
||||||
|
store.inbox.confirm({
|
||||||
|
id: "msg_prompt",
|
||||||
|
sessionID: "child",
|
||||||
|
timeCreated: 1,
|
||||||
|
type: "user",
|
||||||
|
delivery: "steer",
|
||||||
|
payload: { text: "hello" },
|
||||||
|
})
|
||||||
|
|
||||||
|
await Promise.all([store.sync("child"), store.hydrateTransient("child", async () => ({ pending: [], forms: [] }))])
|
||||||
|
store.inbox.reconcile("child")
|
||||||
|
|
||||||
|
expect(store.data.pending.child).toEqual([])
|
||||||
|
expect(store.data.message.child).toEqual([])
|
||||||
|
expect(store.data.part.msg_prompt).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("retires a stale enqueued message when inbox hydration finishes after history", async () => {
|
||||||
|
const store = createServerSession(messageClient(response()))
|
||||||
|
store.applyV2({
|
||||||
|
id: "evt_prompt",
|
||||||
|
created: 1,
|
||||||
|
type: "session.inbox.enqueued",
|
||||||
|
durable: { aggregateID: "child", seq: 1, version: 1 },
|
||||||
|
data: {
|
||||||
|
sessionID: "child",
|
||||||
|
inboxID: "msg_prompt",
|
||||||
|
item: { type: "user", delivery: "steer", payload: { text: "hello" } },
|
||||||
|
},
|
||||||
|
} as OpenCodeEvent)
|
||||||
|
|
||||||
|
await store.sync("child")
|
||||||
|
await store.hydrateTransient("child", async () => ({ pending: [], forms: [] }))
|
||||||
|
store.inbox.reconcile("child")
|
||||||
|
|
||||||
|
expect(store.data.pending.child).toEqual([])
|
||||||
expect(store.data.session_message.child).toEqual([])
|
expect(store.data.session_message.child).toEqual([])
|
||||||
|
expect(store.data.message.child).toEqual([])
|
||||||
|
expect(store.data.part.msg_prompt).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("does not remove content confirmed by a message event", () => {
|
test("deduplicates the durable admission event against its local echo", () => {
|
||||||
const message = userMessage("message")
|
|
||||||
const part = textPart(message.id)
|
|
||||||
const store = setup({ child: session("child") }).store
|
const store = setup({ child: session("child") }).store
|
||||||
store.optimistic.add({ sessionID: "child", message, parts: [part] })
|
store.inbox.echo(promptEcho("msg_prompt"))
|
||||||
store.apply({ type: "message.updated", properties: { sessionID: "child", info: message } })
|
|
||||||
|
|
||||||
store.optimistic.remove({ sessionID: "child", messageID: message.id })
|
store.applyV2({
|
||||||
|
id: "evt_prompt",
|
||||||
|
created: 2,
|
||||||
|
type: "session.inbox.enqueued",
|
||||||
|
durable: { aggregateID: "child", seq: 1, version: 1 },
|
||||||
|
data: {
|
||||||
|
sessionID: "child",
|
||||||
|
inboxID: "msg_prompt",
|
||||||
|
item: { type: "user", delivery: "steer", payload: { text: "hello" } },
|
||||||
|
},
|
||||||
|
} as OpenCodeEvent)
|
||||||
|
|
||||||
expect(store.data.message.child).toEqual([message])
|
expect(store.data.pending.child).toHaveLength(1)
|
||||||
expect(store.data.part[message.id]).toBeUndefined()
|
expect(store.data.input.child).toEqual(["msg_prompt"])
|
||||||
|
expect(store.data.session_message.child?.filter((message) => message.id === "msg_prompt")).toHaveLength(1)
|
||||||
|
expect(store.data.message.child?.filter((message) => message.id === "msg_prompt")).toHaveLength(1)
|
||||||
|
expect(store.data.part.msg_prompt).toMatchObject([{ type: "text", text: "hello" }])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("does not remove parts confirmed by part events", () => {
|
test("uses the prompt response when the admission event was missed", () => {
|
||||||
const message = userMessage("message")
|
|
||||||
const part = textPart(message.id)
|
|
||||||
const store = setup({ child: session("child") }).store
|
const store = setup({ child: session("child") }).store
|
||||||
store.optimistic.add({ sessionID: "child", message, parts: [part] })
|
store.inbox.echo(promptEcho("msg_prompt"))
|
||||||
store.apply({ type: "message.updated", properties: { sessionID: "child", info: message } })
|
store.inbox.confirm({
|
||||||
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } })
|
id: "msg_prompt",
|
||||||
|
sessionID: "child",
|
||||||
|
timeCreated: 2,
|
||||||
|
type: "user",
|
||||||
|
delivery: "steer",
|
||||||
|
payload: { text: "hello" },
|
||||||
|
})
|
||||||
|
|
||||||
store.optimistic.remove({ sessionID: "child", messageID: message.id })
|
store.applyV2({
|
||||||
|
id: "evt_delivered",
|
||||||
|
created: Date.now() + 1,
|
||||||
|
type: "session.inbox.delivered",
|
||||||
|
durable: { aggregateID: "child", seq: 2, version: 1 },
|
||||||
|
data: { sessionID: "child", inboxID: "msg_prompt" },
|
||||||
|
} as OpenCodeEvent)
|
||||||
|
|
||||||
expect(store.data.message.child).toEqual([message])
|
expect(store.data.pending.child).toEqual([])
|
||||||
expect(store.data.part[message.id]).toEqual([part])
|
expect(store.data.input.child).toEqual([])
|
||||||
|
expect(store.data.session_message.child).toMatchObject([{ id: "msg_prompt", type: "user", text: "hello" }])
|
||||||
|
expect(store.data.message.child?.filter((message) => message.id === "msg_prompt")).toHaveLength(1)
|
||||||
|
expect(store.data.part.msg_prompt).toMatchObject([{ type: "text", text: "hello" }])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("treats a part event as confirmation when it precedes the message event", () => {
|
test("keeps a durable admission when the HTTP request later fails", () => {
|
||||||
const message = userMessage("message")
|
|
||||||
const part = textPart(message.id)
|
|
||||||
const store = setup({ child: session("child") }).store
|
const store = setup({ child: session("child") }).store
|
||||||
store.optimistic.add({ sessionID: "child", message, parts: [part] })
|
store.inbox.echo(promptEcho("msg_prompt"))
|
||||||
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } })
|
store.applyV2({
|
||||||
|
id: "evt_prompt",
|
||||||
|
created: 2,
|
||||||
|
type: "session.inbox.enqueued",
|
||||||
|
durable: { aggregateID: "child", seq: 1, version: 1 },
|
||||||
|
data: {
|
||||||
|
sessionID: "child",
|
||||||
|
inboxID: "msg_prompt",
|
||||||
|
item: { type: "user", delivery: "steer", payload: { text: "hello" } },
|
||||||
|
},
|
||||||
|
} as OpenCodeEvent)
|
||||||
|
|
||||||
store.optimistic.remove({ sessionID: "child", messageID: message.id })
|
expect(store.inbox.clearEcho({ sessionID: "child", messageID: "msg_prompt" })).toBe(false)
|
||||||
|
expect(store.data.pending.child).toHaveLength(1)
|
||||||
|
expect(store.data.message.child?.map((message) => message.id)).toEqual(["msg_prompt"])
|
||||||
|
})
|
||||||
|
|
||||||
expect(store.data.message.child).toEqual([message])
|
test("places durable admission after delayed selection events", () => {
|
||||||
expect(store.data.part[message.id]).toEqual([part])
|
const store = setup({ child: session("child") }).store
|
||||||
|
store.remember(session("child"))
|
||||||
|
store.inbox.echo(promptEcho("msg_prompt"))
|
||||||
|
store.applyV2({
|
||||||
|
id: "evt_agent",
|
||||||
|
created: 1,
|
||||||
|
type: "session.agent.selected",
|
||||||
|
durable: { aggregateID: "child", seq: 1, version: 1 },
|
||||||
|
data: { sessionID: "child", agent: "review" },
|
||||||
|
} as OpenCodeEvent)
|
||||||
|
store.applyV2({
|
||||||
|
id: "evt_model",
|
||||||
|
created: 2,
|
||||||
|
type: "session.model.selected",
|
||||||
|
durable: { aggregateID: "child", seq: 2, version: 1 },
|
||||||
|
data: { sessionID: "child", model: { id: "new-model", providerID: "new-provider" } },
|
||||||
|
} as OpenCodeEvent)
|
||||||
|
store.applyV2({
|
||||||
|
id: "evt_prompt",
|
||||||
|
created: 3,
|
||||||
|
type: "session.inbox.enqueued",
|
||||||
|
durable: { aggregateID: "child", seq: 3, version: 1 },
|
||||||
|
data: {
|
||||||
|
sessionID: "child",
|
||||||
|
inboxID: "msg_prompt",
|
||||||
|
item: { type: "user", delivery: "steer", payload: { text: "hello" } },
|
||||||
|
},
|
||||||
|
} as OpenCodeEvent)
|
||||||
|
|
||||||
|
expect(store.data.session_message.child?.map((message) => message.type)).toEqual([
|
||||||
|
"agent-switched",
|
||||||
|
"model-switched",
|
||||||
|
"user",
|
||||||
|
])
|
||||||
|
expect(store.data.message.child?.find((message) => message.id === "msg_prompt")).toMatchObject({
|
||||||
|
agent: "review",
|
||||||
|
model: { providerID: "new-provider", modelID: "new-model" },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("removes an echoed prompt when submission fails", () => {
|
||||||
|
const store = setup({ child: session("child") }).store
|
||||||
|
store.inbox.echo(promptEcho("msg_prompt"))
|
||||||
|
|
||||||
|
expect(store.inbox.clearEcho({ sessionID: "child", messageID: "msg_prompt" })).toBe(true)
|
||||||
|
|
||||||
|
expect(store.data.pending.child).toEqual([])
|
||||||
|
expect(store.data.input.child).toEqual([])
|
||||||
|
expect(store.data.session_message.child).toBeUndefined()
|
||||||
|
expect(store.data.message.child).toEqual([])
|
||||||
|
expect(store.data.part.msg_prompt).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("removes a response-confirmed echo when the server cancels it", () => {
|
||||||
|
const store = setup({ child: session("child") }).store
|
||||||
|
store.inbox.echo(promptEcho("msg_prompt"))
|
||||||
|
store.inbox.confirm({
|
||||||
|
id: "msg_prompt",
|
||||||
|
sessionID: "child",
|
||||||
|
timeCreated: 1,
|
||||||
|
type: "user",
|
||||||
|
delivery: "steer",
|
||||||
|
payload: { text: "hello" },
|
||||||
|
})
|
||||||
|
|
||||||
|
store.applyV2({
|
||||||
|
id: "evt_cancelled",
|
||||||
|
created: 2,
|
||||||
|
type: "session.inbox.cancelled",
|
||||||
|
durable: { aggregateID: "child", seq: 2, version: 1 },
|
||||||
|
data: { sessionID: "child", inboxID: "msg_prompt" },
|
||||||
|
} as OpenCodeEvent)
|
||||||
|
|
||||||
|
expect(store.data.pending.child).toEqual([])
|
||||||
|
expect(store.data.message.child).toEqual([])
|
||||||
|
expect(store.data.part.msg_prompt).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("clears stale parts when the initial page has none", async () => {
|
test("clears stale parts when the initial page has none", async () => {
|
||||||
@@ -1469,28 +1697,6 @@ describe("server session", () => {
|
|||||||
expect(store.data.part[message.id]).toBeUndefined()
|
expect(store.data.part[message.id]).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("preserves optimistic re-adds across message retries", async () => {
|
|
||||||
const failed = Promise.withResolvers<MessageResponse>()
|
|
||||||
const retried = Promise.withResolvers<MessageResponse>()
|
|
||||||
const message = userMessage("message")
|
|
||||||
const stale = textPart(message.id, { id: "stale", text: "stale" })
|
|
||||||
const optimistic = textPart(message.id, { id: "optimistic", text: "optimistic" })
|
|
||||||
const client = messageClient(response([{ info: message, parts: [stale] }]), failed.promise, retried.promise)
|
|
||||||
const store = createServerSession(client, { retry: retryImmediately })
|
|
||||||
await store.sync("child")
|
|
||||||
const loading = store.sync("child", { force: true })
|
|
||||||
|
|
||||||
store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
|
|
||||||
store.optimistic.add({ sessionID: "child", message, parts: [optimistic] })
|
|
||||||
failed.reject(new Error("failed to fetch"))
|
|
||||||
await client.requested(3)
|
|
||||||
retried.resolve(response([{ info: message, parts: [stale] }]))
|
|
||||||
await loading
|
|
||||||
|
|
||||||
expect(store.data.message.child).toEqual([message])
|
|
||||||
expect(store.data.part[message.id]).toEqual([optimistic])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("accepts part omission from a successful retry after an earlier delta", async () => {
|
test("accepts part omission from a successful retry after an earlier delta", async () => {
|
||||||
const failed = Promise.withResolvers<MessageResponse>()
|
const failed = Promise.withResolvers<MessageResponse>()
|
||||||
const retried = Promise.withResolvers<MessageResponse>()
|
const retried = Promise.withResolvers<MessageResponse>()
|
||||||
@@ -1654,33 +1860,6 @@ describe("server session", () => {
|
|||||||
expect(store.data.part[message.id]).toBeUndefined()
|
expect(store.data.part[message.id]).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("does not cache skipped optimistic parts", () => {
|
|
||||||
const message = userMessage("message")
|
|
||||||
const part = { id: "part", sessionID: "child", messageID: message.id, type: "step-start" as const }
|
|
||||||
const store = setup({ child: session("child") }).store
|
|
||||||
|
|
||||||
store.optimistic.add({ sessionID: "child", message, parts: [part] })
|
|
||||||
|
|
||||||
expect(store.data.part[message.id]).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("clears stale delta buffers when replacing optimistic parts", () => {
|
|
||||||
const message = userMessage("message")
|
|
||||||
const stale = textPart(message.id, { id: "stale", text: "stale" })
|
|
||||||
const optimistic = textPart(message.id, { id: "optimistic", text: "optimistic" })
|
|
||||||
const store = setup({ child: session("child") }).store
|
|
||||||
store.optimistic.add({ sessionID: "child", message, parts: [stale] })
|
|
||||||
store.apply({
|
|
||||||
type: "message.part.delta",
|
|
||||||
properties: { sessionID: "child", messageID: message.id, partID: stale.id, field: "text", delta: " delta" },
|
|
||||||
})
|
|
||||||
|
|
||||||
store.optimistic.add({ sessionID: "child", message, parts: [optimistic] })
|
|
||||||
|
|
||||||
expect(store.data.part_text_accum_delta[stale.id]).toBeUndefined()
|
|
||||||
expect(store.data.part_text_accum_delta[optimistic.id]).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("preserves removals during history prepend", async () => {
|
test("preserves removals during history prepend", async () => {
|
||||||
const pending = deferredResponse()
|
const pending = deferredResponse()
|
||||||
const latest = userMessage("message-2", { time: { created: 2 } })
|
const latest = userMessage("message-2", { time: { created: 2 } })
|
||||||
@@ -1906,24 +2085,7 @@ describe("server session", () => {
|
|||||||
test("preserves pinned session content under server-wide cache pressure", () => {
|
test("preserves pinned session content under server-wide cache pressure", () => {
|
||||||
const ctx = setup({})
|
const ctx = setup({})
|
||||||
ctx.store.pin("active")
|
ctx.store.pin("active")
|
||||||
ctx.store.optimistic.add({
|
ctx.store.inbox.echo({ ...promptEcho("message", "keep"), sessionID: "active" })
|
||||||
sessionID: "active",
|
|
||||||
message: {
|
|
||||||
id: "message",
|
|
||||||
sessionID: "active",
|
|
||||||
role: "assistant",
|
|
||||||
time: { created: 1 },
|
|
||||||
parentID: "parent",
|
|
||||||
modelID: "model",
|
|
||||||
providerID: "provider",
|
|
||||||
mode: "build",
|
|
||||||
agent: "agent",
|
|
||||||
path: { cwd: "/repo", root: "/repo" },
|
|
||||||
cost: 0,
|
|
||||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
||||||
},
|
|
||||||
parts: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
for (let index = 0; index < 50; index++) {
|
for (let index = 0; index < 50; index++) {
|
||||||
ctx.store.remember(session(`session-${index}`))
|
ctx.store.remember(session(`session-${index}`))
|
||||||
|
|||||||
@@ -18,41 +18,22 @@ import { compareMessages, messageKey, normalizeSessionMessages } from "@/utils/s
|
|||||||
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
|
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
|
||||||
import { createV2SessionReducer, type V2SessionReduction } from "./server-session-v2-reducer"
|
import { createV2SessionReducer, type V2SessionReduction } from "./server-session-v2-reducer"
|
||||||
import type { ServerApi } from "@/utils/server"
|
import type { ServerApi } from "@/utils/server"
|
||||||
|
import {
|
||||||
|
createCommentMetadata,
|
||||||
|
formatCommentNote,
|
||||||
|
parseCommentNote,
|
||||||
|
readCommentMetadata,
|
||||||
|
type PromptComment,
|
||||||
|
} from "@/utils/comment-note"
|
||||||
|
|
||||||
type MessageApi = ServerApi["message"]
|
type MessageApi = ServerApi["message"]
|
||||||
|
|
||||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||||
const initialMessagePageSize = 20
|
const messagePageSize = 200
|
||||||
const historyMessagePageSize = 200
|
|
||||||
const sessionInfoLimit = 2_048
|
const sessionInfoLimit = 2_048
|
||||||
const emptyIDs: ReadonlySet<string> = new Set()
|
const emptyIDs: ReadonlySet<string> = new Set()
|
||||||
|
|
||||||
function projectMessageSource(message: Message): SessionMessageInfo[] {
|
|
||||||
if (message.role === "user") {
|
|
||||||
return [
|
|
||||||
{ id: `${message.id}:agent`, type: "agent-switched", agent: message.agent, time: message.time },
|
|
||||||
{
|
|
||||||
id: `${message.id}:model`,
|
|
||||||
type: "model-switched",
|
|
||||||
model: { id: message.model.modelID, providerID: message.model.providerID, variant: message.model.variant },
|
|
||||||
time: message.time,
|
|
||||||
},
|
|
||||||
{ id: message.id, type: "user", text: "", time: message.time },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: message.id,
|
|
||||||
type: "assistant",
|
|
||||||
agent: message.agent ?? message.mode,
|
|
||||||
model: { id: message.modelID, providerID: message.providerID, variant: message.variant },
|
|
||||||
content: [],
|
|
||||||
time: message.time,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) {
|
function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) {
|
||||||
const boundary = source.find(
|
const boundary = source.find(
|
||||||
(message) =>
|
(message) =>
|
||||||
@@ -64,13 +45,6 @@ function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) {
|
|||||||
return boundary?.type === "assistant"
|
return boundary?.type === "assistant"
|
||||||
}
|
}
|
||||||
|
|
||||||
type OptimisticItem = {
|
|
||||||
message: Message
|
|
||||||
parts: Part[]
|
|
||||||
confirmedParts?: Part[]
|
|
||||||
confirmedMessage?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
type MessagePage = {
|
type MessagePage = {
|
||||||
session: Message[]
|
session: Message[]
|
||||||
part: { id: string; part: Part[] }[]
|
part: { id: string; part: Part[] }[]
|
||||||
@@ -81,6 +55,18 @@ type MessagePage = {
|
|||||||
complete: boolean
|
complete: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PromptEcho = {
|
||||||
|
sessionID: string
|
||||||
|
messageID: string
|
||||||
|
text: string
|
||||||
|
displayText: string
|
||||||
|
agent: string
|
||||||
|
model: { providerID: string; modelID: string; variant?: string }
|
||||||
|
files?: { uri: string; mime: string; name?: string; mention?: { start: number; end: number; text: string } }[]
|
||||||
|
agents?: { name: string; mention?: { start: number; end: number; text: string } }[]
|
||||||
|
comments: PromptComment[]
|
||||||
|
}
|
||||||
|
|
||||||
// Most markers describe the current HTTP attempt; deltaParts persists non-durable stream state across retries.
|
// Most markers describe the current HTTP attempt; deltaParts persists non-durable stream state across retries.
|
||||||
type MessageLoadState = {
|
type MessageLoadState = {
|
||||||
touchedMessages: Set<string>
|
touchedMessages: Set<string>
|
||||||
@@ -90,7 +76,6 @@ type MessageLoadState = {
|
|||||||
deltaParts: Map<string, Set<string>>
|
deltaParts: Map<string, Set<string>>
|
||||||
carriedDeltaParts: Map<string, Set<string>>
|
carriedDeltaParts: Map<string, Set<string>>
|
||||||
removedParts: Map<string, Set<string>>
|
removedParts: Map<string, Set<string>>
|
||||||
optimisticParts: Map<string, Set<string>>
|
|
||||||
orphanParents: Set<string>
|
orphanParents: Set<string>
|
||||||
clearedMessageParts: Set<string>
|
clearedMessageParts: Set<string>
|
||||||
touchedSource: Set<string>
|
touchedSource: Set<string>
|
||||||
@@ -101,34 +86,6 @@ type MessageLoadBaseline = Pick<
|
|||||||
"touchedMessages" | "retainedMessages" | "touchedParts" | "clearedMessageParts"
|
"touchedMessages" | "retainedMessages" | "touchedParts" | "clearedMessageParts"
|
||||||
>
|
>
|
||||||
|
|
||||||
function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
|
|
||||||
if (items.length === 0) return { ...page, observed: [] as { messageID: string; parts: Part[] }[] }
|
|
||||||
const session = [...page.session]
|
|
||||||
const part = new Map(page.part.map((item) => [item.id, item.part]))
|
|
||||||
const observed: { messageID: string; parts: Part[] }[] = []
|
|
||||||
for (const item of items) {
|
|
||||||
const result = Binary.search(session, messageKey(item.message), messageKey)
|
|
||||||
const found = result.found
|
|
||||||
if (!found) session.splice(result.index, 0, item.message)
|
|
||||||
const current = part.get(item.message.id)
|
|
||||||
const confirmed = found ? item.parts.filter((part) => current?.some((value) => value.id === part.id)) : []
|
|
||||||
if (found) observed.push({ messageID: item.message.id, parts: confirmed })
|
|
||||||
part.set(
|
|
||||||
item.message.id,
|
|
||||||
merge(
|
|
||||||
found ? (current ?? []) : merge(item.confirmedParts ?? [], current ?? []),
|
|
||||||
item.parts.filter((part) => !confirmed.includes(part)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...page,
|
|
||||||
session,
|
|
||||||
part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, parts]) => ({ id, part: parts })),
|
|
||||||
observed,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function runInflight(map: Map<string, Promise<void>>, key: string, task: () => Promise<void>) {
|
function runInflight(map: Map<string, Promise<void>>, key: string, task: () => Promise<void>) {
|
||||||
const pending = map.get(key)
|
const pending = map.get(key)
|
||||||
if (pending) return pending
|
if (pending) return pending
|
||||||
@@ -203,6 +160,7 @@ export function createServerSession(
|
|||||||
input: {} as Record<string, string[]>,
|
input: {} as Record<string, string[]>,
|
||||||
message: {} as Record<string, Message[]>,
|
message: {} as Record<string, Message[]>,
|
||||||
session_message: {} as Record<string, SessionMessageInfo[]>,
|
session_message: {} as Record<string, SessionMessageInfo[]>,
|
||||||
|
// Part order is semantic and follows SessionMessageAssistant.content; IDs identify parts only.
|
||||||
part: {} as Record<string, Part[]>,
|
part: {} as Record<string, Part[]>,
|
||||||
part_text_accum_delta: {} as Record<string, string>,
|
part_text_accum_delta: {} as Record<string, string>,
|
||||||
session_working(id: string) {
|
session_working(id: string) {
|
||||||
@@ -212,7 +170,6 @@ export function createServerSession(
|
|||||||
const requests = new Map<string, Promise<SessionInfo>>()
|
const requests = new Map<string, Promise<SessionInfo>>()
|
||||||
const inflight = new Map<string, Promise<void>>()
|
const inflight = new Map<string, Promise<void>>()
|
||||||
const inflightTodo = new Map<string, Promise<void>>()
|
const inflightTodo = new Map<string, Promise<void>>()
|
||||||
const optimistic = new Map<string, Map<string, OptimisticItem>>()
|
|
||||||
const v2 = createV2SessionReducer()
|
const v2 = createV2SessionReducer()
|
||||||
const pendingRevision = new Map<string, number>()
|
const pendingRevision = new Map<string, number>()
|
||||||
const formRevision = new Map<string, number>()
|
const formRevision = new Map<string, number>()
|
||||||
@@ -223,7 +180,45 @@ export function createServerSession(
|
|||||||
const pendingParts = new Map<string, Map<string, Set<string>>>()
|
const pendingParts = new Map<string, Map<string, Set<string>>>()
|
||||||
const orphanParts = new Map<string, Set<string>>()
|
const orphanParts = new Map<string, Set<string>>()
|
||||||
const removedMessages = new Map<string, Set<string>>()
|
const removedMessages = new Map<string, Set<string>>()
|
||||||
|
const echoes = new Map<string, Map<string, "sending" | "admitted">>()
|
||||||
|
const messageSnapshots = new Map<string, Set<string>>()
|
||||||
|
const settledInputs = new Map<string, Set<string>>()
|
||||||
const deltaBases = new Map<string, { base: string; sessionID: string }>()
|
const deltaBases = new Map<string, { base: string; sessionID: string }>()
|
||||||
|
const markEcho = (sessionID: string, messageID: string) => {
|
||||||
|
const messages = echoes.get(sessionID) ?? new Map<string, "sending" | "admitted">()
|
||||||
|
messages.set(messageID, "sending")
|
||||||
|
echoes.set(sessionID, messages)
|
||||||
|
}
|
||||||
|
const confirmEcho = (sessionID: string, messageID: string) => {
|
||||||
|
const messages = echoes.get(sessionID)
|
||||||
|
if (!messages?.has(messageID)) return false
|
||||||
|
messages.set(messageID, "admitted")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const releaseEcho = (sessionID: string, messageID: string) => {
|
||||||
|
const messages = echoes.get(sessionID)
|
||||||
|
const state = messages?.get(messageID)
|
||||||
|
if (!messages || !state) return
|
||||||
|
messages.delete(messageID)
|
||||||
|
if (messages.size === 0) echoes.delete(sessionID)
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
const present = (messageID: string, parts: Part[]) => {
|
||||||
|
const local = data.part[messageID] ?? []
|
||||||
|
const comments = local.filter(
|
||||||
|
(part) =>
|
||||||
|
part.type === "text" &&
|
||||||
|
part.synthetic &&
|
||||||
|
(readCommentMetadata(part.metadata) !== undefined || parseCommentNote(part.text) !== undefined),
|
||||||
|
)
|
||||||
|
if (!comments.length) return parts
|
||||||
|
const text = local.find((part) => part.type === "text" && !part.synthetic)
|
||||||
|
const projected = parts.flatMap((part) => {
|
||||||
|
if (part.id !== `${messageID}:text:0` || part.type !== "text") return [part]
|
||||||
|
return text?.type === "text" && text.text ? [{ ...part, text: text.text }] : []
|
||||||
|
})
|
||||||
|
return [...projected, ...comments]
|
||||||
|
}
|
||||||
const deleteMessageParts = (
|
const deleteMessageParts = (
|
||||||
cache: { part: Record<string, Part[] | undefined>; part_text_accum_delta: Record<string, string | undefined> },
|
cache: { part: Record<string, Part[] | undefined>; part_text_accum_delta: Record<string, string | undefined> },
|
||||||
messageID: string,
|
messageID: string,
|
||||||
@@ -246,25 +241,12 @@ export function createServerSession(
|
|||||||
return created
|
return created
|
||||||
}
|
}
|
||||||
const [meta, setMeta] = createStore({
|
const [meta, setMeta] = createStore({
|
||||||
limit: {} as Record<string, number | undefined>,
|
|
||||||
cursor: {} as Record<string, string | undefined>,
|
cursor: {} as Record<string, string | undefined>,
|
||||||
complete: {} as Record<string, boolean | undefined>,
|
complete: {} as Record<string, boolean | undefined>,
|
||||||
loading: {} as Record<string, boolean | undefined>,
|
loading: {} as Record<string, boolean | undefined>,
|
||||||
at: {} as Record<string, number | undefined>,
|
at: {} as Record<string, number | undefined>,
|
||||||
})
|
})
|
||||||
|
|
||||||
const indexProjectedMessage = (message: Message) => {
|
|
||||||
const current = data.session_message[message.sessionID] ?? []
|
|
||||||
if (current.some((item) => item.id === message.id)) return
|
|
||||||
const projected = projectMessageSource(message)
|
|
||||||
const projectedIDs = new Set(projected.map((item) => item.id))
|
|
||||||
setData(
|
|
||||||
"session_message",
|
|
||||||
message.sessionID,
|
|
||||||
reconcile([...current.filter((item) => !projectedIDs.has(item.id)), ...projected]),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const remember = (session: SessionInfo) => {
|
const remember = (session: SessionInfo) => {
|
||||||
setData("info", session.id, reconcile(session))
|
setData("info", session.id, reconcile(session))
|
||||||
infoSeen.delete(session.id)
|
infoSeen.delete(session.id)
|
||||||
@@ -276,7 +258,7 @@ export function createServerSession(
|
|||||||
...inflight.keys(),
|
...inflight.keys(),
|
||||||
...inflightTodo.keys(),
|
...inflightTodo.keys(),
|
||||||
...messageLoads.keys(),
|
...messageLoads.keys(),
|
||||||
...optimistic.keys(),
|
...echoes.keys(),
|
||||||
...Object.entries(data.permission)
|
...Object.entries(data.permission)
|
||||||
.filter(([, items]) => items.length > 0)
|
.filter(([, items]) => items.length > 0)
|
||||||
.map(([sessionID]) => sessionID),
|
.map(([sessionID]) => sessionID),
|
||||||
@@ -352,65 +334,6 @@ export function createServerSession(
|
|||||||
return { session, root }
|
return { session, root }
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearOptimistic = (sessionID: string, messageID?: string) => {
|
|
||||||
if (!messageID) {
|
|
||||||
optimistic.delete(sessionID)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const items = optimistic.get(sessionID)
|
|
||||||
if (!items) return
|
|
||||||
items.delete(messageID)
|
|
||||||
if (items.size === 0) optimistic.delete(sessionID)
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearOptimisticPart = (sessionID: string, messageID: string, partID: string) => {
|
|
||||||
const items = optimistic.get(sessionID)
|
|
||||||
const item = items?.get(messageID)
|
|
||||||
if (!items || !item) return
|
|
||||||
const parts = item.parts.filter((part) => part.id !== partID)
|
|
||||||
const confirmedParts = item.confirmedParts?.filter((part) => part.id !== partID)
|
|
||||||
if (parts.length === 0) {
|
|
||||||
clearOptimistic(sessionID, messageID)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
items.set(messageID, { ...item, parts, confirmedParts, confirmedMessage: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmOptimisticPart = (sessionID: string, messageID: string, part: Part) => {
|
|
||||||
const items = optimistic.get(sessionID)
|
|
||||||
const item = items?.get(messageID)
|
|
||||||
if (!items || !item) return
|
|
||||||
const parts = item.parts.filter((value) => value.id !== part.id)
|
|
||||||
if (parts.length === 0) {
|
|
||||||
clearOptimistic(sessionID, messageID)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
items.set(messageID, {
|
|
||||||
...item,
|
|
||||||
parts,
|
|
||||||
confirmedParts: merge(item.confirmedParts ?? [], [part]),
|
|
||||||
confirmedMessage: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmOptimistic = (sessionID: string, messageID: string, confirmedParts: Part[]) => {
|
|
||||||
const items = optimistic.get(sessionID)
|
|
||||||
const item = items?.get(messageID)
|
|
||||||
if (!items || !item) return
|
|
||||||
const confirmed = new Set(confirmedParts.map((part) => part.id))
|
|
||||||
const parts = item.parts.filter((part) => !confirmed.has(part.id))
|
|
||||||
if (parts.length === 0) {
|
|
||||||
clearOptimistic(sessionID, messageID)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
items.set(messageID, {
|
|
||||||
...item,
|
|
||||||
parts,
|
|
||||||
confirmedParts: merge(item.confirmedParts ?? [], confirmedParts),
|
|
||||||
confirmedMessage: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const trackPartChange = (sessionID: string, messageID: string, partID: string) => {
|
const trackPartChange = (sessionID: string, messageID: string, partID: string) => {
|
||||||
const load = messageLoads.get(sessionID)
|
const load = messageLoads.get(sessionID)
|
||||||
if (!load) return
|
if (!load) return
|
||||||
@@ -448,14 +371,6 @@ export function createServerSession(
|
|||||||
const messages = data.message[sessionID]
|
const messages = data.message[sessionID]
|
||||||
if (messages?.some((message) => message.id === messageID)) load.retainedMessages.add(messageID)
|
if (messages?.some((message) => message.id === messageID)) load.retainedMessages.add(messageID)
|
||||||
}
|
}
|
||||||
for (const [messageID, parts] of load.optimisticParts) {
|
|
||||||
load.removedMessages.delete(messageID)
|
|
||||||
load.clearedMessageParts.add(messageID)
|
|
||||||
load.touchedMessages.add(messageID)
|
|
||||||
const touched = load.touchedParts.get(messageID) ?? new Set<string>()
|
|
||||||
parts.forEach((partID) => touched.add(partID))
|
|
||||||
load.touchedParts.set(messageID, touched)
|
|
||||||
}
|
|
||||||
baseline?.touchedMessages.forEach((messageID) => load.touchedMessages.add(messageID))
|
baseline?.touchedMessages.forEach((messageID) => load.touchedMessages.add(messageID))
|
||||||
baseline?.retainedMessages.forEach((messageID) => load.retainedMessages.add(messageID))
|
baseline?.retainedMessages.forEach((messageID) => load.retainedMessages.add(messageID))
|
||||||
baseline?.clearedMessageParts.forEach((messageID) => load.clearedMessageParts.add(messageID))
|
baseline?.clearedMessageParts.forEach((messageID) => load.clearedMessageParts.add(messageID))
|
||||||
@@ -486,7 +401,9 @@ export function createServerSession(
|
|||||||
sessionIDs.forEach((sessionID) => {
|
sessionIDs.forEach((sessionID) => {
|
||||||
messageHydrationRevision.set(sessionID, (messageHydrationRevision.get(sessionID) ?? 0) + 1)
|
messageHydrationRevision.set(sessionID, (messageHydrationRevision.get(sessionID) ?? 0) + 1)
|
||||||
generations.delete(sessionID)
|
generations.delete(sessionID)
|
||||||
clearOptimistic(sessionID)
|
echoes.delete(sessionID)
|
||||||
|
messageSnapshots.delete(sessionID)
|
||||||
|
settledInputs.delete(sessionID)
|
||||||
requests.delete(sessionID)
|
requests.delete(sessionID)
|
||||||
inflight.delete(sessionID)
|
inflight.delete(sessionID)
|
||||||
inflightTodo.delete(sessionID)
|
inflightTodo.delete(sessionID)
|
||||||
@@ -504,7 +421,6 @@ export function createServerSession(
|
|||||||
setMeta(
|
setMeta(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
for (const sessionID of sessionIDs) {
|
for (const sessionID of sessionIDs) {
|
||||||
delete draft.limit[sessionID]
|
|
||||||
delete draft.cursor[sessionID]
|
delete draft.cursor[sessionID]
|
||||||
delete draft.complete[sessionID]
|
delete draft.complete[sessionID]
|
||||||
delete draft.loading[sessionID]
|
delete draft.loading[sessionID]
|
||||||
@@ -521,7 +437,7 @@ export function createServerSession(
|
|||||||
...inflight.keys(),
|
...inflight.keys(),
|
||||||
...inflightTodo.keys(),
|
...inflightTodo.keys(),
|
||||||
...messageLoads.keys(),
|
...messageLoads.keys(),
|
||||||
...optimistic.keys(),
|
...echoes.keys(),
|
||||||
...Object.entries(data.permission)
|
...Object.entries(data.permission)
|
||||||
.filter(([, items]) => items.length > 0)
|
.filter(([, items]) => items.length > 0)
|
||||||
.map(([sessionID]) => sessionID),
|
.map(([sessionID]) => sessionID),
|
||||||
@@ -538,11 +454,13 @@ export function createServerSession(
|
|||||||
pickSessionCacheEvictions({ seen, keep: sessionID, limit: SESSION_CACHE_LIMIT, preserve: protectedSessions() }),
|
pickSessionCacheEvictions({ seen, keep: sessionID, limit: SESSION_CACHE_LIMIT, preserve: protectedSessions() }),
|
||||||
)
|
)
|
||||||
|
|
||||||
const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => {
|
const fetchMessages = async (sessionID: string, before?: string, onAttempt?: () => void) => {
|
||||||
const request = (cursor?: string) =>
|
const request = (cursor?: string) =>
|
||||||
(options?.retry ?? retry)(() => {
|
(options?.retry ?? retry)(() => {
|
||||||
onAttempt?.()
|
onAttempt?.()
|
||||||
return messageApi.list(cursor ? { sessionID, limit, cursor } : { sessionID, limit, order: "desc" })
|
return messageApi.list(
|
||||||
|
cursor ? { sessionID, limit: messagePageSize, cursor } : { sessionID, limit: messagePageSize, order: "desc" },
|
||||||
|
)
|
||||||
})
|
})
|
||||||
const first = await request(before)
|
const first = await request(before)
|
||||||
const pages = [first]
|
const pages = [first]
|
||||||
@@ -556,9 +474,7 @@ export function createServerSession(
|
|||||||
const normalized = normalizeSessionMessages(sessionID, source)
|
const normalized = normalizeSessionMessages(sessionID, source)
|
||||||
return {
|
return {
|
||||||
session: normalized.messages.sort(compareMessages),
|
session: normalized.messages.sort(compareMessages),
|
||||||
part: [...normalized.parts.entries()]
|
part: [...normalized.parts.entries()].map(([id, part]) => ({ id, part })).sort((a, b) => cmp(a.id, b.id)),
|
||||||
.map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) }))
|
|
||||||
.sort((a, b) => cmp(a.id, b.id)),
|
|
||||||
source,
|
source,
|
||||||
sourceMode: before ? ("older" as const) : ("latest" as const),
|
sourceMode: before ? ("older" as const) : ("latest" as const),
|
||||||
projectSource: true,
|
projectSource: true,
|
||||||
@@ -598,9 +514,10 @@ export function createServerSession(
|
|||||||
) => {
|
) => {
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (!messageIDs.has(item.id)) continue
|
if (!messageIDs.has(item.id)) continue
|
||||||
const fetched = load?.clearedMessageParts.has(item.id)
|
const fetched = present(
|
||||||
? []
|
item.id,
|
||||||
: item.part.filter((part) => !SKIP_PARTS.has(part.type))
|
load?.clearedMessageParts.has(item.id) ? [] : item.part.filter((part) => !SKIP_PARTS.has(part.type)),
|
||||||
|
)
|
||||||
const fetchedIDs = new Set(fetched.map((part) => part.id))
|
const fetchedIDs = new Set(fetched.map((part) => part.id))
|
||||||
const pending = pendingParts.get(sessionID)?.get(item.id)
|
const pending = pendingParts.get(sessionID)?.get(item.id)
|
||||||
const touched = new Set([...(load?.touchedParts.get(item.id) ?? []), ...(pending ?? [])])
|
const touched = new Set([...(load?.touchedParts.get(item.id) ?? []), ...(pending ?? [])])
|
||||||
@@ -651,47 +568,56 @@ export function createServerSession(
|
|||||||
preserveUnfetched: boolean | ((message: Message) => boolean),
|
preserveUnfetched: boolean | ((message: Message) => boolean),
|
||||||
cleanupOrphans: boolean,
|
cleanupOrphans: boolean,
|
||||||
) => {
|
) => {
|
||||||
|
if (page.sourceMode === "latest")
|
||||||
|
messageSnapshots.set(sessionID, new Set((page.source ?? []).map((message) => message.id)))
|
||||||
|
page.source?.forEach((message) => releaseEcho(sessionID, message.id))
|
||||||
const source = page.source
|
const source = page.source
|
||||||
? (() => {
|
? (() => {
|
||||||
const incoming = new Map(page.source.map((message) => [message.id, message]))
|
const incoming = new Map(page.source.map((message) => [message.id, message]))
|
||||||
const existing = data.session_message[sessionID] ?? []
|
const existing = data.session_message[sessionID] ?? []
|
||||||
const boundary = Math.min(...page.source.map((message) => message.time.created))
|
const boundary = Math.min(...page.source.map((message) => message.time.created))
|
||||||
|
const inbox = new Set(data.input[sessionID] ?? [])
|
||||||
const current = existing.filter(
|
const current = existing.filter(
|
||||||
(message) =>
|
(message) =>
|
||||||
!incoming.has(message.id) &&
|
!incoming.has(message.id) &&
|
||||||
|
!inbox.has(message.id) &&
|
||||||
(page.sourceMode === "older" ||
|
(page.sourceMode === "older" ||
|
||||||
load?.touchedSource.has(message.id) ||
|
load?.touchedSource.has(message.id) ||
|
||||||
(!page.complete && message.time.created < boundary)),
|
(!page.complete && message.time.created < boundary)),
|
||||||
)
|
)
|
||||||
|
// message.list never returns admitted-but-undelivered inbox entries; keep them after the
|
||||||
|
// fetched history until a delivered or cancelled event resolves them.
|
||||||
|
const admitted = existing.filter((message) => !incoming.has(message.id) && inbox.has(message.id))
|
||||||
|
const combined =
|
||||||
|
page.sourceMode === "older"
|
||||||
|
? [...page.source, ...current, ...admitted]
|
||||||
|
: [...current, ...page.source, ...admitted]
|
||||||
const live = new Map(existing.map((message) => [message.id, message]))
|
const live = new Map(existing.map((message) => [message.id, message]))
|
||||||
return (page.sourceMode === "older" ? [...page.source, ...current] : [...current, ...page.source]).map(
|
return combined.map((message) =>
|
||||||
(message) => (load?.touchedSource.has(message.id) ? (live.get(message.id) ?? message) : message),
|
load?.touchedSource.has(message.id) ? (live.get(message.id) ?? message) : message,
|
||||||
)
|
)
|
||||||
})()
|
})()
|
||||||
: undefined
|
: undefined
|
||||||
const projected =
|
const merged =
|
||||||
page.projectSource && source
|
page.projectSource && source
|
||||||
? (() => {
|
? (() => {
|
||||||
const normalized = normalizeSessionMessages(sessionID, source)
|
const normalized = normalizeSessionMessages(sessionID, source)
|
||||||
return {
|
return {
|
||||||
...page,
|
...page,
|
||||||
session: normalized.messages.sort(compareMessages),
|
session: normalized.messages.sort(compareMessages),
|
||||||
part: [...normalized.parts.entries()]
|
part: [...normalized.parts.entries()].map(([id, part]) => ({ id, part })).sort((a, b) => cmp(a.id, b.id)),
|
||||||
.map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) }))
|
|
||||||
.sort((a, b) => cmp(a.id, b.id)),
|
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
: page
|
: page
|
||||||
const merged = mergeOptimisticPage(projected, [...(optimistic.get(sessionID)?.values() ?? [])])
|
|
||||||
merged.observed.forEach((item) => {
|
|
||||||
if (!load?.clearedMessageParts.has(item.messageID)) confirmOptimistic(sessionID, item.messageID, item.parts)
|
|
||||||
})
|
|
||||||
const touchedMessages = new Set([...(load?.touchedMessages ?? []), ...(removedMessages.get(sessionID) ?? [])])
|
const touchedMessages = new Set([...(load?.touchedMessages ?? []), ...(removedMessages.get(sessionID) ?? [])])
|
||||||
const messages = reconcileFetched(merged.session, data.message[sessionID] ?? [], {
|
const messages = reconcileFetched(merged.session, data.message[sessionID] ?? [], {
|
||||||
touched: touchedMessages,
|
touched: touchedMessages,
|
||||||
retained: load?.retainedMessages,
|
retained: load?.retainedMessages,
|
||||||
removed: load?.removedMessages,
|
removed: load?.removedMessages,
|
||||||
preserveUnfetched,
|
preserveUnfetched: (message) =>
|
||||||
|
echoes.get(sessionID)?.has(message.id) === true ||
|
||||||
|
preserveUnfetched === true ||
|
||||||
|
(typeof preserveUnfetched === "function" && preserveUnfetched(message)),
|
||||||
compare: compareMessages,
|
compare: compareMessages,
|
||||||
})
|
})
|
||||||
batch(() => {
|
batch(() => {
|
||||||
@@ -705,14 +631,13 @@ export function createServerSession(
|
|||||||
}
|
}
|
||||||
orphanParts.delete(sessionID)
|
orphanParts.delete(sessionID)
|
||||||
}
|
}
|
||||||
setMeta("limit", sessionID, messages.length)
|
|
||||||
setMeta("cursor", sessionID, merged.cursor)
|
setMeta("cursor", sessionID, merged.cursor)
|
||||||
setMeta("complete", sessionID, merged.complete)
|
setMeta("complete", sessionID, merged.complete)
|
||||||
setMeta("at", sessionID, Date.now())
|
setMeta("at", sessionID, Date.now())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadMessages = async (sessionID: string, limit: number, before?: string, mode?: "replace" | "prepend") => {
|
const loadMessages = async (sessionID: string, before?: string, mode?: "replace" | "prepend") => {
|
||||||
if (meta.loading[sessionID]) return
|
if (meta.loading[sessionID]) return
|
||||||
const active = generation(sessionID)
|
const active = generation(sessionID)
|
||||||
const load: MessageLoadState = {
|
const load: MessageLoadState = {
|
||||||
@@ -723,7 +648,6 @@ export function createServerSession(
|
|||||||
deltaParts: new Map(),
|
deltaParts: new Map(),
|
||||||
carriedDeltaParts: new Map(),
|
carriedDeltaParts: new Map(),
|
||||||
removedParts: new Map(),
|
removedParts: new Map(),
|
||||||
optimisticParts: new Map(),
|
|
||||||
orphanParents: new Set(),
|
orphanParents: new Set(),
|
||||||
clearedMessageParts: new Set(),
|
clearedMessageParts: new Set(),
|
||||||
touchedSource: new Set(),
|
touchedSource: new Set(),
|
||||||
@@ -732,7 +656,7 @@ export function createServerSession(
|
|||||||
setMeta("loading", sessionID, true)
|
setMeta("loading", sessionID, true)
|
||||||
let applied = false
|
let applied = false
|
||||||
try {
|
try {
|
||||||
const page = await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load))
|
const page = await fetchMessages(sessionID, before, () => resetMessageLoad(sessionID, load))
|
||||||
const first = page.session.reduce<Message | undefined>(
|
const first = page.session.reduce<Message | undefined>(
|
||||||
(oldest, message) => (!oldest || compareMessages(message, oldest) < 0 ? message : oldest),
|
(oldest, message) => (!oldest || compareMessages(message, oldest) < 0 ? message : oldest),
|
||||||
undefined,
|
undefined,
|
||||||
@@ -744,11 +668,7 @@ export function createServerSession(
|
|||||||
const users = new Set([
|
const users = new Set([
|
||||||
...page.session.filter((message) => message.role === "user").map((message) => message.id),
|
...page.session.filter((message) => message.role === "user").map((message) => message.id),
|
||||||
...(data.message[sessionID] ?? [])
|
...(data.message[sessionID] ?? [])
|
||||||
.filter((message) => {
|
.filter((message) => message.role === "user" && load.touchedMessages.has(message.id))
|
||||||
if (message.role !== "user") return false
|
|
||||||
const item = optimistic.get(sessionID)?.get(message.id)
|
|
||||||
return load.touchedMessages.has(message.id) && (!item || item.confirmedMessage === true)
|
|
||||||
})
|
|
||||||
.map((message) => message.id),
|
.map((message) => message.id),
|
||||||
])
|
])
|
||||||
const parentIDs = [
|
const parentIDs = [
|
||||||
@@ -815,32 +735,30 @@ export function createServerSession(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sync = (sessionID: string, options?: { force?: boolean; messageLimit?: number }) => {
|
const sync = (sessionID: string, options?: { force?: boolean }) => {
|
||||||
touch(sessionID)
|
touch(sessionID)
|
||||||
return runInflight(inflight, sessionID, async () => {
|
return runInflight(inflight, sessionID, async () => {
|
||||||
const cached = data.message[sessionID] !== undefined && meta.limit[sessionID] !== undefined
|
const cached = data.message[sessionID] !== undefined && meta.complete[sessionID] !== undefined
|
||||||
const invalid = invalidated.has(sessionID)
|
const invalid = invalidated.has(sessionID)
|
||||||
const revision = invalidationRevision
|
const revision = invalidationRevision
|
||||||
if (cached && data.info[sessionID] && !invalid && !options?.force) return
|
if (cached && data.info[sessionID] && !invalid && !options?.force) return
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
resolve(sessionID, invalid ? { ...options, force: true } : options),
|
resolve(sessionID, invalid ? { ...options, force: true } : options),
|
||||||
cached && !invalid && !options?.force
|
cached && !invalid && !options?.force ? Promise.resolve() : loadMessages(sessionID),
|
||||||
? Promise.resolve()
|
|
||||||
: loadMessages(sessionID, options?.messageLimit ?? meta.limit[sessionID] ?? initialMessagePageSize),
|
|
||||||
])
|
])
|
||||||
if (invalid && invalidationRevision === revision) invalidated.delete(sessionID)
|
if (invalid && invalidationRevision === revision) invalidated.delete(sessionID)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const prefetch = async (sessionID: string, limit: number) => {
|
const prefetch = async (sessionID: string, messageCount: number) => {
|
||||||
touch(sessionID)
|
touch(sessionID)
|
||||||
await inflight.get(sessionID)
|
await inflight.get(sessionID)
|
||||||
if (
|
if (
|
||||||
Date.now() - (meta.at[sessionID] ?? 0) <= 15_000 &&
|
Date.now() - (meta.at[sessionID] ?? 0) <= 15_000 &&
|
||||||
(meta.complete[sessionID] || (data.message[sessionID]?.length ?? 0) >= limit)
|
(meta.complete[sessionID] || (data.message[sessionID]?.length ?? 0) >= messageCount)
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
await runInflight(inflight, sessionID, () => loadMessages(sessionID, limit))
|
await runInflight(inflight, sessionID, () => loadMessages(sessionID))
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventSessionID = (event: { type: string; properties?: unknown }) => {
|
const eventSessionID = (event: { type: string; properties?: unknown }) => {
|
||||||
@@ -893,12 +811,12 @@ export function createServerSession(
|
|||||||
apply({ type: "message.updated", properties: { sessionID: reduction.sessionID, info: message } })
|
apply({ type: "message.updated", properties: { sessionID: reduction.sessionID, info: message } })
|
||||||
}
|
}
|
||||||
for (const messageID of touched) {
|
for (const messageID of touched) {
|
||||||
const next = normalized.parts.get(messageID) ?? []
|
const next = present(messageID, normalized.parts.get(messageID) ?? [])
|
||||||
const nextIDs = new Set(next.map((part) => part.id))
|
const nextIDs = new Set(next.map((part) => part.id))
|
||||||
for (const part of next) {
|
for (const part of next) {
|
||||||
apply({ type: "message.part.updated", properties: { sessionID: reduction.sessionID, part } })
|
apply({ type: "message.part.updated", properties: { sessionID: reduction.sessionID, part } })
|
||||||
}
|
}
|
||||||
for (const part of data.part[messageID] ?? []) {
|
for (const part of [...(data.part[messageID] ?? [])]) {
|
||||||
if (nextIDs.has(part.id)) continue
|
if (nextIDs.has(part.id)) continue
|
||||||
apply({
|
apply({
|
||||||
type: "message.part.removed",
|
type: "message.part.removed",
|
||||||
@@ -926,6 +844,67 @@ export function createServerSession(
|
|||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const removeEcho = (sessionID: string, messageID: string) => {
|
||||||
|
if (!releaseEcho(sessionID, messageID)) return false
|
||||||
|
pendingRevision.set(sessionID, (pendingRevision.get(sessionID) ?? 0) + 1)
|
||||||
|
const load = messageLoads.get(sessionID)
|
||||||
|
load?.touchedMessages.add(messageID)
|
||||||
|
load?.removedMessages.add(messageID)
|
||||||
|
load?.clearedMessageParts.add(messageID)
|
||||||
|
batch(() => {
|
||||||
|
setData("pending", sessionID, (items) => items?.filter((item) => item.id !== messageID))
|
||||||
|
setData("input", sessionID, (items) => items?.filter((id) => id !== messageID))
|
||||||
|
setData("message", sessionID, (messages) => messages?.filter((message) => message.id !== messageID))
|
||||||
|
setData(produce((draft) => deleteMessageParts(draft, messageID)))
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmInbox = (item: SessionInboxInfo) => {
|
||||||
|
if (!confirmEcho(item.sessionID, item.id)) return false
|
||||||
|
v2.confirm(item)
|
||||||
|
pendingRevision.set(item.sessionID, (pendingRevision.get(item.sessionID) ?? 0) + 1)
|
||||||
|
const current = data.pending[item.sessionID] ?? []
|
||||||
|
const index = current.findIndex((entry) => entry.id === item.id)
|
||||||
|
if (index < 0) setData("pending", item.sessionID, [...current, item])
|
||||||
|
if (index >= 0) setData("pending", item.sessionID, index, reconcile(item))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const reconcileInbox = (sessionID: string) => {
|
||||||
|
const pending = new Set((data.pending[sessionID] ?? []).map((item) => item.id))
|
||||||
|
const fetched = messageSnapshots.get(sessionID) ?? new Set<string>()
|
||||||
|
const removed = [...(settledInputs.get(sessionID) ?? [])].filter(
|
||||||
|
(messageID) => !pending.has(messageID) && !fetched.has(messageID),
|
||||||
|
)
|
||||||
|
settledInputs.delete(sessionID)
|
||||||
|
if (removed.length) {
|
||||||
|
const ids = new Set(removed)
|
||||||
|
const source = data.session_message[sessionID] ?? []
|
||||||
|
projectV2({
|
||||||
|
sessionID,
|
||||||
|
messages: source.filter((message) => !ids.has(message.id)),
|
||||||
|
touched: [],
|
||||||
|
removed: source.filter((message) => ids.has(message.id)).map((message) => message.id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = echoes.get(sessionID)
|
||||||
|
if (!messages) return
|
||||||
|
const projected = new Set((data.session_message[sessionID] ?? []).map((message) => message.id))
|
||||||
|
for (const [messageID, state] of messages) {
|
||||||
|
if (projected.has(messageID)) {
|
||||||
|
releaseEcho(sessionID, messageID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (pending.has(messageID)) {
|
||||||
|
confirmEcho(sessionID, messageID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (state === "admitted") removeEcho(sessionID, messageID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const applyV2 = (event: OpenCodeEvent) => {
|
const applyV2 = (event: OpenCodeEvent) => {
|
||||||
if (event.type === "form.created") {
|
if (event.type === "form.created") {
|
||||||
formRevision.set(event.data.form.sessionID, (formRevision.get(event.data.form.sessionID) ?? 0) + 1)
|
formRevision.set(event.data.form.sessionID, (formRevision.get(event.data.form.sessionID) ?? 0) + 1)
|
||||||
@@ -949,6 +928,9 @@ export function createServerSession(
|
|||||||
}
|
}
|
||||||
if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return
|
if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return
|
||||||
const sessionID = event.data.sessionID
|
const sessionID = event.data.sessionID
|
||||||
|
if (event.type === "session.inbox.enqueued" || event.type === "session.inbox.delivered")
|
||||||
|
releaseEcho(sessionID, event.data.inboxID)
|
||||||
|
if (event.type === "session.inbox.cancelled") removeEcho(sessionID, event.data.inboxID)
|
||||||
if (
|
if (
|
||||||
event.type === "session.inbox.enqueued" ||
|
event.type === "session.inbox.enqueued" ||
|
||||||
event.type === "session.inbox.delivery.changed" ||
|
event.type === "session.inbox.delivery.changed" ||
|
||||||
@@ -960,11 +942,10 @@ export function createServerSession(
|
|||||||
pendingRevision.set(sessionID, (pendingRevision.get(sessionID) ?? 0) + 1)
|
pendingRevision.set(sessionID, (pendingRevision.get(sessionID) ?? 0) + 1)
|
||||||
if (event.type === "session.inbox.enqueued") {
|
if (event.type === "session.inbox.enqueued") {
|
||||||
const current = data.pending[sessionID] ?? []
|
const current = data.pending[sessionID] ?? []
|
||||||
if (!current.some((item) => item.id === event.data.inboxID))
|
const item = { id: event.data.inboxID, sessionID, timeCreated: event.created, ...event.data.item }
|
||||||
setData("pending", sessionID, [
|
const index = current.findIndex((entry) => entry.id === event.data.inboxID)
|
||||||
...current,
|
if (index < 0) setData("pending", sessionID, [...current, item])
|
||||||
{ id: event.data.inboxID, sessionID, timeCreated: event.created, ...event.data.item },
|
if (index >= 0) setData("pending", sessionID, index, reconcile(item))
|
||||||
])
|
|
||||||
if (event.data.item.type !== "compaction" && !data.input[sessionID]?.includes(event.data.inboxID))
|
if (event.data.item.type !== "compaction" && !data.input[sessionID]?.includes(event.data.inboxID))
|
||||||
setData("input", sessionID, [...(data.input[sessionID] ?? []), event.data.inboxID])
|
setData("input", sessionID, [...(data.input[sessionID] ?? []), event.data.inboxID])
|
||||||
}
|
}
|
||||||
@@ -1101,16 +1082,9 @@ export function createServerSession(
|
|||||||
}
|
}
|
||||||
case "message.updated": {
|
case "message.updated": {
|
||||||
const info = (event.properties as { info: Message }).info
|
const info = (event.properties as { info: Message }).info
|
||||||
indexProjectedMessage(info)
|
|
||||||
const load = messageLoads.get(info.sessionID)
|
const load = messageLoads.get(info.sessionID)
|
||||||
load?.touchedMessages.add(info.id)
|
load?.touchedMessages.add(info.id)
|
||||||
load?.removedMessages.delete(info.id)
|
load?.removedMessages.delete(info.id)
|
||||||
const items = optimistic.get(info.sessionID)
|
|
||||||
const item = items?.get(info.id)
|
|
||||||
if (items && item) {
|
|
||||||
if (item.parts.length === 0) clearOptimistic(info.sessionID, info.id)
|
|
||||||
if (item.parts.length > 0) items.set(info.id, { ...item, confirmedMessage: true })
|
|
||||||
}
|
|
||||||
const orphans = orphanParts.get(info.sessionID)
|
const orphans = orphanParts.get(info.sessionID)
|
||||||
orphans?.delete(info.id)
|
orphans?.delete(info.id)
|
||||||
if (orphans?.size === 0) orphanParts.delete(info.sessionID)
|
if (orphans?.size === 0) orphanParts.delete(info.sessionID)
|
||||||
@@ -1123,13 +1097,18 @@ export function createServerSession(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const result = Binary.search(messages, messageKey(info), messageKey)
|
const result = Binary.search(messages, messageKey(info), messageKey)
|
||||||
if (result.found) setData("message", info.sessionID, result.index, reconcile(info))
|
if (result.found) {
|
||||||
if (!result.found)
|
setData("message", info.sessionID, result.index, reconcile(info))
|
||||||
setData("message", info.sessionID, (value = []) => {
|
return
|
||||||
const next = value.slice()
|
}
|
||||||
next.splice(result.index, 0, info)
|
// Delivery rewrites time.created, changing the sort key; reposition instead of duplicating.
|
||||||
return next
|
setData("message", info.sessionID, (value = []) => {
|
||||||
})
|
const next = value.slice()
|
||||||
|
const moved = next.findIndex((message) => message.id === info.id)
|
||||||
|
if (moved >= 0) next.splice(moved, 1)
|
||||||
|
next.splice(moved >= 0 && moved < result.index ? result.index - 1 : result.index, 0, info)
|
||||||
|
return next
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "message.removed": {
|
case "message.removed": {
|
||||||
@@ -1144,13 +1123,11 @@ export function createServerSession(
|
|||||||
load?.deltaParts.delete(props.messageID)
|
load?.deltaParts.delete(props.messageID)
|
||||||
load?.carriedDeltaParts.delete(props.messageID)
|
load?.carriedDeltaParts.delete(props.messageID)
|
||||||
load?.removedParts.delete(props.messageID)
|
load?.removedParts.delete(props.messageID)
|
||||||
load?.optimisticParts.delete(props.messageID)
|
|
||||||
pendingParts.get(props.sessionID)?.delete(props.messageID)
|
pendingParts.get(props.sessionID)?.delete(props.messageID)
|
||||||
if (pendingParts.get(props.sessionID)?.size === 0) pendingParts.delete(props.sessionID)
|
if (pendingParts.get(props.sessionID)?.size === 0) pendingParts.delete(props.sessionID)
|
||||||
const removedMessagesForSession = removedMessages.get(props.sessionID) ?? new Set<string>()
|
const removedMessagesForSession = removedMessages.get(props.sessionID) ?? new Set<string>()
|
||||||
removedMessagesForSession.add(props.messageID)
|
removedMessagesForSession.add(props.messageID)
|
||||||
removedMessages.set(props.sessionID, removedMessagesForSession)
|
removedMessages.set(props.sessionID, removedMessagesForSession)
|
||||||
clearOptimistic(props.sessionID, props.messageID)
|
|
||||||
setData(
|
setData(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
const messages = draft.message[props.sessionID]
|
const messages = draft.message[props.sessionID]
|
||||||
@@ -1196,12 +1173,8 @@ export function createServerSession(
|
|||||||
pending?.delete(part.id)
|
pending?.delete(part.id)
|
||||||
if (pending?.size === 0) pendingParts.get(part.sessionID)?.delete(part.messageID)
|
if (pending?.size === 0) pendingParts.get(part.sessionID)?.delete(part.messageID)
|
||||||
if (pendingParts.get(part.sessionID)?.size === 0) pendingParts.delete(part.sessionID)
|
if (pendingParts.get(part.sessionID)?.size === 0) pendingParts.delete(part.sessionID)
|
||||||
const optimistic = load?.optimisticParts.get(part.messageID)
|
|
||||||
optimistic?.delete(part.id)
|
|
||||||
if (optimistic?.size === 0) load?.optimisticParts.delete(part.messageID)
|
|
||||||
deltaBases.delete(part.id)
|
deltaBases.delete(part.id)
|
||||||
trackPartChange(part.sessionID, part.messageID, part.id)
|
trackPartChange(part.sessionID, part.messageID, part.id)
|
||||||
confirmOptimisticPart(part.sessionID, part.messageID, part)
|
|
||||||
setData(
|
setData(
|
||||||
"part_text_accum_delta",
|
"part_text_accum_delta",
|
||||||
produce((draft) => void delete draft[part.id]),
|
produce((draft) => void delete draft[part.id]),
|
||||||
@@ -1211,14 +1184,9 @@ export function createServerSession(
|
|||||||
setData("part", part.messageID, [part])
|
setData("part", part.messageID, [part])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const result = Binary.search(parts, part.id, (item) => item.id)
|
const index = parts.findIndex((item) => item.id === part.id)
|
||||||
if (result.found) setData("part", part.messageID, result.index, reconcile(part))
|
if (index >= 0) setData("part", part.messageID, index, reconcile(part))
|
||||||
if (!result.found)
|
if (index < 0) setData("part", part.messageID, (value = []) => [...value, part])
|
||||||
setData("part", part.messageID, (value = []) => {
|
|
||||||
const next = value.slice()
|
|
||||||
next.splice(result.index, 0, part)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "message.part.removed": {
|
case "message.part.removed": {
|
||||||
@@ -1240,20 +1208,16 @@ export function createServerSession(
|
|||||||
const parts = load.removedParts.get(props.messageID) ?? new Set<string>()
|
const parts = load.removedParts.get(props.messageID) ?? new Set<string>()
|
||||||
parts.add(props.partID)
|
parts.add(props.partID)
|
||||||
load.removedParts.set(props.messageID, parts)
|
load.removedParts.set(props.messageID, parts)
|
||||||
const optimistic = load.optimisticParts.get(props.messageID)
|
|
||||||
optimistic?.delete(props.partID)
|
|
||||||
if (optimistic?.size === 0) load.optimisticParts.delete(props.messageID)
|
|
||||||
}
|
}
|
||||||
trackPartChange(props.sessionID, props.messageID, props.partID)
|
trackPartChange(props.sessionID, props.messageID, props.partID)
|
||||||
clearOptimisticPart(props.sessionID, props.messageID, props.partID)
|
|
||||||
setData(
|
setData(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
delete draft.part_text_accum_delta[props.partID]
|
delete draft.part_text_accum_delta[props.partID]
|
||||||
deltaBases.delete(props.partID)
|
deltaBases.delete(props.partID)
|
||||||
const parts = draft.part[props.messageID]
|
const parts = draft.part[props.messageID]
|
||||||
if (!parts) return
|
if (!parts) return
|
||||||
const result = Binary.search(parts, props.partID, (part) => part.id)
|
const index = parts.findIndex((part) => part.id === props.partID)
|
||||||
if (result.found) parts.splice(result.index, 1)
|
if (index >= 0) parts.splice(index, 1)
|
||||||
if (parts.length === 0) delete draft.part[props.messageID]
|
if (parts.length === 0) delete draft.part[props.messageID]
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -1269,8 +1233,8 @@ export function createServerSession(
|
|||||||
}
|
}
|
||||||
const parts = data.part[props.messageID]
|
const parts = data.part[props.messageID]
|
||||||
if (!parts) return
|
if (!parts) return
|
||||||
const result = Binary.search(parts, props.partID, (part) => part.id)
|
const index = parts.findIndex((part) => part.id === props.partID)
|
||||||
if (!result.found) return
|
if (index < 0) return
|
||||||
trackPartChange(props.sessionID, props.messageID, props.partID)
|
trackPartChange(props.sessionID, props.messageID, props.partID)
|
||||||
const load = messageLoads.get(props.sessionID)
|
const load = messageLoads.get(props.sessionID)
|
||||||
if (load) {
|
if (load) {
|
||||||
@@ -1282,7 +1246,7 @@ export function createServerSession(
|
|||||||
if (carried?.size === 0) load.carriedDeltaParts.delete(props.messageID)
|
if (carried?.size === 0) load.carriedDeltaParts.delete(props.messageID)
|
||||||
}
|
}
|
||||||
const field = props.field as keyof (typeof parts)[number]
|
const field = props.field as keyof (typeof parts)[number]
|
||||||
const current = parts[result.index]?.[field]
|
const current = parts[index]?.[field]
|
||||||
if (!deltaBases.has(props.partID) && typeof current === "string")
|
if (!deltaBases.has(props.partID) && typeof current === "string")
|
||||||
deltaBases.set(props.partID, { base: current, sessionID: props.sessionID })
|
deltaBases.set(props.partID, { base: current, sessionID: props.sessionID })
|
||||||
setData(
|
setData(
|
||||||
@@ -1295,7 +1259,7 @@ export function createServerSession(
|
|||||||
props.messageID,
|
props.messageID,
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
if (!draft) return
|
if (!draft) return
|
||||||
const part = draft[result.index]
|
const part = draft[index]
|
||||||
const field = props.field as keyof typeof part
|
const field = props.field as keyof typeof part
|
||||||
;(part[field] as string) = ((part[field] as string | undefined) ?? "") + props.delta
|
;(part[field] as string) = ((part[field] as string | undefined) ?? "") + props.delta
|
||||||
}),
|
}),
|
||||||
@@ -1354,25 +1318,30 @@ export function createServerSession(
|
|||||||
while (true) {
|
while (true) {
|
||||||
const pendingAt = pendingRevision.get(sessionID) ?? 0
|
const pendingAt = pendingRevision.get(sessionID) ?? 0
|
||||||
const formAt = formRevision.get(sessionID) ?? 0
|
const formAt = formRevision.get(sessionID) ?? 0
|
||||||
|
const previous = new Set(data.input[sessionID] ?? [])
|
||||||
const result = await load()
|
const result = await load()
|
||||||
const pendingStable = (pendingRevision.get(sessionID) ?? 0) === pendingAt
|
const pendingStable = (pendingRevision.get(sessionID) ?? 0) === pendingAt
|
||||||
const formStable = (formRevision.get(sessionID) ?? 0) === formAt
|
const formStable = (formRevision.get(sessionID) ?? 0) === formAt
|
||||||
if (pendingStable) {
|
if (pendingStable) {
|
||||||
|
const current = new Set(result.pending.filter((item) => item.type !== "compaction").map((item) => item.id))
|
||||||
|
const settled = settledInputs.get(sessionID) ?? new Set<string>()
|
||||||
|
previous.forEach((messageID) => {
|
||||||
|
if (!current.has(messageID)) settled.add(messageID)
|
||||||
|
})
|
||||||
|
if (settled.size) settledInputs.set(sessionID, settled)
|
||||||
|
result.pending.forEach(v2.confirm)
|
||||||
setData("pending", sessionID, reconcile(result.pending))
|
setData("pending", sessionID, reconcile(result.pending))
|
||||||
setData(
|
setData("input", sessionID, reconcile([...current]))
|
||||||
"input",
|
|
||||||
sessionID,
|
|
||||||
reconcile(result.pending.filter((item) => item.type !== "compaction").map((item) => item.id)),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if (formStable) setData("form", sessionID, reconcile(result.forms))
|
if (formStable) setData("form", sessionID, reconcile(result.forms))
|
||||||
if (pendingStable && formStable) return
|
if (pendingStable && formStable) return
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
refreshPinned(hydrateTransient: (sessionID: string) => Promise<void>) {
|
refreshPinned(hydrateTransient: (sessionID: string) => Promise<void>) {
|
||||||
|
const sessions = [...pinned.keys()]
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
[...pinned.keys()].flatMap((sessionID) => [sync(sessionID, { force: true }), hydrateTransient(sessionID)]),
|
sessions.flatMap((sessionID) => [sync(sessionID, { force: true }), hydrateTransient(sessionID)]),
|
||||||
).then(() => undefined)
|
).then(() => sessions.forEach(reconcileInbox))
|
||||||
},
|
},
|
||||||
invalidate() {
|
invalidate() {
|
||||||
invalidationRevision += 1
|
invalidationRevision += 1
|
||||||
@@ -1381,77 +1350,81 @@ export function createServerSession(
|
|||||||
setMeta("at", {})
|
setMeta("at", {})
|
||||||
},
|
},
|
||||||
prefetch,
|
prefetch,
|
||||||
shouldPrefetch(sessionID: string, limit: number) {
|
shouldPrefetch(sessionID: string, messageCount: number) {
|
||||||
if (data.message[sessionID] === undefined) return true
|
if (data.message[sessionID] === undefined) return true
|
||||||
if (Date.now() - (meta.at[sessionID] ?? 0) > 15_000) return true
|
if (Date.now() - (meta.at[sessionID] ?? 0) > 15_000) return true
|
||||||
if (meta.complete[sessionID]) return false
|
if (meta.complete[sessionID]) return false
|
||||||
return (meta.limit[sessionID] ?? 0) <= limit
|
return (data.message[sessionID]?.length ?? 0) <= messageCount
|
||||||
},
|
},
|
||||||
fresh(sessionID: string, ttl: number) {
|
fresh(sessionID: string, ttl: number) {
|
||||||
return Date.now() - (meta.at[sessionID] ?? 0) <= ttl
|
return Date.now() - (meta.at[sessionID] ?? 0) <= ttl
|
||||||
},
|
},
|
||||||
optimistic: {
|
inbox: {
|
||||||
add(input: { sessionID: string; message: Message; parts: Part[] }) {
|
echo(input: PromptEcho) {
|
||||||
const parts = input.parts
|
const created = Date.now()
|
||||||
.filter((part) => !!part?.id && !SKIP_PARTS.has(part.type))
|
const files = input.files?.map((file) => ({
|
||||||
.sort((a, b) => cmp(a.id, b.id))
|
data: "",
|
||||||
const load = messageLoads.get(input.sessionID)
|
mime: file.mime,
|
||||||
if (load?.clearedMessageParts.has(input.message.id)) {
|
source: { type: "uri" as const, uri: file.uri },
|
||||||
const touched = load.touchedParts.get(input.message.id) ?? new Set<string>()
|
name: file.name,
|
||||||
parts.forEach((part) => touched.add(part.id))
|
mention: file.mention,
|
||||||
load.touchedParts.set(input.message.id, touched)
|
}))
|
||||||
|
const item: SessionInboxInfo = {
|
||||||
|
id: input.messageID,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
timeCreated: created,
|
||||||
|
type: "user",
|
||||||
|
delivery: "steer",
|
||||||
|
payload: { text: input.text, files, agents: input.agents },
|
||||||
}
|
}
|
||||||
if (load) {
|
const projected = normalizeSessionMessages(input.sessionID, [
|
||||||
load.removedMessages.delete(input.message.id)
|
{ id: `${input.messageID}:agent`, type: "agent-switched", agent: input.agent, time: { created } },
|
||||||
load.optimisticParts.set(input.message.id, new Set(parts.map((part) => part.id)))
|
{
|
||||||
}
|
id: `${input.messageID}:model`,
|
||||||
const items = optimistic.get(input.sessionID)
|
type: "model-switched",
|
||||||
const removedMessagesForSession = removedMessages.get(input.sessionID)
|
model: {
|
||||||
removedMessagesForSession?.delete(input.message.id)
|
id: input.model.modelID,
|
||||||
if (removedMessagesForSession?.size === 0) removedMessages.delete(input.sessionID)
|
providerID: input.model.providerID,
|
||||||
if (items) items.set(input.message.id, { ...input, parts, confirmedParts: [] })
|
variant: input.model.variant,
|
||||||
if (!items)
|
},
|
||||||
optimistic.set(input.sessionID, new Map([[input.message.id, { ...input, parts, confirmedParts: [] }]]))
|
time: { created },
|
||||||
indexProjectedMessage(input.message)
|
},
|
||||||
setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]).sort(compareMessages))
|
{
|
||||||
setData(
|
id: input.messageID,
|
||||||
"part_text_accum_delta",
|
type: "user",
|
||||||
produce((draft) => {
|
text: input.displayText,
|
||||||
for (const part of [...(data.part[input.message.id] ?? []), ...parts]) {
|
files,
|
||||||
delete draft[part.id]
|
agents: input.agents,
|
||||||
deltaBases.delete(part.id)
|
time: { created },
|
||||||
}
|
},
|
||||||
}),
|
])
|
||||||
)
|
const message = projected.messages[0]!
|
||||||
setData("part", input.message.id, parts)
|
const comments: Part[] = input.comments.map((comment, index) => ({
|
||||||
|
id: `${input.messageID}:comment:${index}`,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
messageID: input.messageID,
|
||||||
|
type: "text",
|
||||||
|
text: formatCommentNote(comment),
|
||||||
|
synthetic: true,
|
||||||
|
metadata: createCommentMetadata(comment),
|
||||||
|
}))
|
||||||
|
const parts = [...(projected.parts.get(input.messageID) ?? []), ...comments]
|
||||||
|
removedMessages.get(input.sessionID)?.delete(input.messageID)
|
||||||
|
markEcho(input.sessionID, input.messageID)
|
||||||
|
pendingRevision.set(input.sessionID, (pendingRevision.get(input.sessionID) ?? 0) + 1)
|
||||||
|
batch(() => {
|
||||||
|
setData("pending", input.sessionID, (items = []) => [...items.filter((entry) => entry.id !== item.id), item])
|
||||||
|
if (!data.input[input.sessionID]?.includes(input.messageID))
|
||||||
|
setData("input", input.sessionID, [...(data.input[input.sessionID] ?? []), input.messageID])
|
||||||
|
setData("message", input.sessionID, (messages = []) => merge(messages, [message]).sort(compareMessages))
|
||||||
|
setData("part", input.messageID, parts)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
remove(input: { sessionID: string; messageID: string }) {
|
confirm: confirmInbox,
|
||||||
const item = optimistic.get(input.sessionID)?.get(input.messageID)
|
reconcile: reconcileInbox,
|
||||||
if (!item) return
|
clearEcho(input: { sessionID: string; messageID: string }) {
|
||||||
messageLoads.get(input.sessionID)?.optimisticParts.delete(input.messageID)
|
if (echoes.get(input.sessionID)?.get(input.messageID) !== "sending") return false
|
||||||
clearOptimistic(input.sessionID, input.messageID)
|
return removeEcho(input.sessionID, input.messageID)
|
||||||
if (item.confirmedMessage) {
|
|
||||||
const partIDs = new Set(item.parts.map((part) => part.id))
|
|
||||||
setData(
|
|
||||||
produce((draft) => {
|
|
||||||
for (const part of item.parts) {
|
|
||||||
delete draft.part_text_accum_delta[part.id]
|
|
||||||
deltaBases.delete(part.id)
|
|
||||||
}
|
|
||||||
const parts = draft.part[input.messageID]
|
|
||||||
if (!parts) return
|
|
||||||
draft.part[input.messageID] = parts.filter((part) => !partIDs.has(part.id))
|
|
||||||
if (draft.part[input.messageID]?.length === 0) delete draft.part[input.messageID]
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const projectedIDs = new Set(projectMessageSource(item.message).map((message) => message.id))
|
|
||||||
setData("session_message", input.sessionID, (messages) =>
|
|
||||||
messages?.filter((message) => !projectedIDs.has(message.id)),
|
|
||||||
)
|
|
||||||
setData("message", input.sessionID, (messages) => messages?.filter((message) => message.id !== input.messageID))
|
|
||||||
setData(produce((draft) => deleteMessageParts(draft, input.messageID)))
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async todo(sessionID: string, request?: { force?: boolean }) {
|
async todo(sessionID: string, request?: { force?: boolean }) {
|
||||||
@@ -1463,14 +1436,14 @@ export function createServerSession(
|
|||||||
history: {
|
history: {
|
||||||
more: (sessionID: string) =>
|
more: (sessionID: string) =>
|
||||||
data.message[sessionID] !== undefined &&
|
data.message[sessionID] !== undefined &&
|
||||||
meta.limit[sessionID] !== undefined &&
|
meta.complete[sessionID] !== undefined &&
|
||||||
!meta.complete[sessionID] &&
|
!meta.complete[sessionID] &&
|
||||||
!!meta.cursor[sessionID],
|
!!meta.cursor[sessionID],
|
||||||
loading: (sessionID: string) => meta.loading[sessionID] ?? false,
|
loading: (sessionID: string) => meta.loading[sessionID] ?? false,
|
||||||
async loadMore(sessionID: string, count = historyMessagePageSize) {
|
async loadMore(sessionID: string) {
|
||||||
touch(sessionID)
|
touch(sessionID)
|
||||||
if (meta.loading[sessionID] || meta.complete[sessionID] || !meta.cursor[sessionID]) return
|
if (meta.loading[sessionID] || meta.complete[sessionID] || !meta.cursor[sessionID]) return
|
||||||
await loadMessages(sessionID, count, meta.cursor[sessionID], "prepend")
|
await loadMessages(sessionID, meta.cursor[sessionID], "prepend")
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
evict(sessionID: string) {
|
evict(sessionID: string) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
|
|||||||
import type {
|
import type {
|
||||||
McpListInput,
|
McpListInput,
|
||||||
McpResourceCatalogInput,
|
McpResourceCatalogInput,
|
||||||
|
OpenCodeEvent,
|
||||||
SessionApi,
|
SessionApi,
|
||||||
SessionInfo,
|
SessionInfo,
|
||||||
SessionListInput,
|
SessionListInput,
|
||||||
@@ -15,11 +16,13 @@ import {
|
|||||||
loadMcpResourcesQuery,
|
loadMcpResourcesQuery,
|
||||||
reconcileActiveSessionStatuses,
|
reconcileActiveSessionStatuses,
|
||||||
seedActiveSessionStatuses,
|
seedActiveSessionStatuses,
|
||||||
|
sessionListEventDirectories,
|
||||||
shouldRefreshWorkspaceSessions,
|
shouldRefreshWorkspaceSessions,
|
||||||
} from "./server-sync"
|
} from "./server-sync"
|
||||||
import { ServerScope } from "@/utils/server-scope"
|
import { ServerScope } from "@/utils/server-scope"
|
||||||
import { createServerSession } from "./server-session"
|
import { createServerSession } from "./server-session"
|
||||||
import type { ServerApi } from "@/utils/server"
|
import type { ServerApi } from "@/utils/server"
|
||||||
|
import { adaptServerEvent } from "./server-sdk"
|
||||||
|
|
||||||
type McpApi = ServerApi["mcp"]
|
type McpApi = ServerApi["mcp"]
|
||||||
|
|
||||||
@@ -214,6 +217,23 @@ describe("workspace session inventory", () => {
|
|||||||
expect(shouldRefreshWorkspaceSessions(event("session.updated", "session.moved"))).toBe(true)
|
expect(shouldRefreshWorkspaceSessions(event("session.updated", "session.moved"))).toBe(true)
|
||||||
expect(shouldRefreshWorkspaceSessions(event("message.updated"))).toBe(false)
|
expect(shouldRefreshWorkspaceSessions(event("message.updated"))).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("invalidates both locations when a session moves", () => {
|
||||||
|
const event = adaptServerEvent({
|
||||||
|
id: "evt_moved",
|
||||||
|
created: 1,
|
||||||
|
type: "session.moved",
|
||||||
|
durable: { aggregateID: "ses_1", seq: 1, version: 1 },
|
||||||
|
location: { directory: "/source" },
|
||||||
|
data: {
|
||||||
|
sessionID: "ses_1",
|
||||||
|
location: { directory: "/destination" },
|
||||||
|
projectID: "project_2",
|
||||||
|
},
|
||||||
|
} satisfies Extract<OpenCodeEvent, { type: "session.moved" }>)
|
||||||
|
|
||||||
|
expect(sessionListEventDirectories(event)).toEqual(["/source", "/destination"])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("canDisposeDirectory", () => {
|
describe("canDisposeDirectory", () => {
|
||||||
|
|||||||
@@ -88,6 +88,12 @@ const SESSION_LIST_EVENTS = new Set([
|
|||||||
"session.usage.updated",
|
"session.usage.updated",
|
||||||
])
|
])
|
||||||
|
|
||||||
|
export function sessionListEventDirectories(event: ServerEvent) {
|
||||||
|
if (!SESSION_LIST_EVENTS.has(event.current?.type ?? event.type)) return []
|
||||||
|
const destination = event.current?.type === "session.moved" ? event.current.data.location.directory : undefined
|
||||||
|
return [...new Set([event.current?.location?.directory, destination].filter((item): item is string => !!item))]
|
||||||
|
}
|
||||||
|
|
||||||
type McpListApi = {
|
type McpListApi = {
|
||||||
readonly list: (input?: McpListInput) => Promise<McpListOutput>
|
readonly list: (input?: McpListInput) => Promise<McpListOutput>
|
||||||
}
|
}
|
||||||
@@ -231,7 +237,10 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
return { pending, forms }
|
return { pending, forms }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const hydrateSession = (sessionID: string) => Promise.all([session.sync(sessionID), hydrateSessionState(sessionID)])
|
const hydrateSession = async (sessionID: string) => {
|
||||||
|
await Promise.all([session.sync(sessionID), hydrateSessionState(sessionID)])
|
||||||
|
session.inbox.reconcile(sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
|
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
|
||||||
queries: [
|
queries: [
|
||||||
@@ -551,14 +560,11 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
type: "session.updated",
|
type: "session.updated",
|
||||||
properties: { sessionID: info.id, info },
|
properties: { sessionID: info.id, info },
|
||||||
})
|
})
|
||||||
const markSessionListChanged = (event: ServerEvent, directory: string, previousDirectory?: string) => {
|
const markSessionListsChanged = (event: ServerEvent) => {
|
||||||
if (SESSION_LIST_EVENTS.has(event.current?.type ?? event.type)) {
|
sessionListEventDirectories(event).forEach((directory) => {
|
||||||
const key = directoryKey(directory)
|
const key = directoryKey(directory)
|
||||||
sessionRevision.set(key, (sessionRevision.get(key) ?? 0) + 1)
|
sessionRevision.set(key, (sessionRevision.get(key) ?? 0) + 1)
|
||||||
}
|
})
|
||||||
if (!previousDirectory || previousDirectory === directory) return
|
|
||||||
const key = directoryKey(previousDirectory)
|
|
||||||
sessionRevision.set(key, (sessionRevision.get(key) ?? 0) + 1)
|
|
||||||
}
|
}
|
||||||
const toDirectoryEvent = (event: ServerEvent) => {
|
const toDirectoryEvent = (event: ServerEvent) => {
|
||||||
if (event.current?.type === "session.created") return
|
if (event.current?.type === "session.created") return
|
||||||
@@ -569,15 +575,10 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const unsub = serverSDK.event.listen((e) => {
|
const unsub = serverSDK.event.listen((e) => {
|
||||||
const directory = e.name
|
|
||||||
const key = directoryKey(directory)
|
|
||||||
const event = e.details
|
const event = e.details
|
||||||
|
const directory = event.current?.location?.directory
|
||||||
const eventType: string = event.type
|
const eventType: string = event.type
|
||||||
const previousDirectory =
|
markSessionListsChanged(event)
|
||||||
event.current?.type === "session.moved"
|
|
||||||
? session.get(event.current.data.sessionID)?.location.directory
|
|
||||||
: undefined
|
|
||||||
markSessionListChanged(event, directory, previousDirectory)
|
|
||||||
if (event.current) session.applyV2(event.current)
|
if (event.current) session.applyV2(event.current)
|
||||||
session.apply(event)
|
session.apply(event)
|
||||||
if (event.current?.type === "session.moved") {
|
if (event.current?.type === "session.moved") {
|
||||||
@@ -629,9 +630,9 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
}
|
}
|
||||||
homeSessions.refresh(event.type)
|
homeSessions.refresh(event.type)
|
||||||
catalog.handleEvent({ type: eventType, directory })
|
catalog.handleEvent({ type: eventType, directory })
|
||||||
connection.handleEvent({ type: eventType, directory })
|
connection.handleEvent({ type: eventType })
|
||||||
|
|
||||||
if (directory === "global") {
|
if (!directory) {
|
||||||
applyGlobalEvent({
|
applyGlobalEvent({
|
||||||
event,
|
event,
|
||||||
project: globalStore.project,
|
project: globalStore.project,
|
||||||
@@ -644,6 +645,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const key = directoryKey(directory)
|
||||||
if (event.current?.type === "session.forked")
|
if (event.current?.type === "session.forked")
|
||||||
void session
|
void session
|
||||||
.resolve(event.current.data.sessionID, { force: true })
|
.resolve(event.current.data.sessionID, { force: true })
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ test("invalidates global and active catalogs after connection", async () => {
|
|||||||
load: async () => {},
|
load: async () => {},
|
||||||
})
|
})
|
||||||
|
|
||||||
catalog.handleEvent({ type: "server.connected", directory: "global" })
|
catalog.handleEvent({ type: "server.connected" })
|
||||||
await Bun.sleep(0)
|
await Bun.sleep(0)
|
||||||
|
|
||||||
expect(queryClient.getQueryState(global)?.isInvalidated).toBe(true)
|
expect(queryClient.getQueryState(global)?.isInvalidated).toBe(true)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { pathKey, type PathKey } from "@/utils/path-key"
|
|||||||
|
|
||||||
type CatalogEvent = {
|
type CatalogEvent = {
|
||||||
type: string
|
type: string
|
||||||
directory: string
|
directory?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createCatalogSync(input: {
|
export function createCatalogSync(input: {
|
||||||
@@ -24,7 +24,7 @@ export function createCatalogSync(input: {
|
|||||||
event.type === "integration.updated" ||
|
event.type === "integration.updated" ||
|
||||||
event.type === "integration.connection.updated"
|
event.type === "integration.connection.updated"
|
||||||
) {
|
) {
|
||||||
void refresh(event.directory === "global" ? null : pathKey(event.directory)).catch(() => undefined)
|
void refresh(event.directory ? pathKey(event.directory) : null).catch(() => undefined)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,10 +12,8 @@ test("invalidates disconnected data and synchronizes after the handshake", () =>
|
|||||||
connected: () => calls.push("connected"),
|
connected: () => calls.push("connected"),
|
||||||
})
|
})
|
||||||
|
|
||||||
connection.handleEvent({ type: "server.connected", directory: "global" })
|
connection.handleEvent({ type: "server.connected" })
|
||||||
expect(calls).toContain("connected")
|
expect(calls).toContain("connected")
|
||||||
connection.handleEvent({ type: "server.connected", directory: "/repo" })
|
|
||||||
expect(calls.filter((call) => call === "connected")).toHaveLength(1)
|
|
||||||
setStatus("connected")
|
setStatus("connected")
|
||||||
return dispose
|
return dispose
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ export function createConnectionSync(input: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
let connectedOnce = false
|
let connectedOnce = false
|
||||||
function handleEvent(event: { type: string; directory: string }) {
|
function handleEvent(event: { type: string }) {
|
||||||
if (event.directory !== "global" || event.type !== "server.connected") return
|
if (event.type !== "server.connected") return
|
||||||
input.connected({ reconnect: connectedOnce })
|
input.connected({ reconnect: connectedOnce })
|
||||||
connectedOnce = true
|
connectedOnce = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,137 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
import type { Message, Part } from "@/types"
|
|
||||||
import { applyOptimisticAdd, applyOptimisticRemove, mergeOptimisticPage } from "./sync"
|
|
||||||
|
|
||||||
type Text = Extract<Part, { type: "text" }>
|
|
||||||
|
|
||||||
const userMessage = (id: string, sessionID: string, created = 1): Message => ({
|
|
||||||
id,
|
|
||||||
sessionID,
|
|
||||||
role: "user",
|
|
||||||
time: { created },
|
|
||||||
agent: "assistant",
|
|
||||||
model: { providerID: "openai", modelID: "gpt" },
|
|
||||||
})
|
|
||||||
|
|
||||||
const textPart = (id: string, sessionID: string, messageID: string): Text => ({
|
|
||||||
id,
|
|
||||||
sessionID,
|
|
||||||
messageID,
|
|
||||||
type: "text",
|
|
||||||
text: id,
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("sync optimistic reducers", () => {
|
|
||||||
test("applyOptimisticAdd inserts by creation time", () => {
|
|
||||||
const sessionID = "ses_1"
|
|
||||||
const draft = {
|
|
||||||
message: { [sessionID]: [userMessage("msg_z", sessionID, 1)] },
|
|
||||||
part: {} as Record<string, Part[] | undefined>,
|
|
||||||
}
|
|
||||||
|
|
||||||
applyOptimisticAdd(draft, {
|
|
||||||
sessionID,
|
|
||||||
message: userMessage("msg_a", sessionID, 2),
|
|
||||||
parts: [textPart("prt_2", sessionID, "msg_a"), textPart("prt_1", sessionID, "msg_a")],
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(draft.message[sessionID]?.map((x) => x.id)).toEqual(["msg_z", "msg_a"])
|
|
||||||
expect(draft.part.msg_a?.map((x) => x.id)).toEqual(["prt_1", "prt_2"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("applyOptimisticRemove removes message and part entries", () => {
|
|
||||||
const sessionID = "ses_1"
|
|
||||||
const draft = {
|
|
||||||
message: { [sessionID]: [userMessage("msg_1", sessionID), userMessage("msg_2", sessionID)] },
|
|
||||||
part: {
|
|
||||||
msg_1: [textPart("prt_1", sessionID, "msg_1")],
|
|
||||||
msg_2: [textPart("prt_2", sessionID, "msg_2")],
|
|
||||||
} as Record<string, Part[] | undefined>,
|
|
||||||
}
|
|
||||||
|
|
||||||
applyOptimisticRemove(draft, { sessionID, messageID: "msg_1" })
|
|
||||||
|
|
||||||
expect(draft.message[sessionID]?.map((x) => x.id)).toEqual(["msg_2"])
|
|
||||||
expect(draft.part.msg_1).toBeUndefined()
|
|
||||||
expect(draft.part.msg_2).toHaveLength(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("mergeOptimisticPage keeps pending messages in fetched timelines", () => {
|
|
||||||
const sessionID = "ses_1"
|
|
||||||
const page = mergeOptimisticPage(
|
|
||||||
{
|
|
||||||
session: [userMessage("msg_z", sessionID, 1)],
|
|
||||||
part: [{ id: "msg_z", part: [textPart("prt_1", sessionID, "msg_z")] }],
|
|
||||||
complete: true,
|
|
||||||
},
|
|
||||||
[{ message: userMessage("msg_a", sessionID, 2), parts: [textPart("prt_2", sessionID, "msg_a")] }],
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(page.session.map((x) => x.id)).toEqual(["msg_z", "msg_a"])
|
|
||||||
expect(page.part.find((x) => x.id === "msg_a")?.part.map((x) => x.id)).toEqual(["prt_2"])
|
|
||||||
expect(page.confirmed).toEqual([])
|
|
||||||
expect(page.complete).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("mergeOptimisticPage uses IDs only to break equal-time ties", () => {
|
|
||||||
const sessionID = "ses_1"
|
|
||||||
const page = mergeOptimisticPage(
|
|
||||||
{
|
|
||||||
session: [userMessage("msg_z", sessionID, 1)],
|
|
||||||
part: [],
|
|
||||||
complete: true,
|
|
||||||
},
|
|
||||||
[{ message: userMessage("msg_a", sessionID, 1), parts: [] }],
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(page.session.map((message) => message.id)).toEqual(["msg_a", "msg_z"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("mergeOptimisticPage keeps missing optimistic parts until the server has them", () => {
|
|
||||||
const sessionID = "ses_1"
|
|
||||||
const page = mergeOptimisticPage(
|
|
||||||
{
|
|
||||||
session: [userMessage("msg_2", sessionID)],
|
|
||||||
part: [{ id: "msg_2", part: [textPart("prt_2", sessionID, "msg_2")] }],
|
|
||||||
complete: true,
|
|
||||||
},
|
|
||||||
[
|
|
||||||
{
|
|
||||||
message: userMessage("msg_2", sessionID),
|
|
||||||
parts: [textPart("prt_1", sessionID, "msg_2"), textPart("prt_2", sessionID, "msg_2")],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(page.part.find((x) => x.id === "msg_2")?.part.map((x) => x.id)).toEqual(["prt_1", "prt_2"])
|
|
||||||
expect(page.confirmed).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("mergeOptimisticPage confirms echoed messages once all parts arrive", () => {
|
|
||||||
const sessionID = "ses_1"
|
|
||||||
const page = mergeOptimisticPage(
|
|
||||||
{
|
|
||||||
session: [userMessage("msg_2", sessionID)],
|
|
||||||
part: [
|
|
||||||
{
|
|
||||||
id: "msg_2",
|
|
||||||
part: [{ ...textPart("prt_1", sessionID, "msg_2"), text: "server" }, textPart("prt_2", sessionID, "msg_2")],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
complete: true,
|
|
||||||
},
|
|
||||||
[
|
|
||||||
{
|
|
||||||
message: userMessage("msg_2", sessionID),
|
|
||||||
parts: [textPart("prt_1", sessionID, "msg_2"), textPart("prt_2", sessionID, "msg_2")],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(page.confirmed).toEqual(["msg_2"])
|
|
||||||
expect(page.part.find((x) => x.id === "msg_2")?.part).toMatchObject([
|
|
||||||
{ id: "prt_1", type: "text", text: "server" },
|
|
||||||
{ id: "prt_2", type: "text", text: "prt_2" },
|
|
||||||
])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,114 +1,6 @@
|
|||||||
import { Binary } from "@opencode-ai/core/util/binary"
|
|
||||||
import { createMemo } from "solid-js"
|
import { createMemo } from "solid-js"
|
||||||
import { useServerSync } from "./server-sync"
|
import { useServerSync } from "./server-sync"
|
||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import type { Message, Part } from "@/types"
|
|
||||||
import { messageKey } from "@/utils/session-message"
|
|
||||||
|
|
||||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
|
||||||
|
|
||||||
function sortParts(parts: Part[]) {
|
|
||||||
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
|
||||||
|
|
||||||
type OptimisticStore = {
|
|
||||||
message: Record<string, Message[] | undefined>
|
|
||||||
part: Record<string, Part[] | undefined>
|
|
||||||
}
|
|
||||||
|
|
||||||
type OptimisticAddInput = {
|
|
||||||
sessionID: string
|
|
||||||
message: Message
|
|
||||||
parts: Part[]
|
|
||||||
}
|
|
||||||
|
|
||||||
type OptimisticRemoveInput = {
|
|
||||||
sessionID: string
|
|
||||||
messageID: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type OptimisticItem = {
|
|
||||||
message: Message
|
|
||||||
parts: Part[]
|
|
||||||
}
|
|
||||||
|
|
||||||
type MessagePage = {
|
|
||||||
session: Message[]
|
|
||||||
part: { id: string; part: Part[] }[]
|
|
||||||
cursor?: string
|
|
||||||
complete: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasParts = (parts: Part[] | undefined, want: Part[]) => {
|
|
||||||
if (!parts) return want.length === 0
|
|
||||||
return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found)
|
|
||||||
}
|
|
||||||
|
|
||||||
const mergeParts = (parts: Part[] | undefined, want: Part[]) => {
|
|
||||||
if (!parts) return sortParts(want)
|
|
||||||
const next = [...parts]
|
|
||||||
let changed = false
|
|
||||||
for (const part of want) {
|
|
||||||
const result = Binary.search(next, part.id, (item) => item.id)
|
|
||||||
if (result.found) continue
|
|
||||||
next.splice(result.index, 0, part)
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
if (!changed) return parts
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
|
|
||||||
if (items.length === 0) return { ...page, confirmed: [] as string[] }
|
|
||||||
|
|
||||||
const session = [...page.session]
|
|
||||||
const part = new Map(page.part.map((item) => [item.id, sortParts(item.part)]))
|
|
||||||
const confirmed: string[] = []
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
const result = Binary.search(session, messageKey(item.message), messageKey)
|
|
||||||
const found = result.found
|
|
||||||
if (!found) session.splice(result.index, 0, item.message)
|
|
||||||
|
|
||||||
const current = part.get(item.message.id)
|
|
||||||
if (found && hasParts(current, item.parts)) {
|
|
||||||
confirmed.push(item.message.id)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
part.set(item.message.id, mergeParts(current, item.parts))
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
cursor: page.cursor,
|
|
||||||
complete: page.complete,
|
|
||||||
session,
|
|
||||||
part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, part]) => ({ id, part })),
|
|
||||||
confirmed,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) {
|
|
||||||
const messages = draft.message[input.sessionID]
|
|
||||||
if (messages) {
|
|
||||||
const result = Binary.search(messages, messageKey(input.message), messageKey)
|
|
||||||
messages.splice(result.index, 0, input.message)
|
|
||||||
} else {
|
|
||||||
draft.message[input.sessionID] = [input.message]
|
|
||||||
}
|
|
||||||
draft.part[input.message.id] = sortParts(input.parts)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) {
|
|
||||||
const messages = draft.message[input.sessionID]
|
|
||||||
if (messages) {
|
|
||||||
const index = messages.findIndex((message) => message.id === input.messageID)
|
|
||||||
if (index >= 0) messages.splice(index, 1)
|
|
||||||
}
|
|
||||||
delete draft.part[input.messageID]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useSync = () => {
|
export const useSync = () => {
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useQueryOptions } from "@/context/server-sync"
|
|||||||
import { Iterable, pipe } from "effect"
|
import { Iterable, pipe } from "effect"
|
||||||
import { type Accessor } from "solid-js"
|
import { type Accessor } from "solid-js"
|
||||||
import { emptyProviderCatalog } from "./provider-catalog"
|
import { emptyProviderCatalog } from "./provider-catalog"
|
||||||
|
import { useIntegrations } from "./use-integrations"
|
||||||
import { useQuery } from "@tanstack/solid-query"
|
import { useQuery } from "@tanstack/solid-query"
|
||||||
import { pathKey } from "@/utils/path-key"
|
import { pathKey } from "@/utils/path-key"
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ export function useProviders(directory: Accessor<string | undefined>) {
|
|||||||
const dir = directory()
|
const dir = directory()
|
||||||
return queryOpts.providers(dir ? pathKey(dir) : null)
|
return queryOpts.providers(dir ? pathKey(dir) : null)
|
||||||
})
|
})
|
||||||
|
const integrations = useIntegrations(directory)
|
||||||
|
|
||||||
const providers = () => (!providersQuery.isSuccess ? emptyProviderCatalog : providersQuery.data)
|
const providers = () => (!providersQuery.isSuccess ? emptyProviderCatalog : providersQuery.data)
|
||||||
|
|
||||||
@@ -30,13 +32,22 @@ export function useProviders(directory: Accessor<string | undefined>) {
|
|||||||
ready: () => providersQuery.isSuccess,
|
ready: () => providersQuery.isSuccess,
|
||||||
all: () => providers().all,
|
all: () => providers().all,
|
||||||
default: () => providers().default,
|
default: () => providers().default,
|
||||||
popular: () =>
|
// V2 servers list only available providers, so the connectable catalog
|
||||||
pipe(
|
// comes from the integration list, with the provider catalog as fallback.
|
||||||
|
popular: () => {
|
||||||
|
const catalog = integrations
|
||||||
|
.list()
|
||||||
|
.filter((integration) => popularProviderSet.has(integration.id))
|
||||||
|
.map((integration) => ({ id: integration.id, name: integration.name }))
|
||||||
|
const seen = new Set(catalog.map((integration) => integration.id))
|
||||||
|
return pipe(
|
||||||
providers().all,
|
providers().all,
|
||||||
Iterable.map(([, p]) => p),
|
Iterable.map(([, p]) => p),
|
||||||
Iterable.filter((p) => popularProviderSet.has(p.id)),
|
Iterable.filter((p) => popularProviderSet.has(p.id) && !seen.has(p.id)),
|
||||||
(v) => Array.from(v),
|
Iterable.map((p) => ({ id: p.id, name: p.name })),
|
||||||
),
|
(v) => [...catalog, ...v],
|
||||||
|
)
|
||||||
|
},
|
||||||
connected: () => {
|
connected: () => {
|
||||||
const connected = new Set(providers().connected)
|
const connected = new Set(providers().connected)
|
||||||
return pipe(
|
return pipe(
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export type UpdaterState =
|
|||||||
| { status: "disabled" }
|
| { status: "disabled" }
|
||||||
| { status: "idle" }
|
| { status: "idle" }
|
||||||
| { status: "checking" }
|
| { status: "checking" }
|
||||||
| { status: "downloading"; version: string; percent?: number }
|
| { status: "downloading"; version: string }
|
||||||
| { status: "ready"; version: string }
|
| { status: "ready"; version: string }
|
||||||
| { status: "up-to-date" }
|
| { status: "up-to-date" }
|
||||||
| { status: "installing"; version: string }
|
| { status: "installing"; version: string }
|
||||||
|
|||||||
@@ -22,7 +22,10 @@ function blobUrl(id: string, blob: Blob) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function blobID(blob: Blob) {
|
async function blobID(blob: Blob) {
|
||||||
const id = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", await blob.arrayBuffer())))
|
const bytes = crypto.subtle
|
||||||
|
? new Uint8Array(await crypto.subtle.digest("SHA-256", await blob.arrayBuffer()))
|
||||||
|
: crypto.getRandomValues(new Uint8Array(16))
|
||||||
|
const id = Array.from(bytes)
|
||||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||||
.join("")
|
.join("")
|
||||||
return id
|
return id
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ export type ProviderInfo = {
|
|||||||
id: string
|
id: string
|
||||||
integrationID?: string
|
integrationID?: string
|
||||||
name: string
|
name: string
|
||||||
disabled?: boolean
|
activation: "auto" | "enabled" | "disabled"
|
||||||
package: string
|
package: string
|
||||||
settings?: { [x: string]: any }
|
settings?: { [x: string]: any }
|
||||||
headers?: { [x: string]: string }
|
headers?: { [x: string]: string }
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ const layer = Layer.effect(
|
|||||||
const integrations = yield* Integration.Service
|
const integrations = yield* Integration.Service
|
||||||
|
|
||||||
const available = (provider: Provider.Info, integration: Integration.Info | undefined) => {
|
const available = (provider: Provider.Info, integration: Integration.Info | undefined) => {
|
||||||
if (provider.disabled) return false
|
if (provider.activation === "disabled") return false
|
||||||
if (typeof provider.settings?.apiKey === "string") return true
|
if (provider.activation === "enabled") return true
|
||||||
if (integration?.connections.length) return true
|
if (integration?.connections.length) return true
|
||||||
return provider.integrationID === undefined && !integration
|
return provider.integrationID === undefined && !integration
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export const Plugin = define({
|
|||||||
for (const [id, item] of configuredProviders(loaded.entries)) {
|
for (const [id, item] of configuredProviders(loaded.entries)) {
|
||||||
const providerID = id
|
const providerID = id
|
||||||
catalog.provider.update(providerID, (provider) => {
|
catalog.provider.update(providerID, (provider) => {
|
||||||
|
provider.activation = "enabled"
|
||||||
if (item.name !== undefined) provider.name = item.name
|
if (item.name !== undefined) provider.name = item.name
|
||||||
if (item.package !== undefined) provider.package = item.package
|
if (item.package !== undefined) provider.package = item.package
|
||||||
if (item.settings !== undefined) provider.settings = Provider.mergeOverlay(provider.settings, item.settings)
|
if (item.settings !== undefined) provider.settings = Provider.mergeOverlay(provider.settings, item.settings)
|
||||||
|
|||||||
@@ -23,6 +23,32 @@ export type Options = typeof Options.Type
|
|||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem/Search") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem/Search") {}
|
||||||
|
|
||||||
const REFRESH_INTERVAL = Duration.toMillis("10 seconds")
|
const REFRESH_INTERVAL = Duration.toMillis("10 seconds")
|
||||||
|
type Prepared = ReturnType<typeof fuzzysort.prepare>
|
||||||
|
|
||||||
|
function emptyIndex() {
|
||||||
|
return { files: new Map<string, Prepared>(), directories: new Map<string, Prepared>() }
|
||||||
|
}
|
||||||
|
|
||||||
|
function search(index: ReturnType<typeof emptyIndex>, input: FileSystem.FindInput) {
|
||||||
|
const items =
|
||||||
|
input.type === "file"
|
||||||
|
? Array.from(index.files.values())
|
||||||
|
: input.type === "directory"
|
||||||
|
? Array.from(index.directories.values())
|
||||||
|
: [...index.files.values(), ...index.directories.values()]
|
||||||
|
const result = fuzzysort.go(input.query, items, { limit: input.limit ?? 50 })
|
||||||
|
// Targets are owned by the current location index. The only global fuzzysort
|
||||||
|
// state left is its query cache, which must not retain every query forever.
|
||||||
|
fuzzysort.cleanup()
|
||||||
|
return result.map((item) => {
|
||||||
|
const relative = item.target
|
||||||
|
const type = relative.endsWith(path.sep) ? ("directory" as const) : ("file" as const)
|
||||||
|
return FileSystem.Entry.make({
|
||||||
|
path: RelativePath.make(relative),
|
||||||
|
type,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export const ripgrepLayer = Layer.effect(
|
export const ripgrepLayer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
@@ -32,12 +58,13 @@ export const ripgrepLayer = Layer.effect(
|
|||||||
const scope = yield* Scope.Scope
|
const scope = yield* Scope.Scope
|
||||||
const clock = yield* Clock.Clock
|
const clock = yield* Clock.Clock
|
||||||
const home = Protected.isHome(location.directory)
|
const home = Protected.isHome(location.directory)
|
||||||
let index = { files: [] as string[], directories: new Set<string>() }
|
let index = emptyIndex()
|
||||||
let initialized = false
|
let initialized = false
|
||||||
let settledAt = Number.NEGATIVE_INFINITY
|
let settledAt = Number.NEGATIVE_INFINITY
|
||||||
let refreshing = false
|
let refreshing = false
|
||||||
const scan = Effect.gen(function* () {
|
const scan = Effect.gen(function* () {
|
||||||
const next = { files: [] as string[], directories: new Set<string>() }
|
const next = emptyIndex()
|
||||||
|
const previous = index
|
||||||
if (!initialized) index = next
|
if (!initialized) index = next
|
||||||
yield* ripgrep.find({
|
yield* ripgrep.find({
|
||||||
cwd: location.directory,
|
cwd: location.directory,
|
||||||
@@ -46,11 +73,13 @@ export const ripgrepLayer = Layer.effect(
|
|||||||
exclude: home ? [...Protected.names()].map((name) => `${name}/**`) : undefined,
|
exclude: home ? [...Protected.names()].map((name) => `${name}/**`) : undefined,
|
||||||
onEntry: (entry) =>
|
onEntry: (entry) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
next.files.push(entry.path)
|
next.files.set(entry.path, previous.files.get(entry.path) ?? fuzzysort.prepare(entry.path))
|
||||||
const parts = entry.path.split("/")
|
const parts = entry.path.split("/")
|
||||||
parts
|
parts.slice(0, -1).forEach((_, offset) => {
|
||||||
.slice(0, -1)
|
const directory = parts.slice(0, offset + 1).join("/") + path.sep
|
||||||
.forEach((_, offset) => next.directories.add(parts.slice(0, offset + 1).join("/") + path.sep))
|
if (!next.directories.has(directory))
|
||||||
|
next.directories.set(directory, previous.directories.get(directory) ?? fuzzysort.prepare(directory))
|
||||||
|
})
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
index = next
|
index = next
|
||||||
@@ -74,20 +103,7 @@ export const ripgrepLayer = Layer.effect(
|
|||||||
find: (input) =>
|
find: (input) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* refresh
|
yield* refresh
|
||||||
const items =
|
return search(index, input)
|
||||||
input.type === "file"
|
|
||||||
? index.files
|
|
||||||
: input.type === "directory"
|
|
||||||
? Array.from(index.directories)
|
|
||||||
: [...index.files, ...index.directories]
|
|
||||||
return fuzzysort.go(input.query, items, { limit: input.limit ?? 50 }).map((item) => {
|
|
||||||
const relative = item.target
|
|
||||||
const type = relative.endsWith(path.sep) ? ("directory" as const) : ("file" as const)
|
|
||||||
return FileSystem.Entry.make({
|
|
||||||
path: RelativePath.make(relative),
|
|
||||||
type,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Effect, Layer, LayerMap } from "effect"
|
import { Effect, Layer, LayerMap } from "effect"
|
||||||
|
import path from "path"
|
||||||
import { Agent } from "./agent.js"
|
import { Agent } from "./agent.js"
|
||||||
import { AISDK } from "./aisdk.js"
|
import { AISDK } from "./aisdk.js"
|
||||||
import { Catalog } from "./catalog.js"
|
import { Catalog } from "./catalog.js"
|
||||||
@@ -49,6 +50,7 @@ import { ReadToolFileSystem } from "./tool/read-filesystem.js"
|
|||||||
import { Tool } from "./tool.js"
|
import { Tool } from "./tool.js"
|
||||||
import { ToolOutput } from "./tool-output.js"
|
import { ToolOutput } from "./tool-output.js"
|
||||||
import { Vcs } from "./vcs.js"
|
import { Vcs } from "./vcs.js"
|
||||||
|
import { AbsolutePath } from "./schema.js"
|
||||||
|
|
||||||
export { LocationServiceMap } from "./location-service-map.js"
|
export { LocationServiceMap } from "./location-service-map.js"
|
||||||
|
|
||||||
@@ -110,11 +112,13 @@ export type LocationError = LayerNode.Error<typeof locationServices>
|
|||||||
export function buildLocationServiceMap(
|
export function buildLocationServiceMap(
|
||||||
replacements: LayerNode.Replacements = [],
|
replacements: LayerNode.Replacements = [],
|
||||||
): Layer.Layer<LocationServiceMap.Service> {
|
): Layer.Layer<LocationServiceMap.Service> {
|
||||||
// Structural Equal is own-key-set sensitive, so `{ directory }` (schema-decoded
|
// Structural Equal distinguishes optional-key shape and Windows separator style.
|
||||||
// payloads omit optional keys) and `{ directory, workspaceID: undefined }` are
|
// The RcMap caches the raw key before the build callback, so normalize both here.
|
||||||
// different RcMap keys. The RcMap caches by the raw key before the build
|
const canonical = (ref: Location.Ref) =>
|
||||||
// callback runs, so canonicalize at the map boundary to the key-present shape.
|
Location.Ref.make({
|
||||||
const canonical = (ref: Location.Ref) => Location.Ref.make({ directory: ref.directory, workspaceID: ref.workspaceID })
|
directory: AbsolutePath.make(process.platform === "win32" ? path.normalize(ref.directory) : ref.directory),
|
||||||
|
workspaceID: ref.workspaceID,
|
||||||
|
})
|
||||||
return Layer.effect(
|
return Layer.effect(
|
||||||
LocationServiceMap.Service,
|
LocationServiceMap.Service,
|
||||||
Effect.map(
|
Effect.map(
|
||||||
|
|||||||
@@ -358,17 +358,21 @@ export const layer = Layer.effect(
|
|||||||
const connection = yield* integrations.connection.active(
|
const connection = yield* integrations.connection.active(
|
||||||
provider?.integrationID ?? Integration.ID.make(selected.providerID),
|
provider?.integrationID ?? Integration.ID.make(selected.providerID),
|
||||||
)
|
)
|
||||||
const model = yield* resolveModel(
|
const credential = connection ? yield* integrations.connection.resolve(connection) : undefined
|
||||||
selected,
|
const runtimeInfo = yield* withVariant(selected, variant)
|
||||||
variant,
|
const model = yield* fromCatalogModel(runtimeInfo, credential, {
|
||||||
connection ? yield* integrations.connection.resolve(connection) : undefined,
|
loadPackage: (specifier) => Provider.loadPackage(specifier, npm),
|
||||||
{
|
loadAISDK: (model) => aisdk.model(model),
|
||||||
loadPackage: (specifier) => Provider.loadPackage(specifier, npm),
|
})
|
||||||
loadAISDK: (model) => aisdk.model(model),
|
const runtime =
|
||||||
},
|
provider?.activation === "enabled" &&
|
||||||
)
|
credential === undefined &&
|
||||||
|
!hasConfiguredAuth(runtimeInfo) &&
|
||||||
|
usesAPIKeyAuth(runtimeInfo.package)
|
||||||
|
? LanguageModel.update(model, { route: model.route.with({ auth: Auth.none }) })
|
||||||
|
: model
|
||||||
return {
|
return {
|
||||||
model,
|
model: runtime,
|
||||||
ref: Ref.make({
|
ref: Ref.make({
|
||||||
id: selected.id,
|
id: selected.id,
|
||||||
providerID: selected.providerID,
|
providerID: selected.providerID,
|
||||||
@@ -399,6 +403,35 @@ export const layer = Layer.effect(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
function hasConfiguredAuth(model: Info) {
|
||||||
|
return [model.settings?.apiKey, model.settings?.authToken, model.settings?.accessToken].some(
|
||||||
|
(value) => typeof value === "string" && value !== "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function usesAPIKeyAuth(packageName: string | undefined) {
|
||||||
|
const name = Provider.packageName(packageName)
|
||||||
|
return (
|
||||||
|
name === "@ai-sdk/openai" ||
|
||||||
|
name === "@ai-sdk/anthropic" ||
|
||||||
|
name === "@ai-sdk/openai-compatible" ||
|
||||||
|
name === "@ai-sdk/google" ||
|
||||||
|
name === "@ai-sdk/xai" ||
|
||||||
|
name === "@openrouter/ai-sdk-provider" ||
|
||||||
|
name === "@ai-sdk/azure" ||
|
||||||
|
name === "@opencode-ai/ai/providers/openai" ||
|
||||||
|
name?.startsWith("@opencode-ai/ai/providers/openai/") === true ||
|
||||||
|
name === "@opencode-ai/ai/providers/anthropic" ||
|
||||||
|
name === "@opencode-ai/ai/providers/anthropic-compatible" ||
|
||||||
|
name === "@opencode-ai/ai/providers/openai-compatible" ||
|
||||||
|
name === "@opencode-ai/ai/providers/google" ||
|
||||||
|
name === "@opencode-ai/ai/providers/xai" ||
|
||||||
|
name === "@opencode-ai/ai/providers/openrouter" ||
|
||||||
|
name === "@opencode-ai/ai/providers/azure" ||
|
||||||
|
name?.startsWith("@opencode-ai/ai/providers/azure/") === true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const node = makeLocationNode({
|
export const node = makeLocationNode({
|
||||||
service: Service,
|
service: Service,
|
||||||
layer,
|
layer,
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ function normalize(input: Record<string, SourceProvider>): readonly Snapshot[] {
|
|||||||
const info = {
|
const info = {
|
||||||
id: providerID,
|
id: providerID,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
|
activation: "auto",
|
||||||
package: Provider.aisdk(item.npm),
|
package: Provider.aisdk(item.npm),
|
||||||
...(item.api ? { settings: { baseURL: item.api } } : {}),
|
...(item.api ? { settings: { baseURL: item.api } } : {}),
|
||||||
} satisfies Provider.Info
|
} satisfies Provider.Info
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const LLMGatewayPlugin = define({
|
|||||||
const configured = new Set((yield* integrations.list()).map((integration) => integration.id))
|
const configured = new Set((yield* integrations.list()).map((integration) => integration.id))
|
||||||
yield* ctx.catalog.transform((evt) => {
|
yield* ctx.catalog.transform((evt) => {
|
||||||
for (const item of evt.provider.list()) {
|
for (const item of evt.provider.list()) {
|
||||||
if (item.provider.disabled) continue
|
if (item.provider.activation === "disabled") continue
|
||||||
if (!Provider.isAISDK(item.provider.package)) continue
|
if (!Provider.isAISDK(item.provider.package)) continue
|
||||||
if (Provider.packageName(item.provider.package) !== "@ai-sdk/openai-compatible") continue
|
if (Provider.packageName(item.provider.package) !== "@ai-sdk/openai-compatible") continue
|
||||||
if (item.provider.settings?.baseURL !== "https://api.llmgateway.io/v1") continue
|
if (item.provider.settings?.baseURL !== "https://api.llmgateway.io/v1") continue
|
||||||
|
|||||||
@@ -178,7 +178,10 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
|
|||||||
if (!item) return
|
if (!item) return
|
||||||
const hasKey = Boolean(process.env.OPENCODE_API_KEY || connected || item.provider.settings?.apiKey)
|
const hasKey = Boolean(process.env.OPENCODE_API_KEY || connected || item.provider.settings?.apiKey)
|
||||||
catalog.provider.update(item.provider.id, (provider) => {
|
catalog.provider.update(item.provider.id, (provider) => {
|
||||||
if (!hasKey) provider.settings = { ...provider.settings, apiKey: "public" }
|
if (!hasKey) {
|
||||||
|
provider.activation = "enabled"
|
||||||
|
provider.settings = { ...provider.settings, apiKey: "public" }
|
||||||
|
}
|
||||||
})
|
})
|
||||||
if (hasKey) return
|
if (hasKey) return
|
||||||
for (const model of item.models.values()) {
|
for (const model of item.models.values()) {
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ every field, examples, config locations, and links to dedicated feature guides.
|
|||||||
For any request to migrate OpenCode configuration, agents, commands, skills,
|
For any request to migrate OpenCode configuration, agents, commands, skills,
|
||||||
plugins, integrations, or other behavior from V1 to V2, read the full
|
plugins, integrations, or other behavior from V1 to V2, read the full
|
||||||
[migration guide](https://opencode.ai/v2/docs/migrate-v1) before acting. In
|
[migration guide](https://opencode.ai/v2/docs/migrate-v1) before acting. In
|
||||||
the repository, its source is `packages/www/content/docs/(Get started)/migrate-v1.mdx`.
|
the repository, its source is `packages/www/content/docs/migrate-v1.mdx`.
|
||||||
|
|
||||||
V1 config files and `.opencode/` definitions are intended to remain compatible.
|
V1 config files and `.opencode/` definitions are intended to remain compatible.
|
||||||
The only intentional breaking changes are the server API and plugin API. Native
|
The only intentional breaking changes are the server API and plugin API. Native
|
||||||
|
|||||||
@@ -789,7 +789,10 @@ const layer = Layer.effect(
|
|||||||
return false
|
return false
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
if (recovered) return
|
if (recovered) {
|
||||||
|
yield* execution.wakeActive(input.sessionID)
|
||||||
|
return
|
||||||
|
}
|
||||||
yield* execution.wake(input.sessionID)
|
yield* execution.wake(input.sessionID)
|
||||||
}),
|
}),
|
||||||
compact: Effect.fn("Session.compact")(function* (input) {
|
compact: Effect.fn("Session.compact")(function* (input) {
|
||||||
@@ -873,12 +876,7 @@ const layer = Layer.effect(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
interrupt: Effect.fn("Session.interrupt")((sessionID, options) =>
|
interrupt: Effect.fn("Session.interrupt")((sessionID, options) =>
|
||||||
Effect.uninterruptible(
|
Effect.uninterruptible(execution.interrupt(sessionID, options)),
|
||||||
Effect.gen(function* () {
|
|
||||||
yield* execution.interrupt(sessionID)
|
|
||||||
if (options?.continue && (yield* SessionInbox.has(db, sessionID, "any"))) yield* execution.wake(sessionID)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
revert: {
|
revert: {
|
||||||
stage: Effect.fn("Session.revert.stage")(function* (input) {
|
stage: Effect.fn("Session.revert.stage")(function* (input) {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
export * as SessionExecution from "./execution.js"
|
export * as SessionExecution from "./execution.js"
|
||||||
|
|
||||||
import { Cause, Context, Effect, Exit, Layer, Stream } from "effect"
|
import { Cause, Context, Effect, Exit, Layer } from "effect"
|
||||||
import { Bus } from "../bus.js"
|
import { Bus } from "../bus.js"
|
||||||
|
import { Database } from "../database/database.js"
|
||||||
import { LocationServiceMap } from "../location-service-map.js"
|
import { LocationServiceMap } from "../location-service-map.js"
|
||||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||||
import { SessionEvent } from "./event.js"
|
import { SessionEvent } from "./event.js"
|
||||||
@@ -11,6 +12,7 @@ import { SessionSchema } from "./schema.js"
|
|||||||
import { SessionStore } from "./store.js"
|
import { SessionStore } from "./store.js"
|
||||||
import { toSessionError } from "./to-session-error.js"
|
import { toSessionError } from "./to-session-error.js"
|
||||||
import { UserInterruptedError } from "./error.js"
|
import { UserInterruptedError } from "./error.js"
|
||||||
|
import { SessionInbox } from "./inbox.js"
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
/** Snapshots active execution owned by this process. */
|
/** Snapshots active execution owned by this process. */
|
||||||
@@ -19,8 +21,10 @@ export interface Interface {
|
|||||||
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
|
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
|
||||||
/** Registers newly recorded work. Repeated wakeups may coalesce. */
|
/** Registers newly recorded work. Repeated wakeups may coalesce. */
|
||||||
readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||||
|
/** Wakes only an active execution, preserving its current input eligibility. */
|
||||||
|
readonly wakeActive: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||||
/** Interrupt active work owned by this process. Idle interruption is a no-op. */
|
/** Interrupt active work owned by this process. Idle interruption is a no-op. */
|
||||||
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
readonly interrupt: (sessionID: SessionSchema.ID, options?: { readonly continue?: boolean }) => Effect.Effect<void>
|
||||||
/** Resolves once this process owns no active execution for the Session. Returns immediately when idle and never starts work. */
|
/** Resolves once this process owns no active execution for the Session. Returns immediately when idle and never starts work. */
|
||||||
readonly awaitIdle: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
readonly awaitIdle: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||||
}
|
}
|
||||||
@@ -45,6 +49,7 @@ export const layer = Layer.effect(
|
|||||||
const store = yield* SessionStore.Service
|
const store = yield* SessionStore.Service
|
||||||
const locations = yield* LocationServiceMap.Service
|
const locations = yield* LocationServiceMap.Service
|
||||||
const bus = yield* Bus.Service
|
const bus = yield* Bus.Service
|
||||||
|
const db = (yield* Database.Service).db
|
||||||
const reportLifecycle = <A>(sessionID: SessionSchema.ID, effect: Effect.Effect<A>) =>
|
const reportLifecycle = <A>(sessionID: SessionSchema.ID, effect: Effect.Effect<A>) =>
|
||||||
effect.pipe(
|
effect.pipe(
|
||||||
Effect.tapCause((cause) =>
|
Effect.tapCause((cause) =>
|
||||||
@@ -71,12 +76,13 @@ export const layer = Layer.effect(
|
|||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
force: boolean,
|
force: boolean,
|
||||||
continuation?: SessionRunner.Continuation,
|
continuation?: SessionRunner.Continuation,
|
||||||
|
promotable: SessionInbox.Promotable = "input",
|
||||||
): Effect.Effect<void, SessionRunner.RunError> {
|
): Effect.Effect<void, SessionRunner.RunError> {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const session = yield* store.get(sessionID)
|
const session = yield* store.get(sessionID)
|
||||||
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
|
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
|
||||||
const result = yield* SessionRunner.Service.use((runner) =>
|
const result = yield* SessionRunner.Service.use((runner) =>
|
||||||
runner.drain({ sessionID, force, continuation }),
|
runner.drain({ sessionID, force, continuation, promotable }),
|
||||||
).pipe(
|
).pipe(
|
||||||
Effect.provide(locations.get(session.location)),
|
Effect.provide(locations.get(session.location)),
|
||||||
Effect.tapCause((cause) =>
|
Effect.tapCause((cause) =>
|
||||||
@@ -86,7 +92,7 @@ export const layer = Layer.effect(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (result.type === "complete") return
|
if (result.type === "complete") return
|
||||||
return yield* drain(sessionID, false, result.continuation)
|
return yield* drain(sessionID, false, result.continuation, promotable)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError, InterruptReason>({
|
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError, InterruptReason>({
|
||||||
@@ -95,7 +101,7 @@ export const layer = Layer.effect(
|
|||||||
sessionID,
|
sessionID,
|
||||||
bus.publish(SessionEvent.Execution.Started, { sessionID }, claimOnCommit(sessionID)),
|
bus.publish(SessionEvent.Execution.Started, { sessionID }, claimOnCommit(sessionID)),
|
||||||
),
|
),
|
||||||
drain: (sessionID, force) => drain(sessionID, force),
|
drain: (sessionID, force, promotable) => drain(sessionID, force, undefined, promotable),
|
||||||
// One terminal observation per busy period, covering every coalesced drain.
|
// One terminal observation per busy period, covering every coalesced drain.
|
||||||
settled: (sessionID, exit, reason) =>
|
settled: (sessionID, exit, reason) =>
|
||||||
reportLifecycle(
|
reportLifecycle(
|
||||||
@@ -127,16 +133,20 @@ export const layer = Layer.effect(
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
yield* bus.subscribe(SessionEvent.Moved).pipe(
|
|
||||||
Stream.runForEach((event) => coordinator.wake(event.data.sessionID)),
|
|
||||||
Effect.forkScoped,
|
|
||||||
)
|
|
||||||
|
|
||||||
return Service.of({
|
return Service.of({
|
||||||
active: coordinator.active,
|
active: coordinator.active,
|
||||||
interrupt: (sessionID) => coordinator.interrupt(sessionID, "user"),
|
interrupt: (sessionID, options) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* coordinator.interrupt(sessionID, "user")
|
||||||
|
if (!options?.continue) return
|
||||||
|
// Resume only steering input from the interrupted intent. Queued next-turn work
|
||||||
|
// stays parked: a steer-scoped drain never promotes queue-delivery rows.
|
||||||
|
if (yield* SessionInbox.has(db, sessionID, "steer")) yield* coordinator.wake(sessionID, "steer")
|
||||||
|
}),
|
||||||
resume: coordinator.run,
|
resume: coordinator.run,
|
||||||
wake: coordinator.wake,
|
wake: coordinator.wake,
|
||||||
|
wakeActive: coordinator.wakeActive,
|
||||||
awaitIdle: coordinator.awaitIdle,
|
awaitIdle: coordinator.awaitIdle,
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
@@ -145,7 +155,7 @@ export const layer = Layer.effect(
|
|||||||
export const node = makeGlobalNode({
|
export const node = makeGlobalNode({
|
||||||
service: Service,
|
service: Service,
|
||||||
layer,
|
layer,
|
||||||
deps: [SessionStore.node, LocationServiceMap.node, Bus.node],
|
deps: [SessionStore.node, LocationServiceMap.node, Bus.node, Database.node],
|
||||||
})
|
})
|
||||||
|
|
||||||
/** Low-level compatibility layer for callers that only need durable Session recording. */
|
/** Low-level compatibility layer for callers that only need durable Session recording. */
|
||||||
@@ -155,6 +165,7 @@ export const noopLayer = Layer.succeed(
|
|||||||
active: Effect.succeed(new Set()),
|
active: Effect.succeed(new Set()),
|
||||||
resume: () => Effect.void,
|
resume: () => Effect.void,
|
||||||
wake: () => Effect.void,
|
wake: () => Effect.void,
|
||||||
|
wakeActive: () => Effect.void,
|
||||||
interrupt: () => Effect.void,
|
interrupt: () => Effect.void,
|
||||||
awaitIdle: () => Effect.void,
|
awaitIdle: () => Effect.void,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -349,6 +349,14 @@ export const nextSteer = Effect.fn("SessionInbox.nextSteer")(function* (
|
|||||||
return row ? fromRow(row) : undefined
|
return row ? fromRow(row) : undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const nextPromotable = Effect.fn("SessionInbox.nextPromotable")(function* (
|
||||||
|
db: DatabaseService,
|
||||||
|
sessionID: SessionSchema.ID,
|
||||||
|
promotable: Promotable,
|
||||||
|
) {
|
||||||
|
return (yield* nextSteer(db, sessionID)) ?? (promotable === "input" ? yield* nextQueued(db, sessionID) : undefined)
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Which pending rows count: "any" counts every row, while "input" means any
|
* Which pending rows count: "any" counts every row, while "input" means any
|
||||||
* item in either delivery mode.
|
* item in either delivery mode.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export * as SessionRunCoordinator from "./run-coordinator.js"
|
export * as SessionRunCoordinator from "./run-coordinator.js"
|
||||||
|
|
||||||
import { Deferred, Effect, Exit, Fiber, FiberSet, Scope } from "effect"
|
import { Deferred, Effect, Exit, Fiber, FiberSet, Scope } from "effect"
|
||||||
|
import type { Promotable } from "./inbox.js"
|
||||||
|
|
||||||
/** Serializes execution for each key while allowing different keys to run concurrently. */
|
/** Serializes execution for each key while allowing different keys to run concurrently. */
|
||||||
export interface Coordinator<Key, E, Reason = never> {
|
export interface Coordinator<Key, E, Reason = never> {
|
||||||
@@ -9,7 +10,9 @@ export interface Coordinator<Key, E, Reason = never> {
|
|||||||
/** Starts an execution while idle, or joins the active execution and returns its exit. */
|
/** Starts an execution while idle, or joins the active execution and returns its exit. */
|
||||||
readonly run: (key: Key) => Effect.Effect<void, E>
|
readonly run: (key: Key) => Effect.Effect<void, E>
|
||||||
/** Rings the doorbell: an idle key starts an execution; an active one drains again before settling. */
|
/** Rings the doorbell: an idle key starts an execution; an active one drains again before settling. */
|
||||||
readonly wake: (key: Key) => Effect.Effect<void>
|
readonly wake: (key: Key, scope?: Promotable) => Effect.Effect<void>
|
||||||
|
/** Rings the current execution's doorbell with its existing scope. Idle keys remain idle. */
|
||||||
|
readonly wakeActive: (key: Key) => Effect.Effect<void>
|
||||||
/** Stops the active execution, clears its doorbell, and waits for cleanup. No-op when idle. */
|
/** Stops the active execution, clears its doorbell, and waits for cleanup. No-op when idle. */
|
||||||
readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect<void>
|
readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect<void>
|
||||||
/** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */
|
/** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */
|
||||||
@@ -19,14 +22,16 @@ export interface Coordinator<Key, E, Reason = never> {
|
|||||||
/**
|
/**
|
||||||
* One execution is a busy period for one key: one fiber that drains from the first wake
|
* One execution is a busy period for one key: one fiber that drains from the first wake
|
||||||
* until the key would stay idle. `pendingWake` is the doorbell: work recorded during the
|
* until the key would stay idle. `pendingWake` is the doorbell: work recorded during the
|
||||||
* execution rings it, and the execution loop drains again instead of ending. The doorbell
|
* execution rings it with the scope that work needs, and the execution loop drains again
|
||||||
* closes the gap between a drain's last eligibility check and the idle transition, since
|
* instead of ending. The doorbell closes the gap between a drain's last eligibility check
|
||||||
* those cannot be one atomic step. `done` resolves joiners with this execution's exit.
|
* and the idle transition, since those cannot be one atomic step. `done` resolves joiners
|
||||||
|
* with this execution's exit.
|
||||||
*/
|
*/
|
||||||
type Execution<E, Reason> = {
|
type Execution<E, Reason> = {
|
||||||
readonly done: Deferred.Deferred<void, E>
|
readonly done: Deferred.Deferred<void, E>
|
||||||
owner?: Fiber.Fiber<void>
|
owner?: Fiber.Fiber<void>
|
||||||
pendingWake: boolean
|
scope: Promotable
|
||||||
|
pendingWake?: Promotable
|
||||||
stopping: boolean
|
stopping: boolean
|
||||||
interruptionReason?: Reason
|
interruptionReason?: Reason
|
||||||
}
|
}
|
||||||
@@ -43,7 +48,7 @@ type Execution<E, Reason> = {
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export const make = <Key, E, Reason = never>(options: {
|
export const make = <Key, E, Reason = never>(options: {
|
||||||
readonly drain: (key: Key, force: boolean) => Effect.Effect<void, E>
|
readonly drain: (key: Key, force: boolean, scope: Promotable) => Effect.Effect<void, E>
|
||||||
/** Runs once when a process-local busy period begins, before its first drain. */
|
/** Runs once when a process-local busy period begins, before its first drain. */
|
||||||
readonly started?: (key: Key) => Effect.Effect<void>
|
readonly started?: (key: Key) => Effect.Effect<void>
|
||||||
/**
|
/**
|
||||||
@@ -57,21 +62,22 @@ export const make = <Key, E, Reason = never>(options: {
|
|||||||
const fork = yield* FiberSet.makeRuntime<never, void, never>()
|
const fork = yield* FiberSet.makeRuntime<never, void, never>()
|
||||||
|
|
||||||
const loop = (key: Key, execution: Execution<E, Reason>, force: boolean): Effect.Effect<void, E> =>
|
const loop = (key: Key, execution: Execution<E, Reason>, force: boolean): Effect.Effect<void, E> =>
|
||||||
Effect.suspend(() => options.drain(key, force)).pipe(
|
Effect.suspend(() => options.drain(key, force, execution.scope)).pipe(
|
||||||
Effect.flatMap(() =>
|
Effect.flatMap(() =>
|
||||||
Effect.suspend(() => {
|
Effect.suspend(() => {
|
||||||
if (execution.stopping || !execution.pendingWake) return Effect.void
|
if (execution.stopping || execution.pendingWake === undefined) return Effect.void
|
||||||
execution.pendingWake = false
|
execution.scope = execution.pendingWake
|
||||||
|
execution.pendingWake = undefined
|
||||||
// Trampoline so drains that complete synchronously cannot grow the stack.
|
// Trampoline so drains that complete synchronously cannot grow the stack.
|
||||||
return Effect.yieldNow.pipe(Effect.andThen(loop(key, execution, false)))
|
return Effect.yieldNow.pipe(Effect.andThen(loop(key, execution, false)))
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const start = (key: Key, force: boolean) => {
|
const start = (key: Key, force: boolean, scope: Promotable) => {
|
||||||
const execution: Execution<E, Reason> = {
|
const execution: Execution<E, Reason> = {
|
||||||
done: Deferred.makeUnsafe<void, E>(),
|
done: Deferred.makeUnsafe<void, E>(),
|
||||||
pendingWake: false,
|
scope,
|
||||||
stopping: false,
|
stopping: false,
|
||||||
}
|
}
|
||||||
executions.set(key, execution)
|
executions.set(key, execution)
|
||||||
@@ -98,7 +104,7 @@ export const make = <Key, E, Reason = never>(options: {
|
|||||||
// A doorbell that survives the execution loop (rung after the loop decided to end, or
|
// A doorbell that survives the execution loop (rung after the loop decided to end, or
|
||||||
// during failure or interruption cleanup) starts a fresh execution for the remaining work.
|
// during failure or interruption cleanup) starts a fresh execution for the remaining work.
|
||||||
const settle = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>) => {
|
const settle = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>) => {
|
||||||
if (execution.pendingWake) start(key, false)
|
if (execution.pendingWake) start(key, false, execution.pendingWake)
|
||||||
else executions.delete(key)
|
else executions.delete(key)
|
||||||
Deferred.doneUnsafe(execution.done, exit)
|
Deferred.doneUnsafe(execution.done, exit)
|
||||||
}
|
}
|
||||||
@@ -111,17 +117,24 @@ export const make = <Key, E, Reason = never>(options: {
|
|||||||
if (execution.stopping) return Deferred.await(execution.done).pipe(Effect.andThen(run(key)))
|
if (execution.stopping) return Deferred.await(execution.done).pipe(Effect.andThen(run(key)))
|
||||||
return Deferred.await(execution.done)
|
return Deferred.await(execution.done)
|
||||||
}
|
}
|
||||||
return Deferred.await(start(key, true).done)
|
return Deferred.await(start(key, true, "input").done)
|
||||||
})
|
})
|
||||||
|
|
||||||
const wake = (key: Key) =>
|
const wake = (key: Key, scope: Promotable = "input") =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
const execution = executions.get(key)
|
const execution = executions.get(key)
|
||||||
if (execution !== undefined) {
|
if (execution !== undefined) {
|
||||||
execution.pendingWake = true
|
// Coalesced wakes keep the widest scope: "input" subsumes "steer".
|
||||||
|
execution.pendingWake = execution.pendingWake === "input" ? "input" : scope
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
start(key, false)
|
start(key, false, scope)
|
||||||
|
})
|
||||||
|
|
||||||
|
const wakeActive = (key: Key) =>
|
||||||
|
Effect.suspend(() => {
|
||||||
|
const execution = executions.get(key)
|
||||||
|
return execution ? wake(key, execution.scope) : Effect.void
|
||||||
})
|
})
|
||||||
|
|
||||||
const interrupt = (key: Key, reason?: Reason): Effect.Effect<void> =>
|
const interrupt = (key: Key, reason?: Reason): Effect.Effect<void> =>
|
||||||
@@ -129,7 +142,9 @@ export const make = <Key, E, Reason = never>(options: {
|
|||||||
const execution = executions.get(key)
|
const execution = executions.get(key)
|
||||||
if (execution?.owner === undefined || execution.stopping) return Effect.void
|
if (execution?.owner === undefined || execution.stopping) return Effect.void
|
||||||
execution.stopping = true
|
execution.stopping = true
|
||||||
execution.pendingWake = false
|
// Wakes recorded so far belong to the interrupted intent; the interrupt claims them.
|
||||||
|
// Wakes arriving during cleanup are new admissions and restart normally at settle.
|
||||||
|
execution.pendingWake = undefined
|
||||||
execution.interruptionReason = reason
|
execution.interruptionReason = reason
|
||||||
return Fiber.interrupt(execution.owner)
|
return Fiber.interrupt(execution.owner)
|
||||||
})
|
})
|
||||||
@@ -143,5 +158,5 @@ export const make = <Key, E, Reason = never>(options: {
|
|||||||
return Deferred.await(execution.done).pipe(Effect.exit, Effect.andThen(awaitIdle(key)))
|
return Deferred.await(execution.done).pipe(Effect.exit, Effect.andThen(awaitIdle(key)))
|
||||||
})
|
})
|
||||||
|
|
||||||
return { active: Effect.sync(() => new Set(executions.keys())), run, wake, interrupt, awaitIdle }
|
return { active: Effect.sync(() => new Set(executions.keys())), run, wake, wakeActive, interrupt, awaitIdle }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export * as SessionRunner from "./index.js"
|
|||||||
import type { AIError } from "@opencode-ai/ai"
|
import type { AIError } from "@opencode-ai/ai"
|
||||||
import { Context, Effect } from "effect"
|
import { Context, Effect } from "effect"
|
||||||
import { SessionSchema } from "../schema.js"
|
import { SessionSchema } from "../schema.js"
|
||||||
|
import type { Promotable } from "../inbox.js"
|
||||||
import type { AgentNotFoundError, MessageDecodeError, StepFailedError, UserInterruptedError } from "../error.js"
|
import type { AgentNotFoundError, MessageDecodeError, StepFailedError, UserInterruptedError } from "../error.js"
|
||||||
import { SessionRunnerModel } from "./model.js"
|
import { SessionRunnerModel } from "./model.js"
|
||||||
import type { Instructions } from "../../instructions/index.js"
|
import type { Instructions } from "../../instructions/index.js"
|
||||||
@@ -29,6 +30,8 @@ export interface Interface {
|
|||||||
readonly sessionID: SessionSchema.ID
|
readonly sessionID: SessionSchema.ID
|
||||||
readonly force: boolean
|
readonly force: boolean
|
||||||
readonly continuation?: Continuation
|
readonly continuation?: Continuation
|
||||||
|
/** "steer" settles the active intent without promoting queued next-turn work. */
|
||||||
|
readonly promotable?: Promotable
|
||||||
}) => Effect.Effect<DrainResult, RunError>
|
}) => Effect.Effect<DrainResult, RunError>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -128,22 +128,25 @@ const layer = Layer.effect(
|
|||||||
readonly sessionID: SessionSchema.ID
|
readonly sessionID: SessionSchema.ID
|
||||||
readonly force: boolean
|
readonly force: boolean
|
||||||
readonly continuation?: Continuation
|
readonly continuation?: Continuation
|
||||||
|
readonly promotable?: SessionInbox.Promotable
|
||||||
}) {
|
}) {
|
||||||
let force = input.force
|
let force = input.force
|
||||||
let continuation = input.continuation
|
let continuation = input.continuation
|
||||||
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, "any")))
|
const promotable = input.promotable ?? "input"
|
||||||
|
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, promotable)))
|
||||||
return { type: "complete" as const }
|
return { type: "complete" as const }
|
||||||
yield* settleStaleToolCalls(input.sessionID)
|
yield* settleStaleToolCalls(input.sessionID)
|
||||||
while (true) {
|
while (true) {
|
||||||
if (yield* runPendingCompaction(input.sessionID)) {
|
if (yield* runPendingCompaction(input.sessionID, promotable)) {
|
||||||
force = false
|
force = false
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (yield* runPendingMove(input.sessionID, "input")) return { type: "moved" as const }
|
if (yield* runPendingMove(input.sessionID, promotable)) return { type: "moved" as const }
|
||||||
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, "input")))
|
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, promotable)))
|
||||||
return { type: "complete" as const }
|
return { type: "complete" as const }
|
||||||
const result = yield* runSteps(input.sessionID, continuation)
|
const result = yield* runSteps(input.sessionID, continuation, promotable)
|
||||||
if (result.type === "moved") return result
|
if (result.type === "moved") return result
|
||||||
|
if (promotable === "steer") return { type: "complete" as const }
|
||||||
force = false
|
force = false
|
||||||
continuation = undefined
|
continuation = undefined
|
||||||
}
|
}
|
||||||
@@ -155,14 +158,15 @@ const layer = Layer.effect(
|
|||||||
*/
|
*/
|
||||||
const runSteps = Effect.fn("SessionRunner.runSteps")(function* (
|
const runSteps = Effect.fn("SessionRunner.runSteps")(function* (
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
continuation?: Continuation,
|
continuation: Continuation | undefined,
|
||||||
|
drainPromotable: SessionInbox.Promotable,
|
||||||
) {
|
) {
|
||||||
// Fresh work may promote queued input; later steps absorb steers only.
|
// Fresh work may promote queued input; resumed turns and later steps absorb steers only.
|
||||||
let promotable: SessionInbox.Promotable = continuation ? "steer" : "input"
|
let promotable: SessionInbox.Promotable = continuation ? "steer" : drainPromotable
|
||||||
let step = continuation?.step ?? 1
|
let step = continuation?.step ?? 1
|
||||||
let next = continuation
|
let next = continuation
|
||||||
while (true) {
|
while (true) {
|
||||||
if (yield* runPendingCompaction(sessionID)) continue
|
if (yield* runPendingCompaction(sessionID, "steer")) continue
|
||||||
if (yield* runPendingMove(sessionID, "steer")) return { type: "moved" as const, continuation: next }
|
if (yield* runPendingMove(sessionID, "steer")) return { type: "moved" as const, continuation: next }
|
||||||
const result = yield* runStep(sessionID, promotable, step)
|
const result = yield* runStep(sessionID, promotable, step)
|
||||||
next = result.needsContinuation ? { step: result.step + 1 } : undefined
|
next = result.needsContinuation ? { step: result.step + 1 } : undefined
|
||||||
@@ -515,14 +519,14 @@ const layer = Layer.effect(
|
|||||||
/** Executes a previously admitted manual compaction request, if one is pending. */
|
/** Executes a previously admitted manual compaction request, if one is pending. */
|
||||||
const runPendingCompaction = Effect.fn("SessionRunner.runPendingCompaction")(function* (
|
const runPendingCompaction = Effect.fn("SessionRunner.runPendingCompaction")(function* (
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
|
promotable: SessionInbox.Promotable,
|
||||||
) {
|
) {
|
||||||
return yield* Effect.uninterruptibleMask((restore) =>
|
return yield* Effect.uninterruptibleMask((restore) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const pending = yield* SessionInbox.serialized(
|
const pending = yield* SessionInbox.serialized(
|
||||||
sessionID,
|
sessionID,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const selected =
|
const selected = yield* SessionInbox.nextPromotable(db, sessionID, promotable)
|
||||||
(yield* SessionInbox.nextSteer(db, sessionID)) ?? (yield* SessionInbox.nextQueued(db, sessionID))
|
|
||||||
if (selected?.type !== "compaction") return
|
if (selected?.type !== "compaction") return
|
||||||
yield* bus.publishAll([
|
yield* bus.publishAll([
|
||||||
[SessionEvent.InboxDelivered, { sessionID, inboxID: selected.id }],
|
[SessionEvent.InboxDelivered, { sessionID, inboxID: selected.id }],
|
||||||
@@ -564,9 +568,7 @@ const layer = Layer.effect(
|
|||||||
return yield* SessionInbox.serialized(
|
return yield* SessionInbox.serialized(
|
||||||
sessionID,
|
sessionID,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const pending =
|
const pending = yield* SessionInbox.nextPromotable(db, sessionID, promotable)
|
||||||
(yield* SessionInbox.nextSteer(db, sessionID)) ??
|
|
||||||
(promotable === "input" ? yield* SessionInbox.nextQueued(db, sessionID) : undefined)
|
|
||||||
if (pending?.type !== "move") return false
|
if (pending?.type !== "move") return false
|
||||||
yield* modelTransport.close(sessionID)
|
yield* modelTransport.close(sessionID)
|
||||||
yield* bus.publishAll([
|
yield* bus.publishAll([
|
||||||
|
|||||||
@@ -102,6 +102,35 @@ describe("Catalog", () => {
|
|||||||
}).pipe(Effect.provide(localCatalogLayer))
|
}).pipe(Effect.provide(localCatalogLayer))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.effect("makes an explicitly enabled provider available without a connection", () => {
|
||||||
|
const integrationID = Integration.ID.make("gateway")
|
||||||
|
const providerID = Provider.ID.make("remote")
|
||||||
|
const localCatalogLayer = Layer.fresh(
|
||||||
|
AppNodeBuilder.build(LayerNode.group([Catalog.node, Credential.node, Integration.node]), [
|
||||||
|
[Location.node, locationLayer],
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
const catalog = yield* Catalog.Service
|
||||||
|
yield* (yield* Integration.Service).transform((editor) => editor.update(integrationID, () => {}))
|
||||||
|
yield* catalog.transform((editor) =>
|
||||||
|
editor.provider.update(providerID, (provider) => {
|
||||||
|
provider.integrationID = integrationID
|
||||||
|
provider.settings = { baseURL: "https://gateway.example.com/v1" }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(yield* catalog.provider.available()).toEqual([])
|
||||||
|
|
||||||
|
yield* catalog.transform((editor) =>
|
||||||
|
editor.provider.update(providerID, (provider) => {
|
||||||
|
provider.activation = "enabled"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([providerID])
|
||||||
|
}).pipe(Effect.provide(localCatalogLayer))
|
||||||
|
})
|
||||||
|
|
||||||
it.effect("projects environment connections without a catalog plugin", () =>
|
it.effect("projects environment connections without a catalog plugin", () =>
|
||||||
Effect.acquireUseRelease(
|
Effect.acquireUseRelease(
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
@@ -278,7 +307,7 @@ describe("Catalog", () => {
|
|||||||
const fallbackModel = Model.ID.make("fallback")
|
const fallbackModel = Model.ID.make("fallback")
|
||||||
yield* catalog.transform((catalog) => {
|
yield* catalog.transform((catalog) => {
|
||||||
catalog.provider.update(disabledProvider, (provider) => {
|
catalog.provider.update(disabledProvider, (provider) => {
|
||||||
provider.disabled = true
|
provider.activation = "disabled"
|
||||||
})
|
})
|
||||||
catalog.model.update(disabledProvider, disabledModel, () => {})
|
catalog.model.update(disabledProvider, disabledModel, () => {})
|
||||||
catalog.provider.update(enabledProvider, () => {})
|
catalog.provider.update(enabledProvider, () => {})
|
||||||
|
|||||||
@@ -342,7 +342,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
|||||||
names: ["CUSTOM_API_KEY"],
|
names: ["CUSTOM_API_KEY"],
|
||||||
})
|
})
|
||||||
expect((yield* integrations.get(Integration.ID.make("custom")))?.name).toBe("Renamed")
|
expect((yield* integrations.get(Integration.ID.make("custom")))?.name).toBe("Renamed")
|
||||||
expect(provider.disabled).toBeUndefined()
|
expect(provider.activation).toBe("enabled")
|
||||||
expect(provider.package).toBe("aisdk:custom-sdk")
|
expect(provider.package).toBe("aisdk:custom-sdk")
|
||||||
expect(provider.settings).toEqual({ baseURL: "https://example.test" })
|
expect(provider.settings).toEqual({ baseURL: "https://example.test" })
|
||||||
expect(provider.headers).toEqual({ first: "first", shared: "last", last: "last" })
|
expect(provider.headers).toEqual({ first: "first", shared: "last", last: "last" })
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, spyOn, test } from "bun:test"
|
||||||
|
import fuzzysort from "fuzzysort"
|
||||||
import os from "os"
|
import os from "os"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Deferred, Effect, Layer } from "effect"
|
import { Deferred, Effect, Layer } from "effect"
|
||||||
@@ -123,4 +124,58 @@ describe("FileSystemSearch", () => {
|
|||||||
}).pipe(Effect.provide(layer), Effect.provide(TestClock.layer()), Effect.scoped),
|
}).pipe(Effect.provide(layer), Effect.provide(TestClock.layer()), Effect.scoped),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("reuses location-owned fuzzy targets across index refreshes", async () => {
|
||||||
|
let scans = 0
|
||||||
|
const first = Effect.runSync(Deferred.make<void>())
|
||||||
|
const second = Effect.runSync(Deferred.make<void>())
|
||||||
|
const prepare = spyOn(fuzzysort, "prepare")
|
||||||
|
const cleanup = spyOn(fuzzysort, "cleanup")
|
||||||
|
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
|
||||||
|
[
|
||||||
|
Location.node,
|
||||||
|
Layer.succeed(
|
||||||
|
Location.Service,
|
||||||
|
Location.Service.of(
|
||||||
|
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-cache")) }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
Ripgrep.node,
|
||||||
|
Layer.succeed(
|
||||||
|
Ripgrep.Service,
|
||||||
|
Ripgrep.Service.of({
|
||||||
|
find: (input) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
scans++
|
||||||
|
const entry = FileSystem.Entry.make({ path: RelativePath.make("src/index.ts"), type: "file" })
|
||||||
|
if (input.onEntry) yield* input.onEntry(entry)
|
||||||
|
yield* Deferred.succeed(scans === 1 ? first : second, undefined)
|
||||||
|
return [entry]
|
||||||
|
}),
|
||||||
|
glob: () => Effect.succeed([]),
|
||||||
|
grep: () => Effect.succeed([]),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
])
|
||||||
|
|
||||||
|
await Effect.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const search = yield* FileSystemSearch.Service
|
||||||
|
yield* Deferred.await(first)
|
||||||
|
yield* search.find({ query: "index", type: "file" })
|
||||||
|
yield* TestClock.adjust("10 seconds")
|
||||||
|
yield* search.find({ query: "index", type: "file" })
|
||||||
|
yield* Deferred.await(second)
|
||||||
|
yield* search.find({ query: "index", type: "file" })
|
||||||
|
|
||||||
|
expect(prepare).toHaveBeenCalledTimes(2)
|
||||||
|
expect(cleanup).toHaveBeenCalledTimes(3)
|
||||||
|
}).pipe(Effect.provide(layer), Effect.provide(TestClock.layer()), Effect.scoped),
|
||||||
|
)
|
||||||
|
prepare.mockRestore()
|
||||||
|
cleanup.mockRestore()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -510,7 +510,7 @@ describe("LocationServiceMap", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("normalizes ref key shapes to one cached location graph", () =>
|
it.live("normalizes equivalent refs to one cached location graph", () =>
|
||||||
Effect.acquireRelease(
|
Effect.acquireRelease(
|
||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||||
@@ -520,16 +520,20 @@ describe("LocationServiceMap", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const locations = yield* LocationServiceMap.Service
|
const locations = yield* LocationServiceMap.Service
|
||||||
const directory = AbsolutePath.make(dir.path)
|
const directory = AbsolutePath.make(dir.path)
|
||||||
const absent = Location.Ref.make({ directory })
|
const alternate = AbsolutePath.make(directory.replaceAll("\\", "/"))
|
||||||
|
const absent = Location.Ref.make({ directory: alternate })
|
||||||
const present = Location.Ref.make({ directory, workspaceID: undefined })
|
const present = Location.Ref.make({ directory, workspaceID: undefined })
|
||||||
// The two shapes are not structurally Equal: own-key sets differ.
|
// The two shapes are not structurally Equal: own-key sets differ.
|
||||||
expect(Object.keys(absent)).toEqual(["directory"])
|
expect(Object.keys(absent)).toEqual(["directory"])
|
||||||
expect(Object.keys(present)).toEqual(["directory", "workspaceID"])
|
expect(Object.keys(present)).toEqual(["directory", "workspaceID"])
|
||||||
expect(Equal.equals(absent, present)).toBe(false)
|
expect(Equal.equals(absent, present)).toBe(false)
|
||||||
|
if (process.platform === "win32") expect(absent.directory).not.toBe(present.directory)
|
||||||
|
|
||||||
const first = yield* locations.contextEffect(absent)
|
const first = yield* locations.contextEffect(absent)
|
||||||
expect(yield* locations.contextEffect(present)).toBe(first)
|
expect(yield* locations.contextEffect(present)).toBe(first)
|
||||||
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toHaveLength(1)
|
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([
|
||||||
|
Location.Ref.make({ directory, workspaceID: undefined }),
|
||||||
|
])
|
||||||
|
|
||||||
// Invalidating with the shape opposite to the one that booted must evict.
|
// Invalidating with the shape opposite to the one that booted must evict.
|
||||||
yield* locations.invalidate(present)
|
yield* locations.invalidate(present)
|
||||||
|
|||||||
@@ -2,13 +2,16 @@ import { describe, expect } from "bun:test"
|
|||||||
import { LLM, LanguageModel } from "@opencode-ai/ai"
|
import { LLM, LanguageModel } from "@opencode-ai/ai"
|
||||||
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||||
import { compileRequest } from "@opencode-ai/ai/route/client"
|
import { compileRequest } from "@opencode-ai/ai/route/client"
|
||||||
import { Effect } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { Headers } from "effect/unstable/http"
|
import { Headers } from "effect/unstable/http"
|
||||||
import { Credential } from "@opencode-ai/core/credential"
|
import { Credential } from "@opencode-ai/core/credential"
|
||||||
import { Integration } from "@opencode-ai/core/integration"
|
import { Integration } from "@opencode-ai/core/integration"
|
||||||
import { Compatibility, ID, Info, VariantID } from "@opencode-ai/core/model"
|
import { Compatibility, ID, Info, VariantID } from "@opencode-ai/core/model"
|
||||||
import { Provider } from "@opencode-ai/core/provider"
|
import { Provider } from "@opencode-ai/core/provider"
|
||||||
import { ModelResolver } from "@opencode-ai/core/model-resolver"
|
import { ModelResolver } from "@opencode-ai/core/model-resolver"
|
||||||
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
|
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||||
|
import { Npm } from "@opencode-ai/util/npm"
|
||||||
import { it } from "./lib/effect"
|
import { it } from "./lib/effect"
|
||||||
|
|
||||||
interface ModelOptions {
|
interface ModelOptions {
|
||||||
@@ -269,6 +272,109 @@ describe("ModelResolver", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("uses no native API-key auth for an explicitly enabled provider without credentials", () => {
|
||||||
|
const selected = model(Provider.aisdk("@ai-sdk/google"), {
|
||||||
|
providerID: Provider.ID.make("gateway"),
|
||||||
|
settings: { baseURL: "https://gateway.example.com/v1" },
|
||||||
|
headers: { "cf-access-token": "access-token" },
|
||||||
|
})
|
||||||
|
const provider = Provider.Info.make({
|
||||||
|
...Provider.Info.empty(selected.providerID),
|
||||||
|
activation: "enabled",
|
||||||
|
package: selected.package ?? "",
|
||||||
|
settings: selected.settings,
|
||||||
|
headers: selected.headers,
|
||||||
|
})
|
||||||
|
const catalog = Layer.mock(Catalog.Service, {
|
||||||
|
provider: {
|
||||||
|
get: () => Effect.succeed(provider),
|
||||||
|
all: () => Effect.die("unused"),
|
||||||
|
available: () => Effect.die("unused"),
|
||||||
|
},
|
||||||
|
model: {
|
||||||
|
get: () => Effect.succeed(selected),
|
||||||
|
all: () => Effect.die("unused"),
|
||||||
|
available: () => Effect.die("unused"),
|
||||||
|
default: () => Effect.die("unused"),
|
||||||
|
small: () => Effect.die("unused"),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const integrations = Layer.mock(Integration.Service, {
|
||||||
|
connection: {
|
||||||
|
active: () => Effect.succeed(undefined),
|
||||||
|
resolve: () => Effect.die("unused"),
|
||||||
|
key: () => Effect.die("unused"),
|
||||||
|
update: () => Effect.die("unused"),
|
||||||
|
remove: () => Effect.die("unused"),
|
||||||
|
},
|
||||||
|
oauth: {
|
||||||
|
connect: () => Effect.die("unused"),
|
||||||
|
status: () => Effect.die("unused"),
|
||||||
|
complete: () => Effect.die("unused"),
|
||||||
|
cancel: () => Effect.die("unused"),
|
||||||
|
},
|
||||||
|
command: {
|
||||||
|
connect: () => Effect.die("unused"),
|
||||||
|
status: () => Effect.die("unused"),
|
||||||
|
cancel: () => Effect.die("unused"),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const npm = Layer.mock(Npm.Service, {
|
||||||
|
add: () => Effect.die("unused"),
|
||||||
|
which: () => Effect.die("unused"),
|
||||||
|
})
|
||||||
|
const aisdk = Layer.mock(AISDK.Service, {
|
||||||
|
hook: {
|
||||||
|
sdk: () => Effect.die("unused"),
|
||||||
|
language: () => Effect.die("unused"),
|
||||||
|
},
|
||||||
|
model: () => Effect.die("unused"),
|
||||||
|
})
|
||||||
|
const layer = ModelResolver.layer.pipe(Layer.provide(Layer.mergeAll(catalog, integrations, npm, aisdk)))
|
||||||
|
|
||||||
|
return withEnv({ GOOGLE_GENERATIVE_AI_API_KEY: undefined }, () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const resolver = yield* ModelResolver.Service
|
||||||
|
const resolved = yield* resolver.resolveModel(selected)
|
||||||
|
|
||||||
|
const headers = yield* resolved.model.route.auth.apply({
|
||||||
|
request: LLM.request({ model: resolved.model, prompt: "Hello" }),
|
||||||
|
method: "POST",
|
||||||
|
url: "https://gateway.example.com/v1",
|
||||||
|
body: "{}",
|
||||||
|
headers: Headers.fromInput(resolved.model.route.defaults.headers),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(headers["cf-access-token"]).toBe("access-token")
|
||||||
|
expect(headers.authorization).toBeUndefined()
|
||||||
|
expect(headers["x-goog-api-key"]).toBeUndefined()
|
||||||
|
}).pipe(Effect.provide(layer)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.effect("keeps native provider environment auth strict when no API key is configured", () =>
|
||||||
|
withEnv({ GOOGLE_GENERATIVE_AI_API_KEY: undefined }, () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||||
|
model(Provider.aisdk("@ai-sdk/google"), {
|
||||||
|
settings: { baseURL: "https://google.example.com/v1" },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const exit = yield* Effect.exit(
|
||||||
|
resolved.route.auth.apply({
|
||||||
|
request: LLM.request({ model: resolved, prompt: "Hello" }),
|
||||||
|
method: "POST",
|
||||||
|
url: "https://google.example.com/v1",
|
||||||
|
body: "{}",
|
||||||
|
headers: Headers.empty,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(exit._tag).toBe("Failure")
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("uses merged API settings for OpenAI-compatible auth and request defaults", () =>
|
it.effect("uses merged API settings for OpenAI-compatible auth and request defaults", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ const fixtureSnapshot = [
|
|||||||
info: {
|
info: {
|
||||||
id: Provider.ID.make("acme"),
|
id: Provider.ID.make("acme"),
|
||||||
name: "Acme",
|
name: "Acme",
|
||||||
|
activation: "auto",
|
||||||
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
||||||
},
|
},
|
||||||
models: [
|
models: [
|
||||||
@@ -109,6 +110,7 @@ const fixture2Snapshot = [
|
|||||||
info: {
|
info: {
|
||||||
id: Provider.ID.make("beta"),
|
id: Provider.ID.make("beta"),
|
||||||
name: "Beta",
|
name: "Beta",
|
||||||
|
activation: "auto",
|
||||||
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
||||||
},
|
},
|
||||||
models: [
|
models: [
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ describe("ModelsDevPlugin", () => {
|
|||||||
info: {
|
info: {
|
||||||
id: providerID,
|
id: providerID,
|
||||||
name: "Acme",
|
name: "Acme",
|
||||||
|
activation: "auto",
|
||||||
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
||||||
settings: { baseURL: "https://api.acme.test/v1" },
|
settings: { baseURL: "https://api.acme.test/v1" },
|
||||||
},
|
},
|
||||||
@@ -239,6 +240,7 @@ describe("ModelsDevPlugin", () => {
|
|||||||
info: {
|
info: {
|
||||||
id: providerID,
|
id: providerID,
|
||||||
name: "Acme",
|
name: "Acme",
|
||||||
|
activation: "auto",
|
||||||
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
||||||
},
|
},
|
||||||
environment: [],
|
environment: [],
|
||||||
@@ -330,6 +332,7 @@ describe("ModelsDevPlugin", () => {
|
|||||||
info: {
|
info: {
|
||||||
id: providerID,
|
id: providerID,
|
||||||
name: "Acme",
|
name: "Acme",
|
||||||
|
activation: "auto",
|
||||||
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
||||||
settings: { baseURL: "https://${ACME_HOST}/${UNDECLARED_HOST}/v1" },
|
settings: { baseURL: "https://${ACME_HOST}/${UNDECLARED_HOST}/v1" },
|
||||||
},
|
},
|
||||||
@@ -385,6 +388,7 @@ describe("ModelsDevPlugin", () => {
|
|||||||
info: {
|
info: {
|
||||||
id: Provider.ID.make(id),
|
id: Provider.ID.make(id),
|
||||||
name,
|
name,
|
||||||
|
activation: "auto",
|
||||||
package: Provider.aisdk(packageName),
|
package: Provider.aisdk(packageName),
|
||||||
},
|
},
|
||||||
environment: id === "azure" ? ["AZURE_RESOURCE_NAME", environment] : [environment],
|
environment: id === "azure" ? ["AZURE_RESOURCE_NAME", environment] : [environment],
|
||||||
|
|||||||
@@ -60,14 +60,14 @@ describe("LLMGatewayPlugin", () => {
|
|||||||
})
|
})
|
||||||
yield* catalog.transform((catalog) => {
|
yield* catalog.transform((catalog) => {
|
||||||
catalog.provider.update(Provider.ID.make("llmgateway"), (provider) => {
|
catalog.provider.update(Provider.ID.make("llmgateway"), (provider) => {
|
||||||
provider.disabled = true
|
provider.activation = "disabled"
|
||||||
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
|
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
|
||||||
provider.settings = { baseURL: "https://api.llmgateway.io/v1" }
|
provider.settings = { baseURL: "https://api.llmgateway.io/v1" }
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
yield* addPlugin()
|
yield* addPlugin()
|
||||||
|
|
||||||
expect((yield* catalog.provider.get(Provider.ID.make("llmgateway")))?.disabled).toBe(true)
|
expect((yield* catalog.provider.get(Provider.ID.make("llmgateway")))?.activation).toBe("disabled")
|
||||||
expect((yield* catalog.provider.get(Provider.ID.make("llmgateway")))?.headers).toBeUndefined()
|
expect((yield* catalog.provider.get(Provider.ID.make("llmgateway")))?.headers).toBeUndefined()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -347,6 +347,8 @@ describe("OpencodePlugin", () => {
|
|||||||
})
|
})
|
||||||
yield* addPlugin()
|
yield* addPlugin()
|
||||||
expect(required(yield* catalog.provider.get(Provider.ID.opencode)).settings?.apiKey).toBe("public")
|
expect(required(yield* catalog.provider.get(Provider.ID.opencode)).settings?.apiKey).toBe("public")
|
||||||
|
expect(required(yield* catalog.provider.get(Provider.ID.opencode)).activation).toBe("enabled")
|
||||||
|
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(Provider.ID.opencode)
|
||||||
expect(required(yield* catalog.model.get(Provider.ID.opencode, Model.ID.make("free"))).enabled).toBe(true)
|
expect(required(yield* catalog.model.get(Provider.ID.opencode, Model.ID.make("free"))).enabled).toBe(true)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ import { SessionExecution } from "@opencode-ai/core/session/execution"
|
|||||||
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
|
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
|
||||||
import { UserInterruptedError } from "@opencode-ai/core/session/error"
|
import { UserInterruptedError } from "@opencode-ai/core/session/error"
|
||||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||||
|
import { SessionInbox } from "@opencode-ai/core/session/inbox"
|
||||||
|
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||||
import { SessionRunner } from "@opencode-ai/core/session/runner/index"
|
import { SessionRunner } from "@opencode-ai/core/session/runner/index"
|
||||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
import { SessionInboxTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||||
import { Context, Deferred, Effect, Exit, Fiber, Layer, LayerMap, Scope } from "effect"
|
import { Context, Deferred, Effect, Exit, Fiber, Layer, LayerMap, Scope } from "effect"
|
||||||
import { eq } from "drizzle-orm"
|
import { eq } from "drizzle-orm"
|
||||||
@@ -290,6 +292,113 @@ describe("SessionExecution lifecycle", () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("SessionExecution interrupt continuation", () => {
|
||||||
|
it.effect("resumes only steering input after an interrupt with continue", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const database = yield* Database.Service
|
||||||
|
const sessionID = Session.ID.make("ses_continue_steer")
|
||||||
|
yield* seedSessions(database, [sessionID])
|
||||||
|
yield* seedInbox(database, sessionID, ["steer", "queue"])
|
||||||
|
|
||||||
|
const draining = yield* Deferred.make<void>()
|
||||||
|
const drains: Array<{ force: boolean; promotable?: SessionInbox.Promotable }> = []
|
||||||
|
const scope = yield* Scope.make()
|
||||||
|
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
|
||||||
|
const context = yield* buildExecution(scope, (input) =>
|
||||||
|
Effect.suspend(() => {
|
||||||
|
drains.push({ force: input.force, promotable: input.promotable })
|
||||||
|
if (drains.length > 1) return Effect.void
|
||||||
|
return Deferred.succeed(draining, undefined).pipe(Effect.andThen(Effect.never))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const execution = Context.get(context, SessionExecution.Service)
|
||||||
|
yield* execution.resume(sessionID).pipe(Effect.forkScoped)
|
||||||
|
yield* Deferred.await(draining)
|
||||||
|
|
||||||
|
yield* execution.interrupt(sessionID, { continue: true })
|
||||||
|
yield* execution.awaitIdle(sessionID)
|
||||||
|
|
||||||
|
// The successor drain is steer-scoped: queued next-turn work stays parked.
|
||||||
|
expect(drains).toEqual([
|
||||||
|
{ force: true, promotable: "input" },
|
||||||
|
{ force: false, promotable: "steer" },
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("stays parked after an interrupt with continue when only queued work remains", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const database = yield* Database.Service
|
||||||
|
const sessionID = Session.ID.make("ses_continue_parked")
|
||||||
|
yield* seedSessions(database, [sessionID])
|
||||||
|
yield* seedInbox(database, sessionID, ["queue"])
|
||||||
|
|
||||||
|
const draining = yield* Deferred.make<void>()
|
||||||
|
const drains: Array<SessionInbox.Promotable | undefined> = []
|
||||||
|
const scope = yield* Scope.make()
|
||||||
|
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
|
||||||
|
const context = yield* buildExecution(scope, (input) =>
|
||||||
|
Effect.suspend(() => {
|
||||||
|
drains.push(input.promotable)
|
||||||
|
return Deferred.succeed(draining, undefined).pipe(Effect.andThen(Effect.never))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const execution = Context.get(context, SessionExecution.Service)
|
||||||
|
yield* execution.resume(sessionID).pipe(Effect.forkScoped)
|
||||||
|
yield* Deferred.await(draining)
|
||||||
|
|
||||||
|
yield* execution.interrupt(sessionID, { continue: true })
|
||||||
|
yield* execution.awaitIdle(sessionID)
|
||||||
|
|
||||||
|
expect(drains).toEqual(["input"])
|
||||||
|
expect(yield* execution.active).toEqual(new Set())
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("an idle interrupt with continue resumes pending steers", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const database = yield* Database.Service
|
||||||
|
const sessionID = Session.ID.make("ses_continue_idle")
|
||||||
|
yield* seedSessions(database, [sessionID])
|
||||||
|
yield* seedInbox(database, sessionID, ["steer"])
|
||||||
|
|
||||||
|
const drains: Array<{ force: boolean; promotable?: SessionInbox.Promotable }> = []
|
||||||
|
const scope = yield* Scope.make()
|
||||||
|
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
|
||||||
|
const context = yield* buildExecution(scope, (input) =>
|
||||||
|
Effect.sync(() => void drains.push({ force: input.force, promotable: input.promotable })),
|
||||||
|
)
|
||||||
|
const execution = Context.get(context, SessionExecution.Service)
|
||||||
|
|
||||||
|
yield* execution.interrupt(sessionID, { continue: true })
|
||||||
|
yield* execution.awaitIdle(sessionID)
|
||||||
|
|
||||||
|
expect(drains).toEqual([{ force: false, promotable: "steer" }])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function seedInbox(
|
||||||
|
database: Database.Service["Service"],
|
||||||
|
sessionID: Session.ID,
|
||||||
|
deliveries: ReadonlyArray<SessionInbox.Delivery>,
|
||||||
|
) {
|
||||||
|
return database.db
|
||||||
|
.insert(SessionInboxTable)
|
||||||
|
.values(
|
||||||
|
deliveries.map((delivery, index) => ({
|
||||||
|
id: SessionMessage.ID.create(),
|
||||||
|
session_id: sessionID,
|
||||||
|
type: "compaction" as const,
|
||||||
|
payload: {},
|
||||||
|
delivery,
|
||||||
|
enqueued_seq: index + 1,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
}
|
||||||
|
|
||||||
function seedSessions(
|
function seedSessions(
|
||||||
database: Database.Service["Service"],
|
database: Database.Service["Service"],
|
||||||
sessionIDs: ReadonlyArray<Session.ID>,
|
sessionIDs: ReadonlyArray<Session.ID>,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { testEffect } from "./lib/effect"
|
|||||||
|
|
||||||
const executionCalls: Session.ID[] = []
|
const executionCalls: Session.ID[] = []
|
||||||
const interruptCalls: Session.ID[] = []
|
const interruptCalls: Session.ID[] = []
|
||||||
|
const interruptContinuations: Array<boolean | undefined> = []
|
||||||
const wakeCalls: Session.ID[] = []
|
const wakeCalls: Session.ID[] = []
|
||||||
const activeSessions = new Set<Session.ID>()
|
const activeSessions = new Set<Session.ID>()
|
||||||
const execution = Layer.succeed(
|
const execution = Layer.succeed(
|
||||||
@@ -41,14 +42,16 @@ const execution = Layer.succeed(
|
|||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
executionCalls.push(sessionID)
|
executionCalls.push(sessionID)
|
||||||
}),
|
}),
|
||||||
interrupt: (sessionID) =>
|
interrupt: (sessionID, options) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
interruptCalls.push(sessionID)
|
interruptCalls.push(sessionID)
|
||||||
|
interruptContinuations.push(options?.continue)
|
||||||
}),
|
}),
|
||||||
wake: (sessionID) =>
|
wake: (sessionID) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
wakeCalls.push(sessionID)
|
wakeCalls.push(sessionID)
|
||||||
}),
|
}),
|
||||||
|
wakeActive: () => Effect.void,
|
||||||
awaitIdle: () => Effect.void,
|
awaitIdle: () => Effect.void,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -177,31 +180,18 @@ describe("Session.prompt", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("continues after interruption when pending work remains", () =>
|
it.effect("forwards interrupt continuation policy", () =>
|
||||||
Effect.gen(function* () {
|
|
||||||
yield* setup
|
|
||||||
const session = yield* Session.Service
|
|
||||||
yield* session.synthetic({ sessionID, text: "Continue after interrupt", resume: false })
|
|
||||||
interruptCalls.length = 0
|
|
||||||
wakeCalls.length = 0
|
|
||||||
|
|
||||||
yield* session.interrupt(sessionID, { continue: true })
|
|
||||||
|
|
||||||
expect(interruptCalls).toEqual([sessionID])
|
|
||||||
expect(wakeCalls).toEqual([sessionID])
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("does not continue after interruption without pending work", () =>
|
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* setup
|
yield* setup
|
||||||
const session = yield* Session.Service
|
const session = yield* Session.Service
|
||||||
interruptCalls.length = 0
|
interruptCalls.length = 0
|
||||||
|
interruptContinuations.length = 0
|
||||||
wakeCalls.length = 0
|
wakeCalls.length = 0
|
||||||
|
|
||||||
yield* session.interrupt(sessionID, { continue: true })
|
yield* session.interrupt(sessionID, { continue: true })
|
||||||
|
|
||||||
expect(interruptCalls).toEqual([sessionID])
|
expect(interruptCalls).toEqual([sessionID])
|
||||||
|
expect(interruptContinuations).toEqual([true])
|
||||||
expect(wakeCalls).toEqual([])
|
expect(wakeCalls).toEqual([])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
|
import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
|
||||||
|
import { SessionInbox } from "@opencode-ai/core/session/inbox"
|
||||||
import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator"
|
import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
@@ -269,6 +270,28 @@ describe("SessionRunCoordinator", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("a settlement-window wake starts a fresh execution with its own scope", () =>
|
||||||
|
Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const settling = yield* Deferred.make<void>()
|
||||||
|
const release = yield* Deferred.make<void>()
|
||||||
|
const scopes: SessionInbox.Promotable[] = []
|
||||||
|
const coordinator = yield* SessionRunCoordinator.make({
|
||||||
|
drain: (_key, _force, scope) => Effect.sync(() => scopes.push(scope)),
|
||||||
|
settled: () => Deferred.succeed(settling, undefined).pipe(Effect.andThen(Deferred.await(release))),
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* coordinator.wake("session", "steer")
|
||||||
|
yield* Deferred.await(settling)
|
||||||
|
yield* coordinator.wake("session", "input")
|
||||||
|
yield* Deferred.succeed(release, undefined)
|
||||||
|
yield* coordinator.awaitIdle("session")
|
||||||
|
|
||||||
|
expect(scopes).toEqual(["steer", "input"])
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("interrupts active execution and clears its pending wake", () =>
|
it.effect("interrupts active execution and clears its pending wake", () =>
|
||||||
Effect.scoped(
|
Effect.scoped(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -342,6 +365,126 @@ describe("SessionRunCoordinator", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("coalesces drain scopes with input taking precedence", () =>
|
||||||
|
Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const firstStarted = yield* Deferred.make<void>()
|
||||||
|
const release = yield* Deferred.make<void>()
|
||||||
|
const scopes: SessionInbox.Promotable[] = []
|
||||||
|
const coordinator = yield* SessionRunCoordinator.make({
|
||||||
|
drain: (_key, _force, scope) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
scopes.push(scope)
|
||||||
|
if (scopes.length !== 1) return
|
||||||
|
yield* Deferred.succeed(firstStarted, undefined)
|
||||||
|
yield* Deferred.await(release)
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* coordinator.wake("session", "steer")
|
||||||
|
yield* Deferred.await(firstStarted)
|
||||||
|
yield* coordinator.wake("session", "steer")
|
||||||
|
yield* coordinator.wake("session", "input")
|
||||||
|
yield* Deferred.succeed(release, undefined)
|
||||||
|
yield* coordinator.awaitIdle("session")
|
||||||
|
|
||||||
|
expect(scopes).toEqual(["steer", "input"])
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("does not carry a completed input scope into a steer drain", () =>
|
||||||
|
Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const firstStarted = yield* Deferred.make<void>()
|
||||||
|
const release = yield* Deferred.make<void>()
|
||||||
|
const scopes: SessionInbox.Promotable[] = []
|
||||||
|
const coordinator = yield* SessionRunCoordinator.make({
|
||||||
|
drain: (_key, _force, scope) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
scopes.push(scope)
|
||||||
|
if (scopes.length !== 1) return
|
||||||
|
yield* Deferred.succeed(firstStarted, undefined)
|
||||||
|
yield* Deferred.await(release)
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* coordinator.wake("session", "input")
|
||||||
|
yield* Deferred.await(firstStarted)
|
||||||
|
yield* coordinator.wake("session", "steer")
|
||||||
|
yield* Deferred.succeed(release, undefined)
|
||||||
|
yield* coordinator.awaitIdle("session")
|
||||||
|
|
||||||
|
expect(scopes).toEqual(["input", "steer"])
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("an active wake inherits scope without starting idle work", () =>
|
||||||
|
Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const firstStarted = yield* Deferred.make<void>()
|
||||||
|
const release = yield* Deferred.make<void>()
|
||||||
|
const scopes: SessionInbox.Promotable[] = []
|
||||||
|
const coordinator = yield* SessionRunCoordinator.make({
|
||||||
|
drain: (_key, _force, scope) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
scopes.push(scope)
|
||||||
|
if (scopes.length !== 1) return
|
||||||
|
yield* Deferred.succeed(firstStarted, undefined)
|
||||||
|
yield* Deferred.await(release)
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* coordinator.wakeActive("session")
|
||||||
|
yield* coordinator.wake("session", "steer")
|
||||||
|
yield* Deferred.await(firstStarted)
|
||||||
|
yield* coordinator.wakeActive("session")
|
||||||
|
yield* Deferred.succeed(release, undefined)
|
||||||
|
yield* coordinator.awaitIdle("session")
|
||||||
|
|
||||||
|
expect(scopes).toEqual(["steer", "steer"])
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("a cleanup-era wake starts a successor with its own scope", () =>
|
||||||
|
Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const firstStarted = yield* Deferred.make<void>()
|
||||||
|
const cleanupStarted = yield* Deferred.make<void>()
|
||||||
|
const cleanupGate = yield* Deferred.make<void>()
|
||||||
|
const scopes: SessionInbox.Promotable[] = []
|
||||||
|
const coordinator = yield* SessionRunCoordinator.make({
|
||||||
|
drain: (_key, _force, scope) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
scopes.push(scope)
|
||||||
|
if (scopes.length !== 1) return
|
||||||
|
yield* Deferred.succeed(firstStarted, undefined)
|
||||||
|
yield* Effect.never.pipe(
|
||||||
|
Effect.onInterrupt(() =>
|
||||||
|
Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* coordinator.wake("session", "input")
|
||||||
|
yield* Deferred.await(firstStarted)
|
||||||
|
const interrupt = yield* coordinator.interrupt("session").pipe(Effect.forkChild)
|
||||||
|
yield* Deferred.await(cleanupStarted)
|
||||||
|
// A new admission during cancellation restarts normally: interruption only
|
||||||
|
// claims the wakes recorded before it.
|
||||||
|
yield* coordinator.wake("session", "input")
|
||||||
|
yield* Deferred.succeed(cleanupGate, undefined)
|
||||||
|
yield* Fiber.join(interrupt)
|
||||||
|
yield* coordinator.awaitIdle("session")
|
||||||
|
|
||||||
|
expect(scopes).toEqual(["input", "input"])
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("starts a resume registered during interruption cleanup", () =>
|
it.effect("starts a resume registered during interruption cleanup", () =>
|
||||||
Effect.scoped(
|
Effect.scoped(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|||||||
@@ -126,7 +126,8 @@ const execution = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
|
|||||||
active: coordinator.active,
|
active: coordinator.active,
|
||||||
resume: coordinator.run,
|
resume: coordinator.run,
|
||||||
wake: coordinator.wake,
|
wake: coordinator.wake,
|
||||||
interrupt: coordinator.interrupt,
|
wakeActive: coordinator.wakeActive,
|
||||||
|
interrupt: (sessionID) => coordinator.interrupt(sessionID),
|
||||||
awaitIdle: coordinator.awaitIdle,
|
awaitIdle: coordinator.awaitIdle,
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -413,7 +413,8 @@ const execution = Layer.effect(
|
|||||||
active: coordinator.active,
|
active: coordinator.active,
|
||||||
resume: coordinator.run,
|
resume: coordinator.run,
|
||||||
wake: coordinator.wake,
|
wake: coordinator.wake,
|
||||||
interrupt: coordinator.interrupt,
|
wakeActive: coordinator.wakeActive,
|
||||||
|
interrupt: (sessionID) => coordinator.interrupt(sessionID),
|
||||||
awaitIdle: coordinator.awaitIdle,
|
awaitIdle: coordinator.awaitIdle,
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
@@ -1383,6 +1384,44 @@ describe("SessionRunnerLLM", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("keeps queued input parked across a mid-turn move", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const session = yield* setup
|
||||||
|
const bus = yield* Bus.Service
|
||||||
|
const { db } = yield* Database.Service
|
||||||
|
yield* admit(session, "Echo before moving")
|
||||||
|
yield* TestLLM.push(
|
||||||
|
TestLLM.tool("call-move", "echo", { text: "moving" }),
|
||||||
|
TestLLM.text("Done", "text-after-move"),
|
||||||
|
TestLLM.text("Handled queue", "text-after-queue"),
|
||||||
|
)
|
||||||
|
const tools = yield* blockTools()
|
||||||
|
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||||
|
yield* tools.started
|
||||||
|
yield* session.prompt({ sessionID, text: "Queued for later", delivery: "queue", resume: false })
|
||||||
|
yield* SessionInbox.admit(db, bus, {
|
||||||
|
id: SessionMessage.ID.create(),
|
||||||
|
sessionID,
|
||||||
|
item: {
|
||||||
|
type: "move",
|
||||||
|
payload: {
|
||||||
|
location: Location.Ref.make({ directory: AbsolutePath.make("/project") }),
|
||||||
|
projectID: Project.ID.global,
|
||||||
|
},
|
||||||
|
delivery: "steer",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* tools.release
|
||||||
|
yield* Fiber.join(run)
|
||||||
|
|
||||||
|
// The resumed turn absorbs steers only; queued input waits for the turn to end.
|
||||||
|
expect(requests).toHaveLength(3)
|
||||||
|
expect(userTexts(requests[1])).not.toContain("Queued for later")
|
||||||
|
expect(userTexts(requests[2])).toContain("Queued for later")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("seeds a fork with the parent's newest instruction values", () =>
|
it.effect("seeds a fork with the parent's newest instruction values", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* setup
|
const session = yield* setup
|
||||||
@@ -3088,6 +3127,24 @@ describe("SessionRunnerLLM", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("stops a steer-scoped drain before queued input", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const session = yield* setup
|
||||||
|
const { db } = yield* Database.Service
|
||||||
|
yield* session.prompt({ sessionID, text: "Queue for later", delivery: "queue", resume: false })
|
||||||
|
yield* session.prompt({ sessionID, text: "Steer now", resume: false })
|
||||||
|
yield* TestLLM.push(TestLLM.stop())
|
||||||
|
|
||||||
|
const runner = yield* SessionRunner.Service
|
||||||
|
yield* runner.drain({ sessionID, force: false, promotable: "steer" })
|
||||||
|
|
||||||
|
expect(requests).toHaveLength(1)
|
||||||
|
expect(userTexts(requests[0])).toEqual(["Steer now"])
|
||||||
|
expect(yield* SessionInbox.has(db, sessionID, "steer")).toBe(false)
|
||||||
|
expect(yield* SessionInbox.has(db, sessionID, "queue")).toBe(true)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("promotes queued input after steering continuation ends", () =>
|
it.effect("promotes queued input after steering continuation ends", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* setup
|
const session = yield* setup
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ const executionNode = makeGlobalNode({
|
|||||||
active: Effect.succeed(new Set()),
|
active: Effect.succeed(new Set()),
|
||||||
resume: complete,
|
resume: complete,
|
||||||
wake: () => Effect.void,
|
wake: () => Effect.void,
|
||||||
|
wakeActive: () => Effect.void,
|
||||||
interrupt: () => Effect.void,
|
interrupt: () => Effect.void,
|
||||||
awaitIdle: (id) => complete(id).pipe(Effect.exit, Effect.asVoid),
|
awaitIdle: (id) => complete(id).pipe(Effect.exit, Effect.asVoid),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ const executionNode = makeGlobalNode({
|
|||||||
active: Effect.succeed(new Set()),
|
active: Effect.succeed(new Set()),
|
||||||
resume: complete,
|
resume: complete,
|
||||||
wake: () => Effect.void,
|
wake: () => Effect.void,
|
||||||
|
wakeActive: () => Effect.void,
|
||||||
interrupt: () => Effect.void,
|
interrupt: () => Effect.void,
|
||||||
awaitIdle: (sessionID) => complete(sessionID).pipe(Effect.exit, Effect.asVoid),
|
awaitIdle: (sessionID) => complete(sessionID).pipe(Effect.exit, Effect.asVoid),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -24,8 +24,7 @@ import {
|
|||||||
isOldLayoutEligible,
|
isOldLayoutEligible,
|
||||||
} from "./onboarding"
|
} from "./onboarding"
|
||||||
import { getDefaultServerUrl, preferAppEnv, setDefaultServerUrl } from "./server"
|
import { getDefaultServerUrl, preferAppEnv, setDefaultServerUrl } from "./server"
|
||||||
import { setupAutoUpdater, showUpdaterDialog } from "./updater"
|
import { registerUpdaterIpc, setupAutoUpdater, showUpdaterDialog } from "./updater"
|
||||||
import { registerUpdaterIpc } from "./updater-ipc"
|
|
||||||
import { safeWebContentsURL } from "./window-state"
|
import { safeWebContentsURL } from "./window-state"
|
||||||
import {
|
import {
|
||||||
getLastFocusedWindow,
|
getLastFocusedWindow,
|
||||||
@@ -242,7 +241,7 @@ const main = Effect.gen(function* () {
|
|||||||
const win = getLastFocusedWindow()
|
const win = getLastFocusedWindow()
|
||||||
if (win) sendMenuCommand(win, id)
|
if (win) sendMenuCommand(win, id)
|
||||||
},
|
},
|
||||||
checkForUpdates: () => void showUpdaterDialog(updater, true),
|
checkForUpdates: () => void showUpdaterDialog(updater),
|
||||||
relaunch,
|
relaunch,
|
||||||
}
|
}
|
||||||
registerIpcHandlers({
|
registerIpcHandlers({
|
||||||
@@ -267,7 +266,7 @@ const main = Effect.gen(function* () {
|
|||||||
setDisplayBackend: async () => undefined,
|
setDisplayBackend: async () => undefined,
|
||||||
checkAppExists: (appName) => checkAppExists(appName),
|
checkAppExists: (appName) => checkAppExists(appName),
|
||||||
resolveAppPath: async (appName) => resolveAppPath(appName),
|
resolveAppPath: async (appName) => resolveAppPath(appName),
|
||||||
showUpdater: () => showUpdaterDialog(updater, true),
|
showUpdater: () => showUpdaterDialog(updater),
|
||||||
setBackgroundColor: (color) => setBackgroundColor(color),
|
setBackgroundColor: (color) => setBackgroundColor(color),
|
||||||
exportDebugLogs: () => exportDebugLogs(),
|
exportDebugLogs: () => exportDebugLogs(),
|
||||||
recordFatalRendererError: (error) => writeLog("renderer", "fatal renderer error", { ...error }, "error"),
|
recordFatalRendererError: (error) => writeLog("renderer", "fatal renderer error", { ...error }, "error"),
|
||||||
|
|||||||
@@ -1,26 +1,35 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { createUpdaterController, type UpdaterPlatform, type UpdaterReadyRecord } from "./updater-controller"
|
import { createUpdaterController, type UpdaterReadyRecord } from "./updater-controller"
|
||||||
|
|
||||||
function setup(input?: { currentVersion?: string; ready?: UpdaterReadyRecord }) {
|
// Drives the controller the way the app does: start or check, observe the states
|
||||||
|
// the renderer sees, then install like a button click. `calls` records the platform
|
||||||
|
// operations in order; installs record the staged version they would apply.
|
||||||
|
function setup(input?: {
|
||||||
|
currentVersion?: string
|
||||||
|
ready?: UpdaterReadyRecord
|
||||||
|
latest?: () => string
|
||||||
|
stage?: () => Promise<void>
|
||||||
|
install?: () => Promise<never>
|
||||||
|
}) {
|
||||||
const calls: string[] = []
|
const calls: string[] = []
|
||||||
const platform: UpdaterPlatform = {
|
const states: string[] = []
|
||||||
async checkForUpdate() {
|
|
||||||
calls.push("check")
|
|
||||||
return "2.0.0"
|
|
||||||
},
|
|
||||||
async stageUpdate() {
|
|
||||||
calls.push("download")
|
|
||||||
},
|
|
||||||
installAndRestart() {
|
|
||||||
calls.push("install")
|
|
||||||
return new Promise<never>(() => {})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
let ready = input?.ready
|
let ready = input?.ready
|
||||||
const controller = createUpdaterController({
|
const controller = createUpdaterController({
|
||||||
enabled: true,
|
|
||||||
currentVersion: input?.currentVersion ?? "1.0.0",
|
currentVersion: input?.currentVersion ?? "1.0.0",
|
||||||
platform,
|
platform: {
|
||||||
|
async checkForUpdate() {
|
||||||
|
calls.push("check")
|
||||||
|
return input?.latest?.() ?? "2.0.0"
|
||||||
|
},
|
||||||
|
async stageUpdate() {
|
||||||
|
calls.push("download")
|
||||||
|
await input?.stage?.()
|
||||||
|
},
|
||||||
|
installAndRestart() {
|
||||||
|
calls.push(`install:${ready?.version}`)
|
||||||
|
return input?.install?.() ?? new Promise<never>(() => {})
|
||||||
|
},
|
||||||
|
},
|
||||||
lifecycle: {
|
lifecycle: {
|
||||||
async prepareToRestart() {
|
async prepareToRestart() {
|
||||||
calls.push("prepare")
|
calls.push("prepare")
|
||||||
@@ -36,21 +45,29 @@ function setup(input?: { currentVersion?: string; ready?: UpdaterReadyRecord })
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return { controller, calls, getReady: () => ready }
|
controller.subscribe((state) => states.push(state.status))
|
||||||
|
return { controller, calls, states, getReady: () => ready }
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("updater controller", () => {
|
describe("updater controller", () => {
|
||||||
test("checks, downloads, persists, and publishes one authoritative ready state", async () => {
|
test("stages an update found at launch and shows it as ready", async () => {
|
||||||
const app = setup()
|
const app = setup()
|
||||||
const states: ReturnType<typeof app.controller.getState>[] = []
|
|
||||||
app.controller.subscribe((state) => states.push(state))
|
|
||||||
|
|
||||||
await app.controller.start()
|
await app.controller.start()
|
||||||
|
|
||||||
expect(app.calls).toEqual(["check", "download"])
|
expect(app.states).toEqual(["idle", "checking", "downloading", "ready"])
|
||||||
expect(app.getReady()).toEqual({ version: "2.0.0" })
|
|
||||||
expect(states.map((state) => state.status)).toEqual(["idle", "checking", "downloading", "ready"])
|
|
||||||
expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
|
expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
|
||||||
|
expect(app.getReady()).toEqual({ version: "2.0.0" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("reports up to date and clears the record once the update is installed", async () => {
|
||||||
|
const app = setup({ currentVersion: "2.0.0", ready: { version: "2.0.0" } })
|
||||||
|
|
||||||
|
await app.controller.start()
|
||||||
|
|
||||||
|
expect(app.states).toEqual(["idle", "checking", "up-to-date"])
|
||||||
|
expect(app.calls).toEqual(["check"])
|
||||||
|
expect(app.getReady()).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("revalidates a persisted target through the updater cache on launch", async () => {
|
test("revalidates a persisted target through the updater cache on launch", async () => {
|
||||||
@@ -62,16 +79,7 @@ describe("updater controller", () => {
|
|||||||
expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
|
expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("clears a target already installed before checking", async () => {
|
test("concurrent checks share one platform check", async () => {
|
||||||
const app = setup({ currentVersion: "2.0.0", ready: { version: "2.0.0" } })
|
|
||||||
|
|
||||||
await app.controller.start()
|
|
||||||
|
|
||||||
expect(app.getReady()).toBeUndefined()
|
|
||||||
expect(app.calls).toEqual(["check"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("coalesces concurrent checks", async () => {
|
|
||||||
const app = setup()
|
const app = setup()
|
||||||
|
|
||||||
await Promise.all([app.controller.check(), app.controller.check(), app.controller.check()])
|
await Promise.all([app.controller.check(), app.controller.check(), app.controller.check()])
|
||||||
@@ -79,81 +87,142 @@ describe("updater controller", () => {
|
|||||||
expect(app.calls).toEqual(["check", "download"])
|
expect(app.calls).toEqual(["check", "download"])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("starts installing synchronously and coalesces restart requests", async () => {
|
test("clicking install twice checks once and installs the staged version once", async () => {
|
||||||
const app = setup()
|
const app = setup()
|
||||||
await app.controller.start()
|
await app.controller.start()
|
||||||
|
|
||||||
const first = app.controller.install()
|
void app.controller.install()
|
||||||
const second = app.controller.install()
|
void app.controller.install()
|
||||||
|
|
||||||
expect(first).toBe(second)
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||||
await Promise.resolve()
|
expect(app.calls).toEqual(["check", "download", "check", "prepare", "install:2.0.0"])
|
||||||
expect(app.calls).toEqual(["check", "download", "prepare", "install"])
|
|
||||||
expect(app.controller.getState()).toEqual({ status: "installing", version: "2.0.0" })
|
expect(app.controller.getState()).toEqual({ status: "installing", version: "2.0.0" })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("does not check for updates while installation is in progress", async () => {
|
test("ignores checks while an installation is in progress", async () => {
|
||||||
const app = setup()
|
const app = setup()
|
||||||
await app.controller.start()
|
await app.controller.start()
|
||||||
void app.controller.install()
|
void app.controller.install()
|
||||||
|
|
||||||
await app.controller.check()
|
await app.controller.check()
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||||
|
|
||||||
expect(app.calls).toEqual(["check", "download", "prepare", "install"])
|
expect(app.calls).toEqual(["check", "download", "check", "prepare", "install:2.0.0"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("clicking install downloads and installs a newer release", async () => {
|
||||||
|
let latest = "2.0.0"
|
||||||
|
const app = setup({ latest: () => latest })
|
||||||
|
await app.controller.start()
|
||||||
|
|
||||||
|
latest = "3.0.0"
|
||||||
|
void app.controller.install()
|
||||||
|
|
||||||
|
expect(app.controller.getState()).toEqual({ status: "installing", version: "2.0.0" })
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||||
|
expect(app.calls).toEqual(["check", "download", "check", "download", "prepare", "install:3.0.0"])
|
||||||
|
expect(app.controller.getState()).toEqual({ status: "installing", version: "3.0.0" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("clicking install uses the staged release when the final check fails", async () => {
|
||||||
|
let offline = false
|
||||||
|
const app = setup({
|
||||||
|
latest: () => {
|
||||||
|
if (offline) throw new Error("offline")
|
||||||
|
return "2.0.0"
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await app.controller.start()
|
||||||
|
|
||||||
|
offline = true
|
||||||
|
void app.controller.install()
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||||
|
expect(app.calls).toEqual(["check", "download", "check", "prepare", "install:2.0.0"])
|
||||||
expect(app.controller.getState()).toEqual({ status: "installing", version: "2.0.0" })
|
expect(app.controller.getState()).toEqual({ status: "installing", version: "2.0.0" })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("returns to ready when installation fails", async () => {
|
test("later checks stay silent while ready and pick up newer versions", async () => {
|
||||||
const app = setup()
|
let latest = "2.0.0"
|
||||||
|
const app = setup({ latest: () => latest })
|
||||||
await app.controller.start()
|
await app.controller.start()
|
||||||
const error = new Error("install failed")
|
|
||||||
const failed = createUpdaterController({
|
|
||||||
enabled: true,
|
|
||||||
currentVersion: "1.0.0",
|
|
||||||
platform: {
|
|
||||||
checkForUpdate: async () => "2.0.0",
|
|
||||||
stageUpdate: async () => {},
|
|
||||||
installAndRestart: () => Promise.reject(error),
|
|
||||||
},
|
|
||||||
lifecycle: { prepareToRestart: async () => {} },
|
|
||||||
persistence: { get: () => undefined, set() {}, clear() {} },
|
|
||||||
})
|
|
||||||
await failed.start()
|
|
||||||
|
|
||||||
await expect(failed.install()).rejects.toThrow("install failed")
|
await app.controller.check()
|
||||||
expect(failed.getState()).toEqual({ status: "ready", version: "2.0.0" })
|
// Nothing new was published: the install button never hid.
|
||||||
|
expect(app.states).toEqual(["idle", "checking", "downloading", "ready"])
|
||||||
|
|
||||||
|
latest = "3.0.0"
|
||||||
|
await app.controller.check()
|
||||||
|
expect(app.states).toEqual(["idle", "checking", "downloading", "ready", "ready"])
|
||||||
|
expect(app.controller.getState()).toEqual({ status: "ready", version: "3.0.0" })
|
||||||
|
expect(app.getReady()).toEqual({ version: "3.0.0" })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("allows a state subscriber to retry after installation fails", async () => {
|
test("keeps the staged update installable when a silent re-check fails", async () => {
|
||||||
let attempts = 0
|
let offline = false
|
||||||
let sawInstalling = false
|
const app = setup({
|
||||||
let retry: Promise<void> | undefined
|
latest: () => {
|
||||||
const failed = createUpdaterController({
|
if (offline) throw new Error("offline")
|
||||||
enabled: true,
|
return "2.0.0"
|
||||||
currentVersion: "1.0.0",
|
|
||||||
platform: {
|
|
||||||
checkForUpdate: async () => "2.0.0",
|
|
||||||
stageUpdate: async () => {},
|
|
||||||
installAndRestart() {
|
|
||||||
attempts++
|
|
||||||
if (attempts === 1) return Promise.reject(new Error("install failed"))
|
|
||||||
return new Promise<never>(() => {})
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
lifecycle: { prepareToRestart: async () => {} },
|
|
||||||
persistence: { get: () => undefined, set() {}, clear() {} },
|
|
||||||
})
|
})
|
||||||
failed.subscribe((state) => {
|
await app.controller.start()
|
||||||
if (state.status === "installing") sawInstalling = true
|
|
||||||
if (!sawInstalling || state.status !== "ready" || retry) return
|
offline = true
|
||||||
retry = failed.install()
|
await app.controller.check()
|
||||||
|
|
||||||
|
expect(app.states).toEqual(["idle", "checking", "downloading", "ready"])
|
||||||
|
expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
|
||||||
|
expect(app.getReady()).toEqual({ version: "2.0.0" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("install during a silent refresh waits for the download, then installs the newer version", async () => {
|
||||||
|
let latest = "2.0.0"
|
||||||
|
let slowStage = false
|
||||||
|
let releaseStage = () => {}
|
||||||
|
const app = setup({
|
||||||
|
latest: () => latest,
|
||||||
|
stage: () => {
|
||||||
|
if (!slowStage) return Promise.resolve()
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
releaseStage = resolve
|
||||||
|
})
|
||||||
|
},
|
||||||
})
|
})
|
||||||
await failed.start()
|
await app.controller.start()
|
||||||
|
|
||||||
await expect(failed.install()).rejects.toThrow("install failed")
|
latest = "3.0.0"
|
||||||
|
slowStage = true
|
||||||
|
const refresh = app.controller.check()
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||||
|
|
||||||
expect(retry).toBeDefined()
|
void app.controller.install()
|
||||||
|
expect(app.controller.getState()).toEqual({ status: "installing", version: "2.0.0" })
|
||||||
|
|
||||||
|
releaseStage()
|
||||||
|
await refresh
|
||||||
|
expect(app.controller.getState()).toEqual({ status: "installing", version: "3.0.0" })
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||||
|
expect(app.calls).toEqual(["check", "download", "check", "download", "prepare", "install:3.0.0"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns to ready after a failed installation and allows a retry", async () => {
|
||||||
|
let attempts = 0
|
||||||
|
const app = setup({
|
||||||
|
install() {
|
||||||
|
attempts++
|
||||||
|
if (attempts === 1) return Promise.reject(new Error("install failed"))
|
||||||
|
return new Promise<never>(() => {})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await app.controller.start()
|
||||||
|
|
||||||
|
await expect(app.controller.install()).rejects.toThrow("install failed")
|
||||||
|
expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
|
||||||
|
|
||||||
|
void app.controller.install()
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||||
expect(attempts).toBe(2)
|
expect(attempts).toBe(2)
|
||||||
expect(failed.getState()).toEqual({ status: "installing", version: "2.0.0" })
|
expect(app.controller.getState()).toEqual({ status: "installing", version: "2.0.0" })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -21,14 +21,13 @@ type UpdaterPersistence = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createUpdaterController(input: {
|
export function createUpdaterController(input: {
|
||||||
enabled: boolean
|
|
||||||
currentVersion: string
|
currentVersion: string
|
||||||
platform?: UpdaterPlatform
|
platform?: UpdaterPlatform
|
||||||
lifecycle: UpdaterLifecycle
|
lifecycle: UpdaterLifecycle
|
||||||
persistence: UpdaterPersistence
|
persistence: UpdaterPersistence
|
||||||
log?: (message: string, data?: object) => void
|
log?: (message: string, data?: object) => void
|
||||||
}) {
|
}) {
|
||||||
let state: UpdaterState = input.enabled ? { status: "idle" } : { status: "disabled" }
|
let state: UpdaterState = input.platform ? { status: "idle" } : { status: "disabled" }
|
||||||
let pending: Promise<UpdaterState> | undefined
|
let pending: Promise<UpdaterState> | undefined
|
||||||
let installing: Promise<void> | undefined
|
let installing: Promise<void> | undefined
|
||||||
const listeners = new Set<(state: UpdaterState) => void>()
|
const listeners = new Set<(state: UpdaterState) => void>()
|
||||||
@@ -41,13 +40,21 @@ export function createUpdaterController(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const check = () => {
|
const check = () => {
|
||||||
if (!input.enabled) return Promise.resolve(state)
|
|
||||||
const platform = input.platform
|
const platform = input.platform
|
||||||
if (!platform) return Promise.resolve(state)
|
if (!platform) return Promise.resolve(state)
|
||||||
if (state.status === "ready" || state.status === "installing") return Promise.resolve(state)
|
if (state.status === "installing") return Promise.resolve(state)
|
||||||
if (pending) return pending
|
if (pending) return pending
|
||||||
|
|
||||||
pending = (async () => {
|
pending = (state.status === "ready" ? refreshStaged(platform, state.version) : findAndStage(platform)).finally(
|
||||||
|
() => {
|
||||||
|
pending = undefined
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return pending
|
||||||
|
}
|
||||||
|
|
||||||
|
const findAndStage = (platform: UpdaterPlatform) =>
|
||||||
|
(async () => {
|
||||||
transition({ status: "checking" })
|
transition({ status: "checking" })
|
||||||
const version = await platform.checkForUpdate()
|
const version = await platform.checkForUpdate()
|
||||||
if (!version || version === input.currentVersion) {
|
if (!version || version === input.currentVersion) {
|
||||||
@@ -59,41 +66,49 @@ export function createUpdaterController(input: {
|
|||||||
await platform.stageUpdate()
|
await platform.stageUpdate()
|
||||||
await input.persistence.set({ version })
|
await input.persistence.set({ version })
|
||||||
return transition({ status: "ready", version })
|
return transition({ status: "ready", version })
|
||||||
})()
|
})().catch((error) =>
|
||||||
.catch((error) =>
|
transition({ status: "error", message: error instanceof Error ? error.message : String(error) }),
|
||||||
transition({ status: "error", message: error instanceof Error ? error.message : String(error) }),
|
)
|
||||||
)
|
|
||||||
.finally(() => {
|
// A staged update stays visible and installable throughout: the refresh makes no
|
||||||
pending = undefined
|
// transitions until a newer version is staged, and a failure keeps the current one.
|
||||||
|
const refreshStaged = (platform: UpdaterPlatform, staged: string) =>
|
||||||
|
(async () => {
|
||||||
|
const version = await platform.checkForUpdate()
|
||||||
|
if (!version || version === staged || version === input.currentVersion) return state
|
||||||
|
|
||||||
|
await platform.stageUpdate()
|
||||||
|
await input.persistence.set({ version })
|
||||||
|
// An install may have started while this stage was in flight; keep its status
|
||||||
|
// and show the newer version instead of flickering back to ready.
|
||||||
|
return transition({ status: installing ? "installing" : "ready", version })
|
||||||
|
})().catch((error) => {
|
||||||
|
input.log?.("updater refresh failed, keeping staged update", {
|
||||||
|
staged,
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
})
|
})
|
||||||
return pending
|
return state
|
||||||
}
|
})
|
||||||
|
|
||||||
const install = () => {
|
const install = () => {
|
||||||
if (installing) return installing
|
if (installing) return installing
|
||||||
if (state.status !== "ready") return Promise.reject(new Error("Update is not ready to install"))
|
const platform = input.platform
|
||||||
|
if (!platform || state.status !== "ready") return Promise.reject(new Error("Update is not ready to install"))
|
||||||
|
|
||||||
const version = startInstalling(state.version)
|
const staged = state.version
|
||||||
installing = restartWithUpdate(version)
|
transition({ status: "installing", version: staged })
|
||||||
return installing
|
installing = (async () => {
|
||||||
}
|
// Installation is the commit point: refresh once more so one restart lands
|
||||||
|
// on the newest release, or keep the known-good staged update if checking fails.
|
||||||
const startInstalling = (version: string) => {
|
await (pending ?? refreshStaged(platform, staged))
|
||||||
transition({ status: "installing", version })
|
await input.lifecycle.prepareToRestart()
|
||||||
return version
|
await platform.installAndRestart()
|
||||||
}
|
})().catch((error) => {
|
||||||
|
|
||||||
const restartWithUpdate = (version: string) =>
|
|
||||||
prepareAndRestart().catch((error) => {
|
|
||||||
installing = undefined
|
installing = undefined
|
||||||
transition({ status: "ready", version })
|
if (state.status === "installing") transition({ status: "ready", version: state.version })
|
||||||
throw error
|
throw error
|
||||||
})
|
})
|
||||||
|
return installing
|
||||||
const prepareAndRestart = async () => {
|
|
||||||
if (!input.platform) throw new Error("Updater is disabled")
|
|
||||||
await input.lifecycle.prepareToRestart()
|
|
||||||
await input.platform.installAndRestart()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import { app, ipcMain } from "electron"
|
|
||||||
import type { UpdaterController } from "./updater-controller"
|
|
||||||
import { createUpdaterSubscriptions } from "./updater-subscriptions"
|
|
||||||
|
|
||||||
export function registerUpdaterIpc(controller: UpdaterController) {
|
|
||||||
const subscriptions = createUpdaterSubscriptions()
|
|
||||||
app.once("will-quit", subscriptions.clear)
|
|
||||||
|
|
||||||
ipcMain.handle("updater-subscribe", (event) => {
|
|
||||||
const id = event.sender.id
|
|
||||||
subscriptions.set(
|
|
||||||
id,
|
|
||||||
controller.subscribe((state) => {
|
|
||||||
if (event.sender.isDestroyed()) return subscriptions.delete(id)
|
|
||||||
event.sender.send("updater-state", state)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
event.sender.once("destroyed", () => subscriptions.delete(id))
|
|
||||||
})
|
|
||||||
ipcMain.handle("updater-unsubscribe", (event) => subscriptions.delete(event.sender.id))
|
|
||||||
ipcMain.handle("updater-check", () => controller.check())
|
|
||||||
ipcMain.handle("updater-install", () => controller.install())
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
import { createUpdaterSubscriptions } from "./updater-subscriptions"
|
|
||||||
|
|
||||||
describe("updater subscriptions", () => {
|
|
||||||
test("replaces the previous renderer subscription on reload", () => {
|
|
||||||
const subscriptions = createUpdaterSubscriptions()
|
|
||||||
const disposed: string[] = []
|
|
||||||
|
|
||||||
subscriptions.set(1, () => disposed.push("first"))
|
|
||||||
subscriptions.set(1, () => disposed.push("second"))
|
|
||||||
|
|
||||||
expect(disposed).toEqual(["first"])
|
|
||||||
subscriptions.delete(1)
|
|
||||||
expect(disposed).toEqual(["first", "second"])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
export function createUpdaterSubscriptions() {
|
|
||||||
const subscriptions = new Map<number, () => void>()
|
|
||||||
|
|
||||||
const remove = (id: number) => {
|
|
||||||
subscriptions.get(id)?.()
|
|
||||||
subscriptions.delete(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
set(id: number, unsubscribe: () => void) {
|
|
||||||
remove(id)
|
|
||||||
subscriptions.set(id, unsubscribe)
|
|
||||||
},
|
|
||||||
delete: remove,
|
|
||||||
clear() {
|
|
||||||
subscriptions.forEach((unsubscribe) => unsubscribe())
|
|
||||||
subscriptions.clear()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { app, dialog } from "electron"
|
import { app, dialog, ipcMain } from "electron"
|
||||||
import { UPDATER_ENABLED } from "./constants"
|
import { UPDATER_ENABLED } from "./constants"
|
||||||
import { createUpdaterController, type UpdaterReadyRecord } from "./updater-controller"
|
import { createUpdaterController, type UpdaterController, type UpdaterReadyRecord } from "./updater-controller"
|
||||||
import { getLogger } from "./logging"
|
import { getLogger } from "./logging"
|
||||||
import { getStore } from "./store"
|
import { getStore } from "./store"
|
||||||
import { nativeT } from "./native-translations"
|
import { nativeT } from "./native-translations"
|
||||||
@@ -12,7 +12,6 @@ export function setupAutoUpdater(prepareToRestart: () => Promise<void>) {
|
|||||||
const logger = getLogger()
|
const logger = getLogger()
|
||||||
const store = getStore("opencode.updater")
|
const store = getStore("opencode.updater")
|
||||||
return createUpdaterController({
|
return createUpdaterController({
|
||||||
enabled: UPDATER_ENABLED,
|
|
||||||
currentVersion: app.getVersion(),
|
currentVersion: app.getVersion(),
|
||||||
platform: UPDATER_ENABLED ? createUpdaterPlatform(logger) : undefined,
|
platform: UPDATER_ENABLED ? createUpdaterPlatform(logger) : undefined,
|
||||||
lifecycle: { prepareToRestart },
|
lifecycle: { prepareToRestart },
|
||||||
@@ -29,10 +28,34 @@ export function setupAutoUpdater(prepareToRestart: () => Promise<void>) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function showUpdaterDialog(controller: ReturnType<typeof setupAutoUpdater>, alertOnFail: boolean) {
|
export function registerUpdaterIpc(controller: UpdaterController) {
|
||||||
|
const subscriptions = new Map<number, () => void>()
|
||||||
|
const unsubscribe = (id: number) => {
|
||||||
|
subscriptions.get(id)?.()
|
||||||
|
subscriptions.delete(id)
|
||||||
|
}
|
||||||
|
app.once("will-quit", () => subscriptions.forEach((dispose) => dispose()))
|
||||||
|
|
||||||
|
ipcMain.handle("updater-subscribe", (event) => {
|
||||||
|
const id = event.sender.id
|
||||||
|
subscriptions.get(id)?.() // a reloaded renderer replaces its previous subscription
|
||||||
|
subscriptions.set(
|
||||||
|
id,
|
||||||
|
controller.subscribe((state) => {
|
||||||
|
if (event.sender.isDestroyed()) return unsubscribe(id)
|
||||||
|
event.sender.send("updater-state", state)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
event.sender.once("destroyed", () => unsubscribe(id))
|
||||||
|
})
|
||||||
|
ipcMain.handle("updater-unsubscribe", (event) => unsubscribe(event.sender.id))
|
||||||
|
ipcMain.handle("updater-check", () => controller.check())
|
||||||
|
ipcMain.handle("updater-install", () => controller.install())
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function showUpdaterDialog(controller: UpdaterController) {
|
||||||
const state = await controller.check()
|
const state = await controller.check()
|
||||||
if (state.status === "error") {
|
if (state.status === "error") {
|
||||||
if (!alertOnFail) return
|
|
||||||
await dialog.showMessageBox({
|
await dialog.showMessageBox({
|
||||||
type: "error",
|
type: "error",
|
||||||
message: nativeT("desktop.updater.dialog.checkFailed.message"),
|
message: nativeT("desktop.updater.dialog.checkFailed.message"),
|
||||||
@@ -41,7 +64,6 @@ export async function showUpdaterDialog(controller: ReturnType<typeof setupAutoU
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (state.status === "up-to-date") {
|
if (state.status === "up-to-date") {
|
||||||
if (!alertOnFail) return
|
|
||||||
await dialog.showMessageBox({
|
await dialog.showMessageBox({
|
||||||
type: "info",
|
type: "info",
|
||||||
message: nativeT("desktop.updater.dialog.upToDate.message"),
|
message: nativeT("desktop.updater.dialog.upToDate.message"),
|
||||||
|
|||||||
@@ -3964,7 +3964,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes if durable inbox work remains after interruption.",
|
"description": "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes pending steering input while queued work remains parked.",
|
||||||
"summary": "Interrupt session execution"
|
"summary": "Interrupt session execution"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -17728,8 +17728,9 @@
|
|||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"disabled": {
|
"activation": {
|
||||||
"type": "boolean"
|
"type": "string",
|
||||||
|
"enum": ["auto", "enabled", "disabled"]
|
||||||
},
|
},
|
||||||
"package": {
|
"package": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -17747,7 +17748,7 @@
|
|||||||
"type": "object"
|
"type": "object"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["id", "name", "package"],
|
"required": ["id", "name", "activation", "package"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"ProviderNotFoundError": {
|
"ProviderNotFoundError": {
|
||||||
|
|||||||
@@ -660,7 +660,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
|||||||
identifier: "v2.session.interrupt",
|
identifier: "v2.session.interrupt",
|
||||||
summary: "Interrupt session execution",
|
summary: "Interrupt session execution",
|
||||||
description:
|
description:
|
||||||
"Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes if durable inbox work remains after interruption.",
|
"Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes pending steering input while queued work remains parked.",
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ export type ID = typeof ID.Type
|
|||||||
export const Package = Schema.String
|
export const Package = Schema.String
|
||||||
export type Package = typeof Package.Type
|
export type Package = typeof Package.Type
|
||||||
|
|
||||||
|
export const Activation = Schema.Literals(["auto", "enabled", "disabled"])
|
||||||
|
export type Activation = typeof Activation.Type
|
||||||
|
|
||||||
export const Overlays = {
|
export const Overlays = {
|
||||||
settings: Schema.Record(Schema.String, Schema.Any).pipe(optional),
|
settings: Schema.Record(Schema.String, Schema.Any).pipe(optional),
|
||||||
headers: Schema.Record(Schema.String, Schema.String).pipe(optional),
|
headers: Schema.Record(Schema.String, Schema.String).pipe(optional),
|
||||||
@@ -46,13 +49,13 @@ export const Info = Schema.Struct({
|
|||||||
id: ID,
|
id: ID,
|
||||||
integrationID: Integration.ID.pipe(optional),
|
integrationID: Integration.ID.pipe(optional),
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
disabled: Schema.Boolean.pipe(optional),
|
activation: Activation,
|
||||||
package: Package,
|
package: Package,
|
||||||
...Overlays,
|
...Overlays,
|
||||||
})
|
})
|
||||||
.annotate({ identifier: "Provider.Info" })
|
.annotate({ identifier: "Provider.Info" })
|
||||||
.pipe(
|
.pipe(
|
||||||
statics(() => ({
|
statics(() => ({
|
||||||
empty: (id: ID): Info => ({ id, name: id, package: "" }),
|
empty: (id: ID): Info => ({ id, name: id, activation: "auto", package: "" }),
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -120,10 +120,12 @@ describe("contract hygiene", () => {
|
|||||||
test("model defaults and provider overlays preserve public invariants", () => {
|
test("model defaults and provider overlays preserve public invariants", () => {
|
||||||
const id = Model.ID.make("model")
|
const id = Model.ID.make("model")
|
||||||
expect(Model.Info.default(Provider.ID.make("provider"), id)).toMatchObject({ modelID: id, variants: [] })
|
expect(Model.Info.default(Provider.ID.make("provider"), id)).toMatchObject({ modelID: id, variants: [] })
|
||||||
|
expect(Provider.Info.empty(Provider.ID.make("provider")).activation).toBe("auto")
|
||||||
expect(
|
expect(
|
||||||
Schema.decodeUnknownSync(Provider.Info)({
|
Schema.decodeUnknownSync(Provider.Info)({
|
||||||
id: "provider",
|
id: "provider",
|
||||||
name: "Provider",
|
name: "Provider",
|
||||||
|
activation: "auto",
|
||||||
package: "native",
|
package: "native",
|
||||||
settings: { arbitrary: 1n },
|
settings: { arbitrary: 1n },
|
||||||
}).settings,
|
}).settings,
|
||||||
|
|||||||
@@ -3,7 +3,43 @@ import { patchFiles } from "./apply-patch-file"
|
|||||||
import { text } from "./session-diff"
|
import { text } from "./session-diff"
|
||||||
|
|
||||||
describe("apply patch file", () => {
|
describe("apply patch file", () => {
|
||||||
test("parses patch metadata from the server", () => {
|
test("parses v2 patch metadata", () => {
|
||||||
|
const file = patchFiles([
|
||||||
|
{
|
||||||
|
file: "a.ts",
|
||||||
|
status: "modified",
|
||||||
|
patch:
|
||||||
|
"Index: a.ts\n===================================================================\n--- a.ts\n+++ a.ts\n@@ -1,2 +1,2 @@\n one\n-two\n+three\n",
|
||||||
|
additions: 1,
|
||||||
|
deletions: 1,
|
||||||
|
},
|
||||||
|
])[0]
|
||||||
|
|
||||||
|
expect(file).toBeDefined()
|
||||||
|
expect(file?.filePath).toBe("a.ts")
|
||||||
|
expect(file?.relativePath).toBe("a.ts")
|
||||||
|
expect(file?.type).toBe("update")
|
||||||
|
expect(file?.view.fileDiff.name).toBe("a.ts")
|
||||||
|
expect(file?.view.fileDiff.isPartial).toBe(true)
|
||||||
|
expect(text(file.view, "deletions")).toBe("one\ntwo\n")
|
||||||
|
expect(text(file.view, "additions")).toBe("one\nthree\n")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("maps all v2 patch statuses", () => {
|
||||||
|
expect(
|
||||||
|
patchFiles([
|
||||||
|
{ file: "added.ts", status: "added", patch: "+one", additions: 1, deletions: 0 },
|
||||||
|
{ file: "deleted.ts", status: "deleted", patch: "-one", additions: 0, deletions: 1 },
|
||||||
|
{ file: "modified.ts", status: "modified", patch: "-one\n+two", additions: 1, deletions: 1 },
|
||||||
|
]).map((file) => ({ file: file.filePath, type: file.type })),
|
||||||
|
).toEqual([
|
||||||
|
{ file: "added.ts", type: "add" },
|
||||||
|
{ file: "deleted.ts", type: "delete" },
|
||||||
|
{ file: "modified.ts", type: "update" },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parses legacy patch metadata", () => {
|
||||||
const file = patchFiles([
|
const file = patchFiles([
|
||||||
{
|
{
|
||||||
filePath: "/tmp/a.ts",
|
filePath: "/tmp/a.ts",
|
||||||
@@ -19,8 +55,8 @@ describe("apply patch file", () => {
|
|||||||
expect(file).toBeDefined()
|
expect(file).toBeDefined()
|
||||||
expect(file?.view.fileDiff.name).toBe("a.ts")
|
expect(file?.view.fileDiff.name).toBe("a.ts")
|
||||||
expect(file?.view.fileDiff.isPartial).toBe(false)
|
expect(file?.view.fileDiff.isPartial).toBe(false)
|
||||||
expect(text(file!.view, "deletions")).toBe("one\ntwo\n")
|
expect(text(file.view, "deletions")).toBe("one\ntwo\n")
|
||||||
expect(text(file!.view, "additions")).toBe("one\nthree\n")
|
expect(text(file.view, "additions")).toBe("one\nthree\n")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("keeps legacy before and after payloads working", () => {
|
test("keeps legacy before and after payloads working", () => {
|
||||||
@@ -37,7 +73,7 @@ describe("apply patch file", () => {
|
|||||||
])[0]
|
])[0]
|
||||||
|
|
||||||
expect(file).toBeDefined()
|
expect(file).toBeDefined()
|
||||||
expect(text(file!.view, "deletions")).toBe("one\n")
|
expect(text(file.view, "deletions")).toBe("one\n")
|
||||||
expect(text(file!.view, "additions")).toBe("two\n")
|
expect(text(file.view, "additions")).toBe("two\n")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import { normalize, type ViewDiff } from "./session-diff"
|
|||||||
type Kind = "add" | "update" | "delete" | "move"
|
type Kind = "add" | "update" | "delete" | "move"
|
||||||
|
|
||||||
type Raw = {
|
type Raw = {
|
||||||
|
file?: string
|
||||||
filePath?: string
|
filePath?: string
|
||||||
relativePath?: string
|
relativePath?: string
|
||||||
type?: Kind
|
type?: Kind
|
||||||
|
status?: "added" | "deleted" | "modified"
|
||||||
patch?: string
|
patch?: string
|
||||||
diff?: string
|
diff?: string
|
||||||
before?: string
|
before?: string
|
||||||
@@ -27,6 +29,10 @@ export type ApplyPatchFile = {
|
|||||||
|
|
||||||
function kind(value: unknown) {
|
function kind(value: unknown) {
|
||||||
if (value === "add" || value === "update" || value === "delete" || value === "move") return value
|
if (value === "add" || value === "update" || value === "delete" || value === "move") return value
|
||||||
|
if (value === "added") return "add"
|
||||||
|
if (value === "deleted") return "delete"
|
||||||
|
if (value === "modified") return "update"
|
||||||
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function status(type: Kind): "added" | "deleted" | "modified" {
|
function status(type: Kind): "added" | "deleted" | "modified" {
|
||||||
@@ -36,18 +42,19 @@ function status(type: Kind): "added" | "deleted" | "modified" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function patchFile(raw: unknown): ApplyPatchFile | undefined {
|
export function patchFile(raw: unknown): ApplyPatchFile | undefined {
|
||||||
if (!raw || typeof raw !== "object") return
|
if (!raw || typeof raw !== "object") return undefined
|
||||||
|
|
||||||
const value = raw as Raw
|
const value = raw as Raw
|
||||||
const type = kind(value.type)
|
const type = kind(value.type) ?? kind(value.status)
|
||||||
const filePath = typeof value.filePath === "string" ? value.filePath : undefined
|
const filePath =
|
||||||
|
typeof value.filePath === "string" ? value.filePath : typeof value.file === "string" ? value.file : undefined
|
||||||
const relativePath = typeof value.relativePath === "string" ? value.relativePath : filePath
|
const relativePath = typeof value.relativePath === "string" ? value.relativePath : filePath
|
||||||
const patch = typeof value.patch === "string" ? value.patch : typeof value.diff === "string" ? value.diff : undefined
|
const patch = typeof value.patch === "string" ? value.patch : typeof value.diff === "string" ? value.diff : undefined
|
||||||
const before = typeof value.before === "string" ? value.before : undefined
|
const before = typeof value.before === "string" ? value.before : undefined
|
||||||
const after = typeof value.after === "string" ? value.after : undefined
|
const after = typeof value.after === "string" ? value.after : undefined
|
||||||
|
|
||||||
if (!type || !filePath || !relativePath) return
|
if (!type || !filePath || !relativePath) return undefined
|
||||||
if (!patch && before === undefined && after === undefined) return
|
if (!patch && before === undefined && after === undefined) return undefined
|
||||||
|
|
||||||
const additions = typeof value.additions === "number" ? value.additions : 0
|
const additions = typeof value.additions === "number" ? value.additions : 0
|
||||||
const deletions = typeof value.deletions === "number" ? value.deletions : 0
|
const deletions = typeof value.deletions === "number" ? value.deletions : 0
|
||||||
|
|||||||
@@ -474,6 +474,17 @@ function webSearchProviderLabel(provider: unknown, i18n: ReturnType<typeof useI1
|
|||||||
return i18n.t("ui.tool.websearch")
|
return i18n.t("ui.tool.websearch")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readToolPath(input: Record<string, unknown>) {
|
||||||
|
if (typeof input.path === "string") return input.path
|
||||||
|
if (typeof input.filePath === "string") return input.filePath
|
||||||
|
}
|
||||||
|
|
||||||
|
function skillToolName(input: Record<string, unknown>, metadata?: Record<string, unknown>) {
|
||||||
|
if (typeof metadata?.name === "string") return metadata.name
|
||||||
|
if (typeof input.id === "string") return input.id
|
||||||
|
if (typeof input.name === "string") return input.name
|
||||||
|
}
|
||||||
|
|
||||||
export function getToolInfo(
|
export function getToolInfo(
|
||||||
tool: string,
|
tool: string,
|
||||||
input: any = {},
|
input: any = {},
|
||||||
@@ -481,12 +492,14 @@ export function getToolInfo(
|
|||||||
): ToolInfo {
|
): ToolInfo {
|
||||||
const i18n = useI18n()
|
const i18n = useI18n()
|
||||||
switch (tool) {
|
switch (tool) {
|
||||||
case "read":
|
case "read": {
|
||||||
|
const path = readToolPath(input)
|
||||||
return {
|
return {
|
||||||
icon: "glasses",
|
icon: "glasses",
|
||||||
title: i18n.t("ui.tool.read"),
|
title: i18n.t("ui.tool.read"),
|
||||||
subtitle: input.filePath ? getFilename(input.filePath) : undefined,
|
subtitle: path ? getFilename(path) : undefined,
|
||||||
}
|
}
|
||||||
|
}
|
||||||
case "list":
|
case "list":
|
||||||
return {
|
return {
|
||||||
icon: "bullet-list",
|
icon: "bullet-list",
|
||||||
@@ -568,7 +581,7 @@ export function getToolInfo(
|
|||||||
case "skill":
|
case "skill":
|
||||||
return {
|
return {
|
||||||
icon: "brain",
|
icon: "brain",
|
||||||
title: input.name || i18n.t("ui.tool.skill"),
|
title: skillToolName(input, metadata) || i18n.t("ui.tool.skill"),
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
@@ -847,7 +860,7 @@ function contextToolDetail(part: ToolPart): string | undefined {
|
|||||||
function contextToolTrigger(part: ToolPart, i18n: ReturnType<typeof useI18n>) {
|
function contextToolTrigger(part: ToolPart, i18n: ReturnType<typeof useI18n>) {
|
||||||
const input = (part.state.input ?? {}) as Record<string, unknown>
|
const input = (part.state.input ?? {}) as Record<string, unknown>
|
||||||
const path = typeof input.path === "string" ? input.path : "/"
|
const path = typeof input.path === "string" ? input.path : "/"
|
||||||
const filePath = typeof input.filePath === "string" ? input.filePath : undefined
|
const filePath = readToolPath(input)
|
||||||
const pattern = typeof input.pattern === "string" ? input.pattern : undefined
|
const pattern = typeof input.pattern === "string" ? input.pattern : undefined
|
||||||
const include = typeof input.include === "string" ? input.include : undefined
|
const include = typeof input.include === "string" ? input.include : undefined
|
||||||
const offset = typeof input.offset === "number" ? input.offset : undefined
|
const offset = typeof input.offset === "number" ? input.offset : undefined
|
||||||
@@ -1793,7 +1806,7 @@ ToolRegistry.register({
|
|||||||
icon="glasses"
|
icon="glasses"
|
||||||
trigger={{
|
trigger={{
|
||||||
title: i18n.t("ui.tool.read"),
|
title: i18n.t("ui.tool.read"),
|
||||||
subtitle: props.input.filePath ? getFilename(props.input.filePath) : "",
|
subtitle: getFilename(readToolPath(props.input) ?? ""),
|
||||||
args,
|
args,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -2624,7 +2637,7 @@ ToolRegistry.register({
|
|||||||
name: "skill",
|
name: "skill",
|
||||||
render(props) {
|
render(props) {
|
||||||
const i18n = useI18n()
|
const i18n = useI18n()
|
||||||
const title = createMemo(() => props.input.name || i18n.t("ui.tool.skill"))
|
const title = createMemo(() => skillToolName(props.input, props.metadata) || i18n.t("ui.tool.skill"))
|
||||||
const running = createMemo(() => props.status === "pending" || props.status === "running")
|
const running = createMemo(() => props.status === "pending" || props.status === "running")
|
||||||
|
|
||||||
const titleContent = () => <TextShimmer text={title()} active={running()} />
|
const titleContent = () => <TextShimmer text={title()} active={running()} />
|
||||||
|
|||||||
@@ -26,6 +26,21 @@ describe("partDefaultOpen", () => {
|
|||||||
).toBe(false)
|
).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("collapses v2 patches containing only deleted files when enabled", () => {
|
||||||
|
expect(
|
||||||
|
partDefaultOpen(
|
||||||
|
tool("patch", {
|
||||||
|
files: [
|
||||||
|
{ file: "one.ts", status: "deleted" },
|
||||||
|
{ file: "two.ts", status: "deleted" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
test("keeps mixed patches expanded when enabled", () => {
|
test("keeps mixed patches expanded when enabled", () => {
|
||||||
expect(
|
expect(
|
||||||
partDefaultOpen(
|
partDefaultOpen(
|
||||||
@@ -41,6 +56,21 @@ describe("partDefaultOpen", () => {
|
|||||||
).toBe(true)
|
).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("keeps mixed v2 patches expanded when enabled", () => {
|
||||||
|
expect(
|
||||||
|
partDefaultOpen(
|
||||||
|
tool("patch", {
|
||||||
|
files: [
|
||||||
|
{ file: "one.ts", status: "deleted" },
|
||||||
|
{ file: "two.ts", status: "modified" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
test("preserves shell defaults", () => {
|
test("preserves shell defaults", () => {
|
||||||
expect(partDefaultOpen(tool("shell", {}), true, false)).toBe(true)
|
expect(partDefaultOpen(tool("shell", {}), true, false)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ function deletionOnly(part: ToolPart) {
|
|||||||
|
|
||||||
const files = metadata.files
|
const files = metadata.files
|
||||||
if (Array.isArray(files) && files.length > 0) {
|
if (Array.isArray(files) && files.length > 0) {
|
||||||
return files.every((file) => !!file && typeof file === "object" && "type" in file && file.type === "delete")
|
return files.every(
|
||||||
|
(file) =>
|
||||||
|
!!file &&
|
||||||
|
typeof file === "object" &&
|
||||||
|
(("type" in file && file.type === "delete") || ("status" in file && file.status === "deleted")),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const filediff = metadata.filediff
|
const filediff = metadata.filediff
|
||||||
@@ -16,11 +21,12 @@ function deletionOnly(part: ToolPart) {
|
|||||||
return filediff.additions === 0 && typeof filediff.deletions === "number" && filediff.deletions > 0
|
return filediff.additions === 0 && typeof filediff.deletions === "number" && filediff.deletions > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
export function partDefaultOpen(part: PartType, shell = false, edit = false) {
|
export function partDefaultOpen(part: PartType, shell = false, edit = false): boolean | undefined {
|
||||||
if (part.type !== "tool") return
|
if (part.type !== "tool") return undefined
|
||||||
if (part.tool === "bash" || part.tool === "shell") return shell
|
if (part.tool === "bash" || part.tool === "shell") return shell
|
||||||
if (part.tool === "edit" || part.tool === "write" || part.tool === "patch" || part.tool === "apply_patch") {
|
if (part.tool === "edit" || part.tool === "write" || part.tool === "patch" || part.tool === "apply_patch") {
|
||||||
if (!edit) return false
|
if (!edit) return false
|
||||||
return !deletionOnly(part)
|
return !deletionOnly(part)
|
||||||
}
|
}
|
||||||
|
return undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createMemo, createResource, createSignal, onMount, Show } from "solid-js"
|
import { createMemo, createResource, createSignal, onMount, Show } from "solid-js"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import type { SessionInfo } from "@opencode-ai/client"
|
import type { SessionInfo } from "@opencode-ai/client"
|
||||||
|
import { Project } from "@opencode-ai/schema/project"
|
||||||
import { TextAttributes } from "@opentui/core"
|
import { TextAttributes } from "@opentui/core"
|
||||||
import type { RGBA } from "@opentui/core"
|
import type { RGBA } from "@opentui/core"
|
||||||
import { useDialog } from "../ui/dialog"
|
import { useDialog } from "../ui/dialog"
|
||||||
@@ -54,10 +55,12 @@ export function DialogSessionList() {
|
|||||||
const response = await client.api.session.list({
|
const response = await client.api.session.list({
|
||||||
...(allProjects
|
...(allProjects
|
||||||
? {}
|
? {}
|
||||||
: {
|
: current.project.id === Project.ID.global
|
||||||
project: current.project.id,
|
? { directory: current.directory }
|
||||||
subpath: path.relative(current.project.directory, current.directory).replaceAll("\\", "/"),
|
: {
|
||||||
}),
|
project: current.project.id,
|
||||||
|
subpath: path.relative(current.project.directory, current.directory).replaceAll("\\", "/"),
|
||||||
|
}),
|
||||||
...(query ? { search: query } : {}),
|
...(query ? { search: query } : {}),
|
||||||
limit: 50,
|
limit: 50,
|
||||||
order: "desc",
|
order: "desc",
|
||||||
|
|||||||
@@ -57,10 +57,7 @@ import {
|
|||||||
type LocalAttachment,
|
type LocalAttachment,
|
||||||
} from "./local-attachment"
|
} from "./local-attachment"
|
||||||
import { useData } from "../../context/data"
|
import { useData } from "../../context/data"
|
||||||
import { usePromptRef } from "../../context/prompt"
|
|
||||||
import { useLocation } from "../../context/location"
|
import { useLocation } from "../../context/location"
|
||||||
import type { PromptFileAttachment, PromptSkillAttachment, SkillInfo } from "@opencode-ai/client"
|
|
||||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
|
||||||
import { Keymap, type KeymapCommand } from "../../context/keymap"
|
import { Keymap, type KeymapCommand } from "../../context/keymap"
|
||||||
import { abbreviateHome } from "../../runtime"
|
import { abbreviateHome } from "../../runtime"
|
||||||
import { Slot } from "../../plugin/render"
|
import { Slot } from "../../plugin/render"
|
||||||
@@ -74,6 +71,7 @@ import { DialogImagePreview } from "../dialog-image-preview"
|
|||||||
import { useDirectoryRecents } from "../../prompt/directory-recents"
|
import { useDirectoryRecents } from "../../prompt/directory-recents"
|
||||||
import { directoryRecentValue } from "../../prompt/directory-completion"
|
import { directoryRecentValue } from "../../prompt/directory-completion"
|
||||||
import { useWorkingDirectoryActions } from "../../ui/working-directory-actions"
|
import { useWorkingDirectoryActions } from "../../ui/working-directory-actions"
|
||||||
|
import { truncateFilePath } from "../../ui/file-path"
|
||||||
|
|
||||||
export type PromptProps = {
|
export type PromptProps = {
|
||||||
sessionID?: string
|
sessionID?: string
|
||||||
@@ -103,59 +101,6 @@ export type PromptRef = {
|
|||||||
|
|
||||||
const DRAFT_RETENTION_MIN_CHARS = 20
|
const DRAFT_RETENTION_MIN_CHARS = 20
|
||||||
|
|
||||||
// Serialize background prompt submissions per session so admission order matches
|
|
||||||
// the on-screen optimistic order, even across Prompt remounts and route changes.
|
|
||||||
const submitTails = new Map<string, Promise<void>>()
|
|
||||||
|
|
||||||
function enqueueSubmit(sessionID: string, task: () => Promise<void>) {
|
|
||||||
const tail = (submitTails.get(sessionID) ?? Promise.resolve()).then(task, task)
|
|
||||||
submitTails.set(sessionID, tail)
|
|
||||||
void tail.finally(() => {
|
|
||||||
if (submitTails.get(sessionID) === tail) submitTails.delete(sessionID)
|
|
||||||
})
|
|
||||||
return tail
|
|
||||||
}
|
|
||||||
|
|
||||||
// Approximate the server's materialized attachment shape for the local echo. Pasted
|
|
||||||
// data: URIs carry real content so images preview immediately; file references render
|
|
||||||
// as labels until the admission echo replaces them with server truth.
|
|
||||||
function optimisticFiles(files: PromptInfo["files"]): PromptFileAttachment[] | undefined {
|
|
||||||
if (!files?.length) return undefined
|
|
||||||
return files.map((file) => {
|
|
||||||
const match = /^data:([^;,]*);base64,(.*)$/.exec(file.uri)
|
|
||||||
if (match)
|
|
||||||
return {
|
|
||||||
data: match[2] ?? "",
|
|
||||||
mime: match[1] || "application/octet-stream",
|
|
||||||
source: { type: "inline" as const },
|
|
||||||
name: file.name,
|
|
||||||
description: file.description,
|
|
||||||
mention: file.mention,
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
data: "",
|
|
||||||
mime: file.uri.endsWith("/") ? "application/x-directory" : "text/plain",
|
|
||||||
source: { type: "uri" as const, uri: file.uri },
|
|
||||||
name: file.name,
|
|
||||||
description: file.description,
|
|
||||||
mention: file.mention,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function optimisticSkills(
|
|
||||||
skills: PromptInfo["skills"],
|
|
||||||
available: SkillInfo[],
|
|
||||||
): PromptSkillAttachment[] | undefined {
|
|
||||||
if (!skills?.length) return undefined
|
|
||||||
return skills.map((attachment) => ({
|
|
||||||
id: attachment.id,
|
|
||||||
name: available.find((skill) => skill.id === attachment.id)?.name ?? attachment.id,
|
|
||||||
text: "",
|
|
||||||
mention: attachment.mention,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
function randomIndex(count: number) {
|
function randomIndex(count: number) {
|
||||||
if (count <= 0) return 0
|
if (count <= 0) return 0
|
||||||
return Math.floor(Math.random() * count)
|
return Math.floor(Math.random() * count)
|
||||||
@@ -257,7 +202,6 @@ export function Prompt(props: PromptProps) {
|
|||||||
const editor = useEditorContext()
|
const editor = useEditorContext()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const data = useData()
|
const data = useData()
|
||||||
const activePrompt = usePromptRef()
|
|
||||||
const directoryRecents = useDirectoryRecents()
|
const directoryRecents = useDirectoryRecents()
|
||||||
const keymapCommands = Keymap.useCommands()
|
const keymapCommands = Keymap.useCommands()
|
||||||
const currentLocation = useLocation()
|
const currentLocation = useLocation()
|
||||||
@@ -1127,20 +1071,6 @@ export function Prompt(props: PromptProps) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Return a failed background submission to its author. When the target tab's
|
|
||||||
// editor is live and empty, restore directly; otherwise stash a draft for the
|
|
||||||
// next mount. A non-empty editor is never clobbered—prompt history retains the
|
|
||||||
// failed prompt either way.
|
|
||||||
function restorePrompt(sessionID: string, snapshot: PromptInfo) {
|
|
||||||
const active = route.data
|
|
||||||
if (active.type === "session" && active.sessionID === sessionID) {
|
|
||||||
const live = activePrompt.current
|
|
||||||
if (live && !live.current.text.trim()) live.set(snapshot)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
saveDraft(sessionID, { prompt: snapshot, cursor: snapshot.text.length })
|
|
||||||
}
|
|
||||||
|
|
||||||
let submitting = false
|
let submitting = false
|
||||||
async function submit(delivery: SessionInbox.Delivery = "steer") {
|
async function submit(delivery: SessionInbox.Delivery = "steer") {
|
||||||
// Prevent overlapping invocations (e.g. a double-pressed Enter, or the
|
// Prevent overlapping invocations (e.g. a double-pressed Enter, or the
|
||||||
@@ -1241,6 +1171,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
|
|
||||||
const variant = selection.variant
|
const variant = selection.variant
|
||||||
let sessionID = props.sessionID
|
let sessionID = props.sessionID
|
||||||
|
let session = sessionID ? data.session.get(sessionID) : undefined
|
||||||
let finishMoveProgress = false
|
let finishMoveProgress = false
|
||||||
if (sessionID == null) {
|
if (sessionID == null) {
|
||||||
const directory = await move.getDirectory()
|
const directory = await move.getDirectory()
|
||||||
@@ -1274,6 +1205,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sessionID = created.id
|
sessionID = created.id
|
||||||
|
session = created
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture mode before it gets reset
|
// Capture mode before it gets reset
|
||||||
@@ -1314,70 +1246,70 @@ export function Prompt(props: PromptProps) {
|
|||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
move.startSubmit()
|
move.startSubmit()
|
||||||
// Echo the prompt locally and admit it in the background: the editor clears and
|
if (!session) {
|
||||||
// the message renders immediately, while a per-session queue preserves admission
|
await data.session.sync(sessionID)
|
||||||
// order. Failure rolls the echo back and restores the captured prompt.
|
session = data.session.get(sessionID)
|
||||||
const submitSessionID = sessionID
|
}
|
||||||
const messageID = SessionMessage.ID.create()
|
if (session?.agent !== agent.id) {
|
||||||
const snapshot = structuredClone(unwrap(store.prompt))
|
await client.api.session.switchAgent({ sessionID, agent: agent.id })
|
||||||
const editorContextText = pendingEditorSelection ? formatEditorContext(pendingEditorSelection) : undefined
|
}
|
||||||
const targetAgent = agent.id
|
if (
|
||||||
const model = { providerID: selection.providerID, id: selection.modelID, variant }
|
session?.model?.providerID !== selection.providerID ||
|
||||||
data.session.optimistic.prompt({
|
session.model.id !== selection.modelID ||
|
||||||
sessionID: submitSessionID,
|
(session.model.variant ?? "default") !== (variant ?? "default")
|
||||||
messageID,
|
) {
|
||||||
delivery,
|
const model = { providerID: selection.providerID, id: selection.modelID, variant }
|
||||||
text: inputText,
|
const cancelCommit = local.model.trackSessionCommit(sessionID, model)
|
||||||
files: optimisticFiles(snapshot.files),
|
await client.api.session.switchModel({ sessionID, model }).catch((error) => {
|
||||||
agents: snapshot.agents?.length ? snapshot.agents : undefined,
|
cancelCommit()
|
||||||
skills: optimisticSkills(snapshot.skills, data.location.skill.list(currentLocation.ref) ?? []),
|
throw error
|
||||||
})
|
})
|
||||||
// Mark the editor context sent with the echo so a rapid follow-up submit
|
}
|
||||||
// does not re-attach the same selection while admission is in flight.
|
if (session?.revert) {
|
||||||
if (editorContextText) editor.markSelectionSent()
|
const error = await client.api.session.revert.commit({ sessionID }).then(
|
||||||
void enqueueSubmit(submitSessionID, async () => {
|
|
||||||
const error = await (async () => {
|
|
||||||
let session = data.session.get(submitSessionID)
|
|
||||||
if (!session) {
|
|
||||||
await data.session.sync(submitSessionID)
|
|
||||||
session = data.session.get(submitSessionID)
|
|
||||||
}
|
|
||||||
if (session?.agent !== targetAgent) {
|
|
||||||
await client.api.session.switchAgent({ sessionID: submitSessionID, agent: targetAgent })
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
session?.model?.providerID !== model.providerID ||
|
|
||||||
session.model.id !== model.id ||
|
|
||||||
(session.model.variant ?? "default") !== (model.variant ?? "default")
|
|
||||||
) {
|
|
||||||
const cancelCommit = local.model.trackSessionCommit(submitSessionID, model)
|
|
||||||
await client.api.session.switchModel({ sessionID: submitSessionID, model }).catch((error) => {
|
|
||||||
cancelCommit()
|
|
||||||
throw error
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (session?.revert) await client.api.session.revert.commit({ sessionID: submitSessionID })
|
|
||||||
// Keep editor context hidden while admitting it before the corresponding user prompt.
|
|
||||||
if (editorContextText)
|
|
||||||
await client.api.session.synthetic({ sessionID: submitSessionID, text: editorContextText, resume: false })
|
|
||||||
await client.api.session.prompt({
|
|
||||||
sessionID: submitSessionID,
|
|
||||||
id: messageID,
|
|
||||||
text: inputText,
|
|
||||||
files: snapshot.files,
|
|
||||||
agents: snapshot.agents,
|
|
||||||
skills: snapshot.skills?.length ? snapshot.skills : undefined,
|
|
||||||
delivery,
|
|
||||||
})
|
|
||||||
})().then(
|
|
||||||
() => undefined,
|
() => undefined,
|
||||||
(error) => error,
|
(error) => error,
|
||||||
)
|
)
|
||||||
if (error === undefined) return
|
if (error) {
|
||||||
data.session.optimistic.rollback(submitSessionID, messageID)
|
toast.show({ title: "Failed to commit revert", message: errorMessage(error), variant: "error" })
|
||||||
restorePrompt(submitSessionID, snapshot)
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pendingEditorSelection) {
|
||||||
|
// Keep editor context hidden while admitting it before the corresponding user prompt.
|
||||||
|
const error = await client.api.session
|
||||||
|
.synthetic({
|
||||||
|
sessionID,
|
||||||
|
text: formatEditorContext(pendingEditorSelection),
|
||||||
|
resume: false,
|
||||||
|
})
|
||||||
|
.then(
|
||||||
|
() => undefined,
|
||||||
|
(error) => error,
|
||||||
|
)
|
||||||
|
if (error) {
|
||||||
|
toast.show({ title: "Failed to send editor context", message: errorMessage(error), variant: "error" })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const error = await client.api.session
|
||||||
|
.prompt({
|
||||||
|
sessionID,
|
||||||
|
text: inputText,
|
||||||
|
files: store.prompt.files,
|
||||||
|
agents: store.prompt.agents,
|
||||||
|
skills: store.prompt.skills?.length ? store.prompt.skills : undefined,
|
||||||
|
delivery,
|
||||||
|
})
|
||||||
|
.then(
|
||||||
|
() => undefined,
|
||||||
|
(error) => error,
|
||||||
|
)
|
||||||
|
if (error) {
|
||||||
toast.show({ title: "Failed to send prompt", message: errorMessage(error), variant: "error" })
|
toast.show({ title: "Failed to send prompt", message: errorMessage(error), variant: "error" })
|
||||||
})
|
return false
|
||||||
|
}
|
||||||
|
if (pendingEditorSelection) editor.markSelectionSent()
|
||||||
}
|
}
|
||||||
history.append({
|
history.append({
|
||||||
...store.prompt,
|
...store.prompt,
|
||||||
@@ -1388,14 +1320,15 @@ export function Prompt(props: PromptProps) {
|
|||||||
setStore("extmarkToPart", new Map())
|
setStore("extmarkToPart", new Map())
|
||||||
props.onSubmit?.()
|
props.onSubmit?.()
|
||||||
|
|
||||||
|
// temporary hack to make sure the message is sent
|
||||||
if (!props.sessionID) {
|
if (!props.sessionID) {
|
||||||
if (pendingEditorSelection) editor.preserveSelectionFromNewSession()
|
if (pendingEditorSelection) editor.preserveSelectionFromNewSession()
|
||||||
// The optimistic echo is already in the data store, so the session route
|
setTimeout(() => {
|
||||||
// renders the prompt immediately; admission continues in the background.
|
route.navigate({
|
||||||
route.navigate({
|
type: "session",
|
||||||
type: "session",
|
sessionID,
|
||||||
sessionID,
|
})
|
||||||
})
|
}, 50)
|
||||||
}
|
}
|
||||||
input.clear()
|
input.clear()
|
||||||
if (finishMoveProgress) move.finishSubmit()
|
if (finishMoveProgress) move.finishSubmit()
|
||||||
@@ -1623,6 +1556,12 @@ export function Prompt(props: PromptProps) {
|
|||||||
const branch = data.location.vcs.info(location)?.branch.current
|
const branch = data.location.vcs.info(location)?.branch.current
|
||||||
return branch ? `${directory}:${branch}` : directory
|
return branch ? `${directory}:${branch}` : directory
|
||||||
})
|
})
|
||||||
|
const [locationWidth, setLocationWidth] = createSignal(dimensions().width)
|
||||||
|
const locationLabelDisplay = createMemo(() => {
|
||||||
|
const label = locationLabel()
|
||||||
|
if (!label) return
|
||||||
|
return truncateFilePath(label, locationWidth())
|
||||||
|
})
|
||||||
const locationActions = useWorkingDirectoryActions({
|
const locationActions = useWorkingDirectoryActions({
|
||||||
directory: () => footerLocation()?.directory,
|
directory: () => footerLocation()?.directory,
|
||||||
onMove: () => void move.open(),
|
onMove: () => void move.open(),
|
||||||
@@ -1908,7 +1847,15 @@ export function Prompt(props: PromptProps) {
|
|||||||
<box width="100%" flexDirection="row" justifyContent="space-between" gap={2}>
|
<box width="100%" flexDirection="row" justifyContent="space-between" gap={2}>
|
||||||
<Slot path="prompt.footer" input={footerInput()}>
|
<Slot path="prompt.footer" input={footerInput()}>
|
||||||
<Slot path="prompt.footer.status" input={footerInput()}>
|
<Slot path="prompt.footer.status" input={footerInput()}>
|
||||||
<box flexGrow={1} flexShrink={1} minWidth={0}>
|
<box
|
||||||
|
flexGrow={1}
|
||||||
|
flexShrink={1}
|
||||||
|
minWidth={0}
|
||||||
|
onSizeChange={function (this: BoxRenderable) {
|
||||||
|
const width = this.width
|
||||||
|
queueMicrotask(() => setLocationWidth(width))
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={status() === "running"}>
|
<Match when={status() === "running"}>
|
||||||
<box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start">
|
<box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start">
|
||||||
@@ -1945,7 +1892,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
</box>
|
</box>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={true}>
|
<Match when={true}>
|
||||||
<Show when={!props.hint && locationLabel()} fallback={props.hint ?? <text />}>
|
<Show when={!props.hint && locationLabelDisplay()} fallback={props.hint ?? <text />}>
|
||||||
{(location) => (
|
{(location) => (
|
||||||
<text
|
<text
|
||||||
id="prompt.footer.location"
|
id="prompt.footer.location"
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import type {
|
|||||||
ProviderInfo,
|
ProviderInfo,
|
||||||
ReferenceInfo,
|
ReferenceInfo,
|
||||||
SessionMessageInfo,
|
SessionMessageInfo,
|
||||||
SessionMessageUser,
|
|
||||||
SessionMessageAssistant,
|
SessionMessageAssistant,
|
||||||
SessionMessageAssistantReasoning,
|
SessionMessageAssistantReasoning,
|
||||||
SessionMessageAssistantText,
|
SessionMessageAssistantText,
|
||||||
@@ -41,7 +40,7 @@ import { useClient } from "./client"
|
|||||||
import { nonEmptyToolContent } from "../util/tool-display"
|
import { nonEmptyToolContent } from "../util/tool-display"
|
||||||
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
|
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
|
||||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||||
import { batch, createEffect, createSignal, onCleanup } from "solid-js"
|
import { createEffect, createSignal, onCleanup } from "solid-js"
|
||||||
|
|
||||||
export type DataSessionStatus = "idle" | "running"
|
export type DataSessionStatus = "idle" | "running"
|
||||||
|
|
||||||
@@ -162,25 +161,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
const messageIndex = new Map<string, Map<string, number>>()
|
const messageIndex = new Map<string, Map<string, number>>()
|
||||||
const sync = createSync()
|
const sync = createSync()
|
||||||
|
|
||||||
// Optimistic prompt echoes: user prompts applied locally before server admission,
|
|
||||||
// keyed sessionID -> messageID. The session.inbox.enqueued echo carrying the same
|
|
||||||
// ID replaces the local copy with server truth (admission materializes file
|
|
||||||
// attachments and expands skills). Entries survive wholesale sync replaces until
|
|
||||||
// the server confirms them or the submitter rolls back.
|
|
||||||
type OptimisticPrompt = { message: SessionMessageInfo; pending: SessionInboxInfo }
|
|
||||||
const optimisticPrompts = new Map<string, Map<string, OptimisticPrompt>>()
|
|
||||||
|
|
||||||
function confirmOptimistic(sessionID: string, messageID: string) {
|
|
||||||
const entries = optimisticPrompts.get(sessionID)
|
|
||||||
if (!entries?.delete(messageID)) return false
|
|
||||||
if (entries.size === 0) optimisticPrompts.delete(sessionID)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
function optimisticMessages(sessionID: string) {
|
|
||||||
return [...(optimisticPrompts.get(sessionID)?.values() ?? [])]
|
|
||||||
}
|
|
||||||
|
|
||||||
function setSessionActive(sessionID: string, status: DataSessionStatus) {
|
function setSessionActive(sessionID: string, status: DataSessionStatus) {
|
||||||
setStore("session", "active", sessionID, status)
|
setStore("session", "active", sessionID, status)
|
||||||
}
|
}
|
||||||
@@ -208,17 +188,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeMessage(sessionID: string, messageID: string) {
|
|
||||||
if (!messageIndex.get(sessionID)?.has(messageID)) return
|
|
||||||
message.update(sessionID, (draft, index) => {
|
|
||||||
const position = index.get(messageID)
|
|
||||||
if (position === undefined) return
|
|
||||||
draft.splice(position, 1)
|
|
||||||
index.delete(messageID)
|
|
||||||
message.reindex(draft, index, position)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function removePermission(sessionID: string, requestID: string) {
|
function removePermission(sessionID: string, requestID: string) {
|
||||||
const requests = store.session.permission[sessionID]
|
const requests = store.session.permission[sessionID]
|
||||||
if (!requests?.some((request) => request.id === requestID)) return
|
if (!requests?.some((request) => request.id === requestID)) return
|
||||||
@@ -344,7 +313,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
|
|
||||||
function removeSession(sessionID: string) {
|
function removeSession(sessionID: string) {
|
||||||
messageIndex.delete(sessionID)
|
messageIndex.delete(sessionID)
|
||||||
optimisticPrompts.delete(sessionID)
|
|
||||||
sync.invalidate(`session:${sessionID}`)
|
sync.invalidate(`session:${sessionID}`)
|
||||||
sync.invalidate(`session.pending:${sessionID}`)
|
sync.invalidate(`session.pending:${sessionID}`)
|
||||||
sync.invalidate(`session.message:${sessionID}`)
|
sync.invalidate(`session.message:${sessionID}`)
|
||||||
@@ -503,9 +471,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "session.inbox.delivered": {
|
case "session.inbox.delivered": {
|
||||||
// Delivery implies the message is projected server-side, so future message
|
|
||||||
// fetches include it and the optimistic entry no longer needs re-appending.
|
|
||||||
confirmOptimistic(event.data.sessionID, event.data.inboxID)
|
|
||||||
const admitted = store.session.input[event.data.sessionID]?.includes(event.data.inboxID) ?? false
|
const admitted = store.session.input[event.data.sessionID]?.includes(event.data.inboxID) ?? false
|
||||||
removePending(event.data.sessionID, event.data.inboxID)
|
removePending(event.data.sessionID, event.data.inboxID)
|
||||||
message.update(event.data.sessionID, (draft, index) => {
|
message.update(event.data.sessionID, (draft, index) => {
|
||||||
@@ -524,31 +489,25 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
updatePending(event.data.sessionID, event.data.inboxID, event.data.delivery)
|
updatePending(event.data.sessionID, event.data.inboxID, event.data.delivery)
|
||||||
break
|
break
|
||||||
case "session.inbox.cancelled": {
|
case "session.inbox.cancelled": {
|
||||||
confirmOptimistic(event.data.sessionID, event.data.inboxID)
|
|
||||||
removePending(event.data.sessionID, event.data.inboxID)
|
removePending(event.data.sessionID, event.data.inboxID)
|
||||||
removeMessage(event.data.sessionID, event.data.inboxID)
|
if (messageIndex.get(event.data.sessionID)?.has(event.data.inboxID))
|
||||||
|
message.update(event.data.sessionID, (draft, index) => {
|
||||||
|
const position = index.get(event.data.inboxID)
|
||||||
|
if (position === undefined) return
|
||||||
|
draft.splice(position, 1)
|
||||||
|
index.delete(event.data.inboxID)
|
||||||
|
message.reindex(draft, index, position)
|
||||||
|
})
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "session.inbox.enqueued": {
|
case "session.inbox.enqueued": {
|
||||||
const item = event.data.item
|
const item = event.data.item
|
||||||
// The admission echo is authoritative for an optimistic local copy: it
|
addPending({
|
||||||
// materializes file attachments and expands skills, so replace in place.
|
|
||||||
const confirmed = confirmOptimistic(event.data.sessionID, event.data.inboxID)
|
|
||||||
const pendingItem: SessionInboxInfo = {
|
|
||||||
id: event.data.inboxID,
|
id: event.data.inboxID,
|
||||||
sessionID: event.data.sessionID,
|
sessionID: event.data.sessionID,
|
||||||
timeCreated: event.created,
|
timeCreated: event.created,
|
||||||
...item,
|
...item,
|
||||||
}
|
})
|
||||||
const pendingList = store.session.pending[event.data.sessionID]
|
|
||||||
if (confirmed && pendingList?.some((pending) => pending.id === event.data.inboxID))
|
|
||||||
setStore(
|
|
||||||
"session",
|
|
||||||
"pending",
|
|
||||||
event.data.sessionID,
|
|
||||||
pendingList.map((pending) => (pending.id === event.data.inboxID ? pendingItem : pending)),
|
|
||||||
)
|
|
||||||
else addPending(pendingItem)
|
|
||||||
if (!store.session.input[event.data.sessionID]?.includes(event.data.inboxID))
|
if (!store.session.input[event.data.sessionID]?.includes(event.data.inboxID))
|
||||||
setStore("session", "input", event.data.sessionID, [
|
setStore("session", "input", event.data.sessionID, [
|
||||||
...(store.session.input[event.data.sessionID] ?? []),
|
...(store.session.input[event.data.sessionID] ?? []),
|
||||||
@@ -556,7 +515,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
])
|
])
|
||||||
if (item.type !== "user" && item.type !== "synthetic") break
|
if (item.type !== "user" && item.type !== "synthetic") break
|
||||||
message.update(event.data.sessionID, (draft, index) => {
|
message.update(event.data.sessionID, (draft, index) => {
|
||||||
const next: SessionMessageInfo =
|
message.append(
|
||||||
|
draft,
|
||||||
|
index,
|
||||||
item.type === "user"
|
item.type === "user"
|
||||||
? {
|
? {
|
||||||
id: event.data.inboxID,
|
id: event.data.inboxID,
|
||||||
@@ -569,13 +530,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
type: "synthetic",
|
type: "synthetic",
|
||||||
...item.payload,
|
...item.payload,
|
||||||
time: { created: event.created },
|
time: { created: event.created },
|
||||||
}
|
},
|
||||||
const position = index.get(event.data.inboxID)
|
)
|
||||||
if (confirmed && position !== undefined) {
|
|
||||||
draft[position] = next
|
|
||||||
return
|
|
||||||
}
|
|
||||||
message.append(draft, index, next)
|
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -869,30 +825,22 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
if (store.session.info[event.data.sessionID])
|
if (store.session.info[event.data.sessionID])
|
||||||
setStore("session", "info", event.data.sessionID, "revert", undefined)
|
setStore("session", "info", event.data.sessionID, "revert", undefined)
|
||||||
break
|
break
|
||||||
case "session.revert.committed": {
|
case "session.revert.committed":
|
||||||
if (store.session.info[event.data.sessionID]) {
|
if (store.session.info[event.data.sessionID]) {
|
||||||
setStore("session", "info", event.data.sessionID, "revert", undefined)
|
setStore("session", "info", event.data.sessionID, "revert", undefined)
|
||||||
}
|
}
|
||||||
// Unconfirmed optimistic prompts postdate the revert boundary but were never
|
|
||||||
// part of the reverted history: the server admits them after the commit.
|
|
||||||
const local = optimisticPrompts.get(event.data.sessionID)
|
|
||||||
setStore(
|
setStore(
|
||||||
"session",
|
"session",
|
||||||
"input",
|
"input",
|
||||||
event.data.sessionID,
|
event.data.sessionID,
|
||||||
(store.session.input[event.data.sessionID] ?? []).filter(
|
(store.session.input[event.data.sessionID] ?? []).filter((id) => id < event.data.to),
|
||||||
(id) => id < event.data.to || local?.has(id) === true,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
message.update(event.data.sessionID, (draft, index) => {
|
message.update(event.data.sessionID, (draft, index) => {
|
||||||
const position = draft.findIndex((item) => item.id >= event.data.to)
|
const position = draft.findIndex((item) => item.id >= event.data.to)
|
||||||
if (position === -1) return
|
if (position === -1) return
|
||||||
const dropped = draft.splice(position)
|
for (const item of draft.splice(position)) index.delete(item.id)
|
||||||
for (const item of dropped) index.delete(item.id)
|
|
||||||
for (const item of dropped) if (local?.has(item.id)) message.append(draft, index, item)
|
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
}
|
|
||||||
case "session.compaction.delta":
|
case "session.compaction.delta":
|
||||||
message.update(event.data.sessionID, (draft) => {
|
message.update(event.data.sessionID, (draft) => {
|
||||||
const current = message.compaction(draft)
|
const current = message.compaction(draft)
|
||||||
@@ -1065,74 +1013,13 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
return store.session.input[sessionID]?.includes(inboxID) ?? false
|
return store.session.input[sessionID]?.includes(inboxID) ?? false
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
optimistic: {
|
|
||||||
// Locally echo a user prompt before server admission. The session.inbox.enqueued
|
|
||||||
// echo carrying the same message ID replaces the copy with server truth; rollback
|
|
||||||
// removes the echo when submission fails.
|
|
||||||
prompt(input: {
|
|
||||||
sessionID: string
|
|
||||||
messageID: string
|
|
||||||
delivery: SessionInbox.Delivery
|
|
||||||
text: string
|
|
||||||
files?: SessionMessageUser["files"]
|
|
||||||
agents?: SessionMessageUser["agents"]
|
|
||||||
skills?: SessionMessageUser["skills"]
|
|
||||||
}) {
|
|
||||||
const created = Date.now()
|
|
||||||
const pendingItem: SessionInboxInfo = {
|
|
||||||
id: input.messageID,
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
timeCreated: created,
|
|
||||||
type: "user",
|
|
||||||
payload: { text: input.text, files: input.files, agents: input.agents, skills: input.skills },
|
|
||||||
delivery: input.delivery,
|
|
||||||
}
|
|
||||||
const messageItem: SessionMessageInfo = {
|
|
||||||
id: input.messageID,
|
|
||||||
type: "user",
|
|
||||||
text: input.text,
|
|
||||||
files: input.files,
|
|
||||||
agents: input.agents,
|
|
||||||
skills: input.skills,
|
|
||||||
time: { created },
|
|
||||||
}
|
|
||||||
const entries = optimisticPrompts.get(input.sessionID) ?? new Map<string, OptimisticPrompt>()
|
|
||||||
optimisticPrompts.set(input.sessionID, entries)
|
|
||||||
entries.set(input.messageID, { message: messageItem, pending: pendingItem })
|
|
||||||
batch(() => {
|
|
||||||
addPending(pendingItem)
|
|
||||||
if (!store.session.input[input.sessionID]?.includes(input.messageID))
|
|
||||||
setStore("session", "input", input.sessionID, [
|
|
||||||
...(store.session.input[input.sessionID] ?? []),
|
|
||||||
input.messageID,
|
|
||||||
])
|
|
||||||
message.update(input.sessionID, (draft, index) => message.append(draft, index, messageItem))
|
|
||||||
})
|
|
||||||
},
|
|
||||||
rollback(sessionID: string, messageID: string) {
|
|
||||||
if (!confirmOptimistic(sessionID, messageID)) return
|
|
||||||
batch(() => {
|
|
||||||
removePending(sessionID, messageID)
|
|
||||||
removeMessage(sessionID, messageID)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
pending: {
|
pending: {
|
||||||
list(sessionID: string) {
|
list(sessionID: string) {
|
||||||
return store.session.pending[sessionID] ?? []
|
return store.session.pending[sessionID] ?? []
|
||||||
},
|
},
|
||||||
sync(sessionID: string) {
|
sync(sessionID: string) {
|
||||||
return sync.run(`session.pending:${sessionID}`, async () => {
|
return sync.run(`session.pending:${sessionID}`, async () => {
|
||||||
const fetched = await client.api.session.inbox.list({ sessionID })
|
const pending = await client.api.session.inbox.list({ sessionID })
|
||||||
// Keep unconfirmed optimistic prompts pending across the wholesale replace.
|
|
||||||
// Server presence here is not treated as confirmation: until the enqueued
|
|
||||||
// echo or a projected message arrives, the ledger still guards message.sync.
|
|
||||||
const pending = [
|
|
||||||
...fetched,
|
|
||||||
...optimisticMessages(sessionID)
|
|
||||||
.map((entry) => entry.pending)
|
|
||||||
.filter((entry) => !fetched.some((item) => item.id === entry.id)),
|
|
||||||
]
|
|
||||||
setStore("session", "pending", sessionID, reconcile(pending))
|
setStore("session", "pending", sessionID, reconcile(pending))
|
||||||
setStore(
|
setStore(
|
||||||
"session",
|
"session",
|
||||||
@@ -1182,15 +1069,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
},
|
},
|
||||||
sync(sessionID: string) {
|
sync(sessionID: string) {
|
||||||
return sync.run(`session.message:${sessionID}`, async () => {
|
return sync.run(`session.message:${sessionID}`, async () => {
|
||||||
const fetched = (
|
const messages = (
|
||||||
await client.api.message.list({ sessionID, limit: 200, order: "desc" })
|
await client.api.message.list({ sessionID, limit: 200, order: "desc" })
|
||||||
).data.toReversed()
|
).data.toReversed()
|
||||||
// A wholesale replace would drop optimistic prompts the server has not
|
|
||||||
// admitted yet. A fetched ID is server confirmation; the rest re-append.
|
|
||||||
for (const entry of optimisticMessages(sessionID))
|
|
||||||
if (fetched.some((item) => item.id === entry.message.id))
|
|
||||||
confirmOptimistic(sessionID, entry.message.id)
|
|
||||||
const messages = [...fetched, ...optimisticMessages(sessionID).map((entry) => entry.message)]
|
|
||||||
messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])))
|
messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])))
|
||||||
setStore("session", "message", sessionID, reconcile(messages))
|
setStore("session", "message", sessionID, reconcile(messages))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,276 +0,0 @@
|
|||||||
/** @jsxImportSource @opentui/solid */
|
|
||||||
import { expect, test } from "bun:test"
|
|
||||||
import { testRender } from "@opentui/solid"
|
|
||||||
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client"
|
|
||||||
import { createEffect, type ParentProps } from "solid-js"
|
|
||||||
import { ConfigProvider } from "../../../src/config"
|
|
||||||
import { ClientProvider, useClient } from "../../../src/context/client"
|
|
||||||
import { DataProvider as DataProviderBase, useData } from "../../../src/context/data"
|
|
||||||
import { LocationProvider, useLocation } from "../../../src/context/location"
|
|
||||||
import { createApi, createEventStream, createFetch, directory, json } from "../../fixture/tui-client"
|
|
||||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
|
||||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
|
||||||
|
|
||||||
async function wait(fn: () => boolean, timeout = 2000) {
|
|
||||||
const start = Date.now()
|
|
||||||
while (!fn()) {
|
|
||||||
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
|
|
||||||
await Bun.sleep(10)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function emitEvent(events: ReturnType<typeof createEventStream>, event: OpenCodeEvent) {
|
|
||||||
events.emit({ ...event, location: { directory } })
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = createTuiResolvedConfig()
|
|
||||||
|
|
||||||
function DataProvider(props: ParentProps) {
|
|
||||||
return (
|
|
||||||
<ConfigProvider config={config}>
|
|
||||||
<DataProviderBase>
|
|
||||||
<LocationProvider>
|
|
||||||
<SyncLocation />
|
|
||||||
{props.children}
|
|
||||||
</LocationProvider>
|
|
||||||
</DataProviderBase>
|
|
||||||
</ConfigProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SyncLocation() {
|
|
||||||
const data = useData()
|
|
||||||
const location = useLocation()
|
|
||||||
createEffect(() => location.set(data.location.default()))
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function durable(sessionID: string, seq = 0): { aggregateID: string; seq: number; version: 1 } {
|
|
||||||
return { aggregateID: sessionID, seq, version: 1 }
|
|
||||||
}
|
|
||||||
|
|
||||||
type Harness = {
|
|
||||||
data: ReturnType<typeof useData>
|
|
||||||
client: ReturnType<typeof useClient>
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renderData(fetch: ReturnType<typeof createFetch>["fetch"]) {
|
|
||||||
const harness = {} as Harness
|
|
||||||
|
|
||||||
function Probe() {
|
|
||||||
harness.client = useClient()
|
|
||||||
harness.data = useData()
|
|
||||||
return <box />
|
|
||||||
}
|
|
||||||
|
|
||||||
const app = await testRender(() => (
|
|
||||||
<TestTuiContexts>
|
|
||||||
<ClientProvider api={createApi(fetch)}>
|
|
||||||
<DataProvider>
|
|
||||||
<Probe />
|
|
||||||
</DataProvider>
|
|
||||||
</ClientProvider>
|
|
||||||
</TestTuiContexts>
|
|
||||||
))
|
|
||||||
await wait(() => harness.client.connection.status() === "connected")
|
|
||||||
return { app, ...harness }
|
|
||||||
}
|
|
||||||
|
|
||||||
test("echoes an optimistic prompt and replaces it with the admission echo", async () => {
|
|
||||||
const events = createEventStream()
|
|
||||||
const sessionID = "session-optimistic-echo"
|
|
||||||
const calls = createFetch(undefined, events)
|
|
||||||
const { app, data } = await renderData(calls.fetch)
|
|
||||||
|
|
||||||
try {
|
|
||||||
data.session.optimistic.prompt({
|
|
||||||
sessionID,
|
|
||||||
messageID: "msg_optimistic",
|
|
||||||
delivery: "steer",
|
|
||||||
text: "Hello",
|
|
||||||
files: [{ data: "", mime: "text/plain", source: { type: "uri", uri: "file:///tmp/a.ts" }, name: "a.ts" }],
|
|
||||||
})
|
|
||||||
|
|
||||||
const echoed = data.session.message.get(sessionID, "msg_optimistic")
|
|
||||||
expect(echoed?.type === "user" && echoed.text).toBe("Hello")
|
|
||||||
expect(echoed?.type === "user" && echoed.files?.[0]?.data).toBe("")
|
|
||||||
expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual(["msg_optimistic"])
|
|
||||||
expect(data.session.input.has(sessionID, "msg_optimistic")).toBe(true)
|
|
||||||
|
|
||||||
// Admission materializes attachments, so the echo must be replaced in place.
|
|
||||||
emitEvent(events, {
|
|
||||||
id: "evt_admitted",
|
|
||||||
created: 9,
|
|
||||||
type: "session.inbox.enqueued",
|
|
||||||
durable: durable(sessionID),
|
|
||||||
data: {
|
|
||||||
sessionID,
|
|
||||||
inboxID: "msg_optimistic",
|
|
||||||
item: {
|
|
||||||
type: "user",
|
|
||||||
payload: {
|
|
||||||
text: "Hello",
|
|
||||||
files: [{ data: "QUJD", mime: "text/plain", source: { type: "uri", uri: "file:///tmp/a.ts" }, name: "a.ts" }],
|
|
||||||
},
|
|
||||||
delivery: "steer",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
await wait(() => {
|
|
||||||
const message = data.session.message.get(sessionID, "msg_optimistic")
|
|
||||||
return message?.type === "user" && message.files?.[0]?.data === "QUJD"
|
|
||||||
})
|
|
||||||
expect(data.session.message.list(sessionID)).toHaveLength(1)
|
|
||||||
expect(data.session.pending.list(sessionID)).toHaveLength(1)
|
|
||||||
expect(data.session.pending.list(sessionID)[0]?.timeCreated).toBe(9)
|
|
||||||
} finally {
|
|
||||||
app.renderer.destroy()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("rolls back a failed optimistic prompt", async () => {
|
|
||||||
const events = createEventStream()
|
|
||||||
const sessionID = "session-optimistic-rollback"
|
|
||||||
const calls = createFetch(undefined, events)
|
|
||||||
const { app, data } = await renderData(calls.fetch)
|
|
||||||
|
|
||||||
try {
|
|
||||||
data.session.optimistic.prompt({
|
|
||||||
sessionID,
|
|
||||||
messageID: "msg_failed",
|
|
||||||
delivery: "queue",
|
|
||||||
text: "Will fail",
|
|
||||||
})
|
|
||||||
expect(data.session.message.get(sessionID, "msg_failed")).toBeDefined()
|
|
||||||
|
|
||||||
data.session.optimistic.rollback(sessionID, "msg_failed")
|
|
||||||
expect(data.session.message.get(sessionID, "msg_failed")).toBeUndefined()
|
|
||||||
expect(data.session.pending.list(sessionID)).toHaveLength(0)
|
|
||||||
expect(data.session.input.has(sessionID, "msg_failed")).toBe(false)
|
|
||||||
|
|
||||||
// Rollback of an unknown or already-settled echo is a no-op.
|
|
||||||
data.session.optimistic.rollback(sessionID, "msg_failed")
|
|
||||||
expect(data.session.message.list(sessionID)).toHaveLength(0)
|
|
||||||
} finally {
|
|
||||||
app.renderer.destroy()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("optimistic prompts survive sync replaces until the server confirms them", async () => {
|
|
||||||
const events = createEventStream()
|
|
||||||
const sessionID = "session-optimistic-sync"
|
|
||||||
let serverMessages: SessionMessageInfo[] = []
|
|
||||||
const calls = createFetch((url) => {
|
|
||||||
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: serverMessages, cursor: {} })
|
|
||||||
if (url.pathname === `/api/session/${sessionID}/inbox`) return json({ data: [] })
|
|
||||||
}, events)
|
|
||||||
const { app, data } = await renderData(calls.fetch)
|
|
||||||
|
|
||||||
try {
|
|
||||||
data.session.optimistic.prompt({
|
|
||||||
sessionID,
|
|
||||||
messageID: "msg_pending",
|
|
||||||
delivery: "steer",
|
|
||||||
text: "Survive the sync",
|
|
||||||
})
|
|
||||||
|
|
||||||
// A wholesale replace from an empty server page keeps the unconfirmed echo.
|
|
||||||
await data.session.message.sync(sessionID)
|
|
||||||
expect(data.session.message.get(sessionID, "msg_pending")).toBeDefined()
|
|
||||||
|
|
||||||
await data.session.pending.sync(sessionID)
|
|
||||||
expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual(["msg_pending"])
|
|
||||||
expect(data.session.input.has(sessionID, "msg_pending")).toBe(true)
|
|
||||||
|
|
||||||
// A fetched page containing the ID is server confirmation: the projected copy
|
|
||||||
// wins and later rollback attempts become no-ops.
|
|
||||||
serverMessages = [{ id: "msg_pending", type: "user", text: "Survive the sync", time: { created: 5 } }]
|
|
||||||
data.session.message.invalidate(sessionID)
|
|
||||||
await data.session.message.sync(sessionID)
|
|
||||||
const confirmed = data.session.message.get(sessionID, "msg_pending")
|
|
||||||
expect(confirmed?.type === "user" && confirmed.time.created).toBe(5)
|
|
||||||
expect(data.session.message.list(sessionID)).toHaveLength(1)
|
|
||||||
|
|
||||||
data.session.optimistic.rollback(sessionID, "msg_pending")
|
|
||||||
expect(data.session.message.get(sessionID, "msg_pending")).toBeDefined()
|
|
||||||
} finally {
|
|
||||||
app.renderer.destroy()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("cancellation clears the optimistic echo for good", async () => {
|
|
||||||
const events = createEventStream()
|
|
||||||
const sessionID = "session-optimistic-cancel"
|
|
||||||
const calls = createFetch((url) => {
|
|
||||||
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
|
|
||||||
}, events)
|
|
||||||
const { app, data } = await renderData(calls.fetch)
|
|
||||||
|
|
||||||
try {
|
|
||||||
data.session.optimistic.prompt({
|
|
||||||
sessionID,
|
|
||||||
messageID: "msg_cancelled",
|
|
||||||
delivery: "queue",
|
|
||||||
text: "Cancel me",
|
|
||||||
})
|
|
||||||
emitEvent(events, {
|
|
||||||
id: "evt_cancelled",
|
|
||||||
created: 2,
|
|
||||||
type: "session.inbox.cancelled",
|
|
||||||
durable: durable(sessionID),
|
|
||||||
data: { sessionID, inboxID: "msg_cancelled" },
|
|
||||||
})
|
|
||||||
await wait(() => data.session.message.get(sessionID, "msg_cancelled") === undefined)
|
|
||||||
expect(data.session.pending.list(sessionID)).toHaveLength(0)
|
|
||||||
|
|
||||||
// The ledger entry is gone too: a sync replace must not resurrect the echo.
|
|
||||||
await data.session.message.sync(sessionID)
|
|
||||||
expect(data.session.message.get(sessionID, "msg_cancelled")).toBeUndefined()
|
|
||||||
} finally {
|
|
||||||
app.renderer.destroy()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("revert commit preserves unconfirmed optimistic prompts", async () => {
|
|
||||||
const events = createEventStream()
|
|
||||||
const sessionID = "session-optimistic-revert"
|
|
||||||
const calls = createFetch(undefined, events)
|
|
||||||
const { app, data } = await renderData(calls.fetch)
|
|
||||||
|
|
||||||
try {
|
|
||||||
for (const [seq, id] of [
|
|
||||||
[0, "msg_1"],
|
|
||||||
[1, "msg_2"],
|
|
||||||
] as const) {
|
|
||||||
emitEvent(events, {
|
|
||||||
id: `evt_seed_${id}`,
|
|
||||||
created: seq + 1,
|
|
||||||
type: "session.inbox.enqueued",
|
|
||||||
durable: durable(sessionID, seq),
|
|
||||||
data: { sessionID, inboxID: id, item: { type: "user", payload: { text: id }, delivery: "steer" } },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
await wait(() => data.session.message.list(sessionID).length === 2)
|
|
||||||
|
|
||||||
data.session.optimistic.prompt({
|
|
||||||
sessionID,
|
|
||||||
messageID: "msg_9",
|
|
||||||
delivery: "steer",
|
|
||||||
text: "After the revert boundary",
|
|
||||||
})
|
|
||||||
|
|
||||||
emitEvent(events, {
|
|
||||||
id: "evt_revert",
|
|
||||||
created: 4,
|
|
||||||
type: "session.revert.committed",
|
|
||||||
durable: durable(sessionID, 2),
|
|
||||||
data: { sessionID, to: "msg_2" },
|
|
||||||
})
|
|
||||||
await wait(() => data.session.message.get(sessionID, "msg_2") === undefined)
|
|
||||||
expect(data.session.message.get(sessionID, "msg_1")).toBeDefined()
|
|
||||||
expect(data.session.message.get(sessionID, "msg_9")).toBeDefined()
|
|
||||||
expect(data.session.input.has(sessionID, "msg_9")).toBe(true)
|
|
||||||
} finally {
|
|
||||||
app.renderer.destroy()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -5,6 +5,7 @@ export function catalogProvider(id: string, name: string): ProviderListOutput["d
|
|||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
|
activation: "auto",
|
||||||
package: "",
|
package: "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user