mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-25 10:00:48 +08:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a366c3cab | |||
| c992716523 | |||
| e402600d92 | |||
| 1e4e9c5d85 | |||
| f34b74b1dc | |||
| b257d476c9 | |||
| 5e38101fba | |||
| 4beaffbda9 | |||
| 21bbcc33a1 | |||
| 0b1dbaf621 | |||
| 0da51e9274 |
@@ -9,30 +9,42 @@ const sessionB = session("ses_tab_b", "Tab B session")
|
||||
const sessionC = session("ses_tab_c", "Tab C session")
|
||||
const unresolvedSessionID = "ses_tab_unresolved"
|
||||
|
||||
test("new session tab hugs its content", async ({ page }) => {
|
||||
test("new session tab matches neighboring session widths", async ({ page }, testInfo) => {
|
||||
await mockServer(page)
|
||||
await page.addInitScript(
|
||||
({ server, sessionID, directory }) => {
|
||||
({ server, sessionA, sessionB, directory }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([
|
||||
{ type: "session", server, sessionId: sessionID },
|
||||
{ type: "session", server, sessionId: sessionA },
|
||||
{ type: "draft", server, directory, draftID: "draft_tab_width" },
|
||||
{ type: "session", server, sessionId: sessionB },
|
||||
]),
|
||||
)
|
||||
},
|
||||
{ server, sessionID: sessionA.id, directory: sessionA.directory },
|
||||
{ server, sessionA: sessionA.id, sessionB: sessionB.id, directory: sessionA.directory },
|
||||
)
|
||||
|
||||
const href = `/server/${base64Encode(server)}/session/${sessionA.id}`
|
||||
await page.goto(href)
|
||||
|
||||
const sessionTab = page.locator(`[data-titlebar-tab-slot]:has(a[href="${href}"])`)
|
||||
const draftTab = page.locator('[data-titlebar-tab-slot]:has(a[href^="/new-session?draftId="])')
|
||||
await expect(draftTab).toContainText("New session")
|
||||
const width = (await draftTab.boundingBox())?.width ?? 0
|
||||
expect(width).toBeGreaterThan(100)
|
||||
expect(width).toBeLessThan((await sessionTab.boundingBox())?.width ?? 0)
|
||||
const tabs = page.locator("[data-titlebar-tab-slot]")
|
||||
await expect(tabs.locator("[data-titlebar-tab-title]")).toHaveText([sessionA.title, "New session", sessionB.title])
|
||||
await testInfo.attach("new-session-between-tabs", {
|
||||
body: await page.locator('[data-slot="titlebar-v2"]').screenshot(),
|
||||
contentType: "image/png",
|
||||
})
|
||||
for (const width of [1280, 800]) {
|
||||
await page.setViewportSize({ width, height: 720 })
|
||||
await expect
|
||||
.poll(() =>
|
||||
tabs.evaluateAll((tabs) => {
|
||||
const widths = tabs.map((tab) => tab.getBoundingClientRect().width)
|
||||
return Math.max(...widths) - Math.min(...widths)
|
||||
}),
|
||||
)
|
||||
.toBeLessThan(1)
|
||||
}
|
||||
})
|
||||
|
||||
test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => {
|
||||
@@ -280,9 +292,7 @@ test("appearance experimental settings control vertical tab details", async ({ p
|
||||
await page.reload()
|
||||
const href = `/server/${base64Encode(server)}/session/${sessionA.id}`
|
||||
await page.getByRole("button", { name: "Tabs", exact: true }).click()
|
||||
await expect(page.locator('[data-slot="mobile-tabs-drawer"] [data-slot="tab-project"]')).toHaveText([
|
||||
"tab-project",
|
||||
])
|
||||
await expect(page.locator('[data-slot="mobile-tabs-drawer"] [data-slot="tab-project"]')).toHaveText(["tab-project"])
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-slot="mobile-tabs-drawer"]')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { AgentListOutput, ModelListOutput, ProviderListOutput } from "@opencode-ai/client/promise"
|
||||
import { directoryKey, normalizeAgentList, normalizeProviderList } from "./utils"
|
||||
import type { AgentListOutput, ModelListOutput, Project, ProviderListOutput } from "@opencode-ai/client/promise"
|
||||
import { directoryKey, normalizeAgentList, normalizeProjectInfo, normalizeProviderList } from "./utils"
|
||||
|
||||
describe("normalizeAgentList", () => {
|
||||
test("adapts current agents to the app agent shape", () => {
|
||||
@@ -85,6 +85,15 @@ describe("normalizeProviderList", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("normalizeProjectInfo", () => {
|
||||
test("keeps the project VCS backend", () => {
|
||||
const project = { id: "prj", canonical: "/repo", time: { created: 1, updated: 1 }, sandboxes: [] }
|
||||
expect(normalizeProjectInfo({ ...project, vcs: "git" } as Project).vcs).toBe("git")
|
||||
expect(normalizeProjectInfo({ ...project, vcs: "hg" } as Project).vcs).toBe("hg")
|
||||
expect(normalizeProjectInfo(project as Project).vcs).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("directoryKey", () => {
|
||||
test("normalizes slashes", () => {
|
||||
expect(String(directoryKey("C:\\Repos\\sst\\opencode"))).toBe("C:/Repos/sst/opencode")
|
||||
|
||||
@@ -135,6 +135,5 @@ export function normalizeProjectInfo(project: Project | CurrentProject): Project
|
||||
...project,
|
||||
worktree,
|
||||
worktrees: "worktrees" in project ? project.worktrees : [{ directory: worktree }],
|
||||
vcs: project.vcs === "git" ? "git" : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,9 +55,9 @@ export function createSessionReview(input: {
|
||||
const options = createMemo<ChangeMode[]>(() => {
|
||||
const list: ChangeMode[] = []
|
||||
const project = input.session.project()
|
||||
if (project?.vcs === "git") list.push("git")
|
||||
if (project?.vcs) list.push("git")
|
||||
if (
|
||||
project?.vcs === "git" &&
|
||||
project?.vcs &&
|
||||
vcs()?.branch.current &&
|
||||
vcs()?.branch.default &&
|
||||
vcs()?.branch.current !== vcs()?.branch.default
|
||||
@@ -94,7 +94,7 @@ export function createSessionReview(input: {
|
||||
const value = vcsMode()
|
||||
return {
|
||||
queryKey: [...vcsKey(), value] as const,
|
||||
enabled: server.connection.status() === "connected" && wantsReview() && input.session.project()?.vcs === "git",
|
||||
enabled: server.connection.status() === "connected" && wantsReview() && !!input.session.project()?.vcs,
|
||||
refetchOnMount: "always" as const,
|
||||
refetchOnWindowFocus: true,
|
||||
queryFn: value
|
||||
@@ -110,7 +110,7 @@ export function createSessionReview(input: {
|
||||
})
|
||||
const detailsQuery = createQuery(() => ({
|
||||
queryKey: [server.scope, "session-details", input.session.workspace.directory()] as const,
|
||||
enabled: state.detailsOpen && server.connection.status() === "connected" && input.session.project()?.vcs === "git",
|
||||
enabled: state.detailsOpen && server.connection.status() === "connected" && !!input.session.project()?.vcs,
|
||||
queryFn: () =>
|
||||
server.api.vcs
|
||||
.diff({ location: { directory: input.session.workspace.directory() }, mode: "working" })
|
||||
@@ -413,7 +413,7 @@ export function createSessionReview(input: {
|
||||
tab: () => state.mobileTab,
|
||||
},
|
||||
mode,
|
||||
noGit: createMemo(() => !!input.session.project() && input.session.project()?.vcs !== "git"),
|
||||
noGit: createMemo(() => !!input.session.project() && !input.session.project()?.vcs),
|
||||
open,
|
||||
openFile,
|
||||
options,
|
||||
|
||||
@@ -165,7 +165,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
baseBranch={
|
||||
session.shared.data.location.vcs.info({ directory: project().worktree })?.branch.current
|
||||
}
|
||||
diffs={project().vcs === "git" ? review.details.diffs() : []}
|
||||
diffs={project().vcs ? review.details.diffs() : []}
|
||||
sessionID={session.identity.params.id ?? ""}
|
||||
moveEligible={composer.workspaceMoveEligible()}
|
||||
moveDismissed={store.mobileMoveDismissed}
|
||||
|
||||
@@ -395,11 +395,8 @@ export function DraftTabItem(props: {
|
||||
data-active={props.active}
|
||||
data-dragging={props.dragging}
|
||||
data-state={props.active || props.pressed ? "pressed" : undefined}
|
||||
class="group relative flex h-7 min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] px-1.5 whitespace-nowrap"
|
||||
classList={{
|
||||
invisible: props.hidden,
|
||||
"w-full [container-type:inline-size]": props.orientation === "vertical",
|
||||
}}
|
||||
class="group relative flex h-7 w-full min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] px-1.5 [container-type:inline-size] whitespace-nowrap"
|
||||
classList={{ invisible: props.hidden }}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button !== MIDDLE_MOUSE_BUTTON) return
|
||||
event.preventDefault()
|
||||
@@ -432,16 +429,14 @@ export function DraftTabItem(props: {
|
||||
if (props.suppressNavigation) return
|
||||
props.onNavigate()
|
||||
}}
|
||||
class="flex h-full min-w-0 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
||||
classList={{ "flex-1": props.orientation === "vertical", "flex-none pe-10": props.orientation !== "vertical" }}
|
||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
||||
>
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<Icon name="edit" />
|
||||
</span>
|
||||
<span
|
||||
data-titlebar-tab-title
|
||||
class="min-w-0 overflow-hidden text-clip whitespace-nowrap outline-none leading-4"
|
||||
classList={{ "flex-1": props.orientation === "vertical", "flex-none": props.orientation !== "vertical" }}
|
||||
class="min-w-0 flex-1 overflow-hidden text-clip whitespace-nowrap outline-none leading-4"
|
||||
>
|
||||
{props.title}
|
||||
</span>
|
||||
|
||||
@@ -211,7 +211,7 @@ function DraftTabSlot(props: {
|
||||
data-orientation={props.orientation}
|
||||
class="relative flex"
|
||||
classList={{
|
||||
"w-max min-w-7 max-w-56 shrink-0": props.orientation === "horizontal",
|
||||
"w-56 min-w-7 max-w-56 flex-shrink": props.orientation === "horizontal",
|
||||
"w-full shrink-0": props.orientation === "vertical",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -128,7 +128,9 @@ export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sh
|
||||
external: ["node-gyp"],
|
||||
format: "esm",
|
||||
minify: true,
|
||||
bytecode: true,
|
||||
// Bun 1.4.0 cross-compiled bytecode can crash on Windows (oven-sh/bun#40270).
|
||||
// Re-enable after both the builder and embedded runtime move to Bun 1.4.1.
|
||||
bytecode: false,
|
||||
sourcemap: Script.channel === "dev" || Script.channel === "local" ? "inline" : "none",
|
||||
splitting: true,
|
||||
compile: {
|
||||
|
||||
+163
-225
@@ -107,7 +107,6 @@ type Store = {
|
||||
messageCursor: Record<string, string | undefined>
|
||||
messageLoading: Record<string, boolean>
|
||||
pending: Record<string, SessionInboxInfo[]>
|
||||
input: Record<string, string[]>
|
||||
permission: Record<string, PermissionRequest[]>
|
||||
// Pending forms keyed by owner: a session ID or the temporary "global" elicitation sentinel.
|
||||
form: Record<string, FormWithLocation[]>
|
||||
@@ -198,7 +197,6 @@ export function createData(config: CreateDataInput) {
|
||||
messageCursor: {},
|
||||
messageLoading: {},
|
||||
pending: {},
|
||||
input: {},
|
||||
permission: {},
|
||||
form: {},
|
||||
},
|
||||
@@ -231,13 +229,6 @@ export function createData(config: CreateDataInput) {
|
||||
sessionID,
|
||||
(store.session.pending[sessionID] ?? []).filter((item) => item.id !== inboxID),
|
||||
)
|
||||
if (store.session.input[sessionID]?.includes(inboxID))
|
||||
setStore(
|
||||
"session",
|
||||
"input",
|
||||
sessionID,
|
||||
(store.session.input[sessionID] ?? []).filter((id) => id !== inboxID),
|
||||
)
|
||||
}
|
||||
|
||||
function removePermission(sessionID: string, requestID: string) {
|
||||
@@ -335,8 +326,8 @@ export function createData(config: CreateDataInput) {
|
||||
return request
|
||||
}
|
||||
|
||||
// Upsert an admitted inbox item into pending, input, and (for user and
|
||||
// synthetic items) the visible transcript. Used by the inbox.enqueued
|
||||
// Upsert an admitted inbox item into pending and (for user and synthetic
|
||||
// items) the visible transcript. Used by the inbox.enqueued
|
||||
// handler and by optimistic admission; the upsert is what reconciles
|
||||
// the durable echo with an optimistic placeholder — the durable payload and
|
||||
// times replace the client's guess.
|
||||
@@ -351,8 +342,6 @@ export function createData(config: CreateDataInput) {
|
||||
at < 0 ? [...pending, item] : pending.map((entry, index) => (index === at ? item : entry)),
|
||||
)
|
||||
if (item.type === "compaction") return
|
||||
const input = store.session.input[item.sessionID] ?? []
|
||||
if (!input.includes(item.id)) setStore("session", "input", item.sessionID, [...input, item.id])
|
||||
materializeInboxMessage(item)
|
||||
})
|
||||
}
|
||||
@@ -401,15 +390,44 @@ export function createData(config: CreateDataInput) {
|
||||
index.set(item.id, messages.length)
|
||||
messages.push(item)
|
||||
},
|
||||
insert(sessionID: string, item: SessionMessageInfo) {
|
||||
message.update(sessionID, (draft, index) => message.append(draft, index, item))
|
||||
},
|
||||
// Streaming events target one assistant message and, within it, the latest part of a kind.
|
||||
// A missing target means the row was never loaded or was evicted; the event is dropped.
|
||||
editAssistant(sessionID: string, messageID: string, fn: (assistant: SessionMessageAssistant) => void) {
|
||||
message.update(sessionID, (draft, index) => {
|
||||
const position = index.get(messageID)
|
||||
const item = position === undefined ? undefined : draft[position]
|
||||
if (item?.type === "assistant") fn(item)
|
||||
})
|
||||
},
|
||||
editTool(sessionID: string, messageID: string, toolID: string, fn: (tool: SessionMessageAssistantTool) => void) {
|
||||
message.editAssistant(sessionID, messageID, (assistant) => {
|
||||
const tool = assistant.content.findLast(
|
||||
(item): item is SessionMessageAssistantTool => item.type === "tool" && item.id === toolID,
|
||||
)
|
||||
if (tool) fn(tool)
|
||||
})
|
||||
},
|
||||
editText(sessionID: string, messageID: string, fn: (text: SessionMessageAssistantText) => void) {
|
||||
message.editAssistant(sessionID, messageID, (assistant) => {
|
||||
const text = assistant.content.findLast((item): item is SessionMessageAssistantText => item.type === "text")
|
||||
if (text) fn(text)
|
||||
})
|
||||
},
|
||||
editReasoning(sessionID: string, messageID: string, fn: (reasoning: SessionMessageAssistantReasoning) => void) {
|
||||
message.editAssistant(sessionID, messageID, (assistant) => {
|
||||
const reasoning = assistant.content.findLast(
|
||||
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && !item.time?.completed,
|
||||
)
|
||||
if (reasoning) fn(reasoning)
|
||||
})
|
||||
},
|
||||
activeAssistant(messages: SessionMessageInfo[]) {
|
||||
const item = messages.findLast((item) => item.type === "assistant" && !item.time.completed)
|
||||
return item?.type === "assistant" ? item : undefined
|
||||
},
|
||||
assistant(messages: SessionMessageInfo[], index: Map<string, number>, messageID: string) {
|
||||
const position = index.get(messageID)
|
||||
const item = position === undefined ? undefined : messages[position]
|
||||
return item?.type === "assistant" ? item : undefined
|
||||
},
|
||||
shell(messages: SessionMessageInfo[], shellID: string) {
|
||||
const item = messages.findLast((item) => item.type === "shell" && item.shellID === shellID)
|
||||
return item?.type === "shell" ? item : undefined
|
||||
@@ -418,19 +436,6 @@ export function createData(config: CreateDataInput) {
|
||||
const item = messages.findLast((item) => item.type === "compaction" && item.status === "running")
|
||||
return item?.type === "compaction" ? item : undefined
|
||||
},
|
||||
latestTool(assistant: SessionMessageAssistant | undefined, id?: string) {
|
||||
return assistant?.content.findLast(
|
||||
(item): item is SessionMessageAssistantTool => item.type === "tool" && (id === undefined || item.id === id),
|
||||
)
|
||||
},
|
||||
latestText(assistant: SessionMessageAssistant | undefined) {
|
||||
return assistant?.content.findLast((item): item is SessionMessageAssistantText => item.type === "text")
|
||||
},
|
||||
latestReasoning(assistant: SessionMessageAssistant | undefined) {
|
||||
return assistant?.content.findLast(
|
||||
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && !item.time?.completed,
|
||||
)
|
||||
},
|
||||
reindex(messages: SessionMessageInfo[], index: Map<string, number>, start: number) {
|
||||
for (let position = start; position < messages.length; position++) {
|
||||
const item = messages[position]
|
||||
@@ -508,12 +513,8 @@ export function createData(config: CreateDataInput) {
|
||||
delete draft.messageCursor[sessionID]
|
||||
delete draft.messageLoading[sessionID]
|
||||
delete draft.pending[sessionID]
|
||||
delete draft.input[sessionID]
|
||||
if (messages.length) draft.message[sessionID] = messages
|
||||
if (pending.length) {
|
||||
draft.pending[sessionID] = pending
|
||||
draft.input[sessionID] = pending.filter((item) => item.type !== "compaction").map((item) => item.id)
|
||||
}
|
||||
if (pending.length) draft.pending[sessionID] = pending
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -537,7 +538,6 @@ export function createData(config: CreateDataInput) {
|
||||
delete draft.messageCursor[sessionID]
|
||||
delete draft.messageLoading[sessionID]
|
||||
delete draft.pending[sessionID]
|
||||
delete draft.input[sessionID]
|
||||
delete draft.permission[sessionID]
|
||||
delete draft.form[sessionID]
|
||||
for (const [rootID, family] of Object.entries(draft.family)) {
|
||||
@@ -609,14 +609,12 @@ export function createData(config: CreateDataInput) {
|
||||
const previous = store.session.info[event.data.sessionID]?.agent
|
||||
if (store.session.info[event.data.sessionID])
|
||||
setStore("session", "info", event.data.sessionID, "agent", event.data.agent)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "agent-switched",
|
||||
agent: event.data.agent,
|
||||
previous,
|
||||
time: { created: event.created },
|
||||
})
|
||||
message.insert(event.data.sessionID, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "agent-switched",
|
||||
agent: event.data.agent,
|
||||
previous,
|
||||
time: { created: event.created },
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -624,13 +622,11 @@ export function createData(config: CreateDataInput) {
|
||||
if (store.session.info[event.data.sessionID])
|
||||
setStore("session", "info", event.data.sessionID, "model", event.data.model)
|
||||
if (!store.session.message[event.data.sessionID]) return
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "model-switched",
|
||||
model: event.data.model,
|
||||
time: { created: event.created },
|
||||
})
|
||||
message.insert(event.data.sessionID, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "model-switched",
|
||||
model: event.data.model,
|
||||
time: { created: event.created },
|
||||
})
|
||||
void api()
|
||||
.session.message({ sessionID: event.data.sessionID, messageID: messageIDFromEvent(event.id) })
|
||||
@@ -665,16 +661,14 @@ export function createData(config: CreateDataInput) {
|
||||
setStore("session", "info", event.data.sessionID, "location", event.data.location)
|
||||
if (event.data.projectID) setStore("session", "info", event.data.sessionID, "projectID", event.data.projectID)
|
||||
setStore("session", "info", event.data.sessionID, "subpath", event.data.subpath)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "location-switched",
|
||||
location: event.data.location,
|
||||
projectID: event.data.projectID,
|
||||
subpath: event.data.subpath,
|
||||
previous,
|
||||
time: { created: event.created },
|
||||
})
|
||||
message.insert(event.data.sessionID, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "location-switched",
|
||||
location: event.data.location,
|
||||
projectID: event.data.projectID,
|
||||
subpath: event.data.subpath,
|
||||
previous,
|
||||
time: { created: event.created },
|
||||
})
|
||||
}
|
||||
return
|
||||
@@ -704,7 +698,7 @@ export function createData(config: CreateDataInput) {
|
||||
return
|
||||
}
|
||||
case "session.inbox.delivered": {
|
||||
const admitted = store.session.input[event.data.sessionID]?.includes(event.data.inboxID) ?? false
|
||||
const admitted = result.session.input.has(event.data.sessionID, event.data.inboxID)
|
||||
removePending(event.data.sessionID, event.data.inboxID)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const position = index.get(event.data.inboxID)
|
||||
@@ -748,42 +742,36 @@ export function createData(config: CreateDataInput) {
|
||||
// and produce no transcript message.
|
||||
const updateText = event.data.text
|
||||
if (updateText === undefined) return
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "system",
|
||||
text: updateText,
|
||||
description: `Instructions updated: ${Object.keys(event.data.delta).join(", ")}`,
|
||||
metadata: event.metadata,
|
||||
time: { created: event.created },
|
||||
})
|
||||
message.insert(event.data.sessionID, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "system",
|
||||
text: updateText,
|
||||
description: `Instructions updated: ${Object.keys(event.data.delta).join(", ")}`,
|
||||
metadata: event.metadata,
|
||||
time: { created: event.created },
|
||||
})
|
||||
return
|
||||
case "session.synthetic":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "synthetic",
|
||||
text: event.data.text,
|
||||
description: event.data.description,
|
||||
metadata: event.data.metadata,
|
||||
time: { created: event.created },
|
||||
})
|
||||
message.insert(event.data.sessionID, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "synthetic",
|
||||
text: event.data.text,
|
||||
description: event.data.description,
|
||||
metadata: event.data.metadata,
|
||||
time: { created: event.created },
|
||||
})
|
||||
return
|
||||
case "session.shell.started":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "shell",
|
||||
shellID: event.data.shell.id,
|
||||
command: event.data.shell.command,
|
||||
status: event.data.shell.status,
|
||||
exit: event.data.shell.exit,
|
||||
metadata:
|
||||
event.data.shell.metadata.background === true ? { ...event.metadata, background: true } : event.metadata,
|
||||
time: { created: event.created },
|
||||
})
|
||||
message.insert(event.data.sessionID, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "shell",
|
||||
shellID: event.data.shell.id,
|
||||
command: event.data.shell.command,
|
||||
status: event.data.shell.status,
|
||||
exit: event.data.shell.exit,
|
||||
metadata:
|
||||
event.data.shell.metadata.background === true ? { ...event.metadata, background: true } : event.metadata,
|
||||
time: { created: event.created },
|
||||
})
|
||||
return
|
||||
case "session.shell.ended":
|
||||
@@ -798,9 +786,8 @@ export function createData(config: CreateDataInput) {
|
||||
return
|
||||
case "session.message.content.updated": {
|
||||
if (store.session.message[event.data.sessionID])
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const assistant = message.assistant(draft, index, event.data.messageID)
|
||||
if (assistant) assistant.content = [...event.data.content]
|
||||
message.editAssistant(event.data.sessionID, event.data.messageID, (assistant) => {
|
||||
assistant.content = [...event.data.content]
|
||||
})
|
||||
if (!sync.pending(`session.message:${event.data.sessionID}`)) return
|
||||
result.session.message.invalidate(event.data.sessionID)
|
||||
@@ -842,65 +829,54 @@ export function createData(config: CreateDataInput) {
|
||||
})
|
||||
return
|
||||
case "session.step.streamed":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
|
||||
if (currentAssistant) currentAssistant.time.streamed = event.created
|
||||
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
|
||||
assistant.time.streamed = event.created
|
||||
})
|
||||
return
|
||||
case "session.step.ended": {
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
|
||||
if (!currentAssistant) return
|
||||
currentAssistant.time.completed = event.created
|
||||
currentAssistant.finish = event.data.finish
|
||||
currentAssistant.rawFinish = event.data.rawFinish
|
||||
currentAssistant.providerState = event.data.providerState
|
||||
currentAssistant.cost = event.data.cost
|
||||
currentAssistant.tokens = event.data.tokens
|
||||
if (event.data.snapshot)
|
||||
currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.data.snapshot }
|
||||
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
|
||||
assistant.time.completed = event.created
|
||||
assistant.finish = event.data.finish
|
||||
assistant.rawFinish = event.data.rawFinish
|
||||
assistant.providerState = event.data.providerState
|
||||
assistant.cost = event.data.cost
|
||||
assistant.tokens = event.data.tokens
|
||||
if (event.data.snapshot) assistant.snapshot = { ...assistant.snapshot, end: event.data.snapshot }
|
||||
})
|
||||
return
|
||||
}
|
||||
case "session.step.failed":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
|
||||
if (!currentAssistant) return
|
||||
currentAssistant.time.completed = event.created
|
||||
currentAssistant.finish = event.data.finish ?? "error"
|
||||
currentAssistant.rawFinish = event.data.rawFinish
|
||||
currentAssistant.providerState = event.data.providerState
|
||||
currentAssistant.error = event.data.error
|
||||
currentAssistant.retry = undefined
|
||||
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
|
||||
assistant.time.completed = event.created
|
||||
assistant.finish = event.data.finish ?? "error"
|
||||
assistant.rawFinish = event.data.rawFinish
|
||||
assistant.providerState = event.data.providerState
|
||||
assistant.error = event.data.error
|
||||
assistant.retry = undefined
|
||||
if (event.data.cost !== undefined && event.data.tokens !== undefined) {
|
||||
currentAssistant.cost = event.data.cost
|
||||
currentAssistant.tokens = event.data.tokens
|
||||
assistant.cost = event.data.cost
|
||||
assistant.tokens = event.data.tokens
|
||||
}
|
||||
})
|
||||
return
|
||||
case "session.text.started":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
|
||||
type: "text",
|
||||
text: "",
|
||||
})
|
||||
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
|
||||
assistant.content.push({ type: "text", text: "" })
|
||||
})
|
||||
return
|
||||
case "session.text.delta":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID))
|
||||
if (match) match.text += event.data.delta
|
||||
message.editText(event.data.sessionID, event.data.assistantMessageID, (text) => {
|
||||
text.text += event.data.delta
|
||||
})
|
||||
return
|
||||
case "session.text.ended":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID))
|
||||
if (match) match.text = event.data.text
|
||||
message.editText(event.data.sessionID, event.data.assistantMessageID, (text) => {
|
||||
text.text = event.data.text
|
||||
})
|
||||
return
|
||||
case "session.tool.input.started":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
|
||||
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
|
||||
assistant.content.push({
|
||||
type: "tool",
|
||||
id: event.data.id,
|
||||
name: event.data.name,
|
||||
@@ -910,86 +886,60 @@ export function createData(config: CreateDataInput) {
|
||||
})
|
||||
return
|
||||
case "session.tool.input.delta":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestTool(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.id,
|
||||
)
|
||||
if (match?.state.status === "streaming") match.state.input += event.data.delta
|
||||
message.editTool(event.data.sessionID, event.data.assistantMessageID, event.data.id, (tool) => {
|
||||
if (tool.state.status === "streaming") tool.state.input += event.data.delta
|
||||
})
|
||||
return
|
||||
case "session.tool.input.ended":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestTool(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.id,
|
||||
)
|
||||
if (match?.state.status === "streaming") match.state.input = event.data.text
|
||||
message.editTool(event.data.sessionID, event.data.assistantMessageID, event.data.id, (tool) => {
|
||||
if (tool.state.status === "streaming") tool.state.input = event.data.text
|
||||
})
|
||||
return
|
||||
case "session.tool.called":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestTool(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.id,
|
||||
)
|
||||
if (!match) return
|
||||
match.time.ran = event.created
|
||||
match.executed = event.data.executed
|
||||
match.providerState = event.data.state
|
||||
match.state = { status: "running", input: event.data.input, metadata: {} }
|
||||
message.editTool(event.data.sessionID, event.data.assistantMessageID, event.data.id, (tool) => {
|
||||
tool.time.ran = event.created
|
||||
tool.executed = event.data.executed
|
||||
tool.providerState = event.data.state
|
||||
tool.state = { status: "running", input: event.data.input, metadata: {} }
|
||||
})
|
||||
return
|
||||
case "session.tool.progress":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestTool(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.id,
|
||||
)
|
||||
if (match?.state.status !== "running") return
|
||||
match.state.metadata = event.data.metadata
|
||||
message.editTool(event.data.sessionID, event.data.assistantMessageID, event.data.id, (tool) => {
|
||||
if (tool.state.status === "running") tool.state.metadata = event.data.metadata
|
||||
})
|
||||
return
|
||||
case "session.tool.success":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestTool(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.id,
|
||||
)
|
||||
if (match?.state.status !== "running") return
|
||||
match.state = {
|
||||
message.editTool(event.data.sessionID, event.data.assistantMessageID, event.data.id, (tool) => {
|
||||
if (tool.state.status !== "running") return
|
||||
tool.state = {
|
||||
status: "completed",
|
||||
input: match.state.input,
|
||||
input: tool.state.input,
|
||||
metadata: event.data.metadata,
|
||||
content: [...event.data.content],
|
||||
}
|
||||
match.executed = event.data.executed || match.executed === true
|
||||
match.providerResultState = event.data.resultState
|
||||
match.time.completed = event.created
|
||||
tool.executed = event.data.executed || tool.executed === true
|
||||
tool.providerResultState = event.data.resultState
|
||||
tool.time.completed = event.created
|
||||
})
|
||||
return
|
||||
case "session.tool.failed":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestTool(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.id,
|
||||
)
|
||||
if (!match || (match.state.status !== "streaming" && match.state.status !== "running")) return
|
||||
match.state = {
|
||||
message.editTool(event.data.sessionID, event.data.assistantMessageID, event.data.id, (tool) => {
|
||||
if (tool.state.status !== "streaming" && tool.state.status !== "running") return
|
||||
tool.state = {
|
||||
status: "error",
|
||||
error: event.data.error,
|
||||
input: typeof match.state.input === "string" ? {} : match.state.input,
|
||||
input: typeof tool.state.input === "string" ? {} : tool.state.input,
|
||||
metadata: event.data.metadata,
|
||||
content: event.data.content,
|
||||
}
|
||||
match.executed = event.data.executed || match.executed === true
|
||||
match.providerResultState = event.data.resultState
|
||||
match.time.completed = event.created
|
||||
tool.executed = event.data.executed || tool.executed === true
|
||||
tool.providerResultState = event.data.resultState
|
||||
tool.time.completed = event.created
|
||||
})
|
||||
return
|
||||
case "session.reasoning.started":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
|
||||
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
|
||||
assistant.content.push({
|
||||
type: "reasoning",
|
||||
text: "",
|
||||
state: event.data.state,
|
||||
@@ -998,30 +948,20 @@ export function createData(config: CreateDataInput) {
|
||||
})
|
||||
return
|
||||
case "session.reasoning.delta":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestReasoning(message.assistant(draft, index, event.data.assistantMessageID))
|
||||
if (match) match.text += event.data.delta
|
||||
message.editReasoning(event.data.sessionID, event.data.assistantMessageID, (reasoning) => {
|
||||
reasoning.text += event.data.delta
|
||||
})
|
||||
return
|
||||
case "session.reasoning.ended":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestReasoning(message.assistant(draft, index, event.data.assistantMessageID))
|
||||
if (match) {
|
||||
match.text = event.data.text
|
||||
match.time = { created: match.time?.created ?? event.created, completed: event.created }
|
||||
if (event.data.state !== undefined) match.state = event.data.state
|
||||
}
|
||||
message.editReasoning(event.data.sessionID, event.data.assistantMessageID, (reasoning) => {
|
||||
reasoning.text = event.data.text
|
||||
reasoning.time = { created: reasoning.time?.created ?? event.created, completed: event.created }
|
||||
if (event.data.state !== undefined) reasoning.state = event.data.state
|
||||
})
|
||||
return
|
||||
case "session.retry.scheduled":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
|
||||
if (!currentAssistant) return
|
||||
currentAssistant.retry = {
|
||||
attempt: event.data.attempt,
|
||||
at: event.data.at,
|
||||
error: event.data.error,
|
||||
}
|
||||
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
|
||||
assistant.retry = { attempt: event.data.attempt, at: event.data.at, error: event.data.error }
|
||||
})
|
||||
return
|
||||
case "session.execution.started":
|
||||
@@ -1029,16 +969,14 @@ export function createData(config: CreateDataInput) {
|
||||
return
|
||||
case "session.compaction.started":
|
||||
if (event.data.inputID) removePending(event.data.sessionID, event.data.inputID)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: event.data.inputID ?? messageIDFromEvent(event.id),
|
||||
type: "compaction",
|
||||
status: "running",
|
||||
reason: event.data.reason,
|
||||
summary: "",
|
||||
recent: event.data.recent ?? "",
|
||||
time: { created: event.created },
|
||||
})
|
||||
message.insert(event.data.sessionID, {
|
||||
id: event.data.inputID ?? messageIDFromEvent(event.id),
|
||||
type: "compaction",
|
||||
status: "running",
|
||||
reason: event.data.reason,
|
||||
summary: "",
|
||||
recent: event.data.recent ?? "",
|
||||
time: { created: event.created },
|
||||
})
|
||||
if (event.data.inputID) compacting.get(event.data.sessionID)?.observed.add(event.data.inputID)
|
||||
return
|
||||
@@ -1073,11 +1011,12 @@ export function createData(config: CreateDataInput) {
|
||||
if (store.session.info[event.data.sessionID]) {
|
||||
setStore("session", "info", event.data.sessionID, "revert", undefined)
|
||||
}
|
||||
// The projector also deletes inbox items enqueued at or after the boundary without a cancel event.
|
||||
setStore(
|
||||
"session",
|
||||
"input",
|
||||
"pending",
|
||||
event.data.sessionID,
|
||||
(store.session.input[event.data.sessionID] ?? []).filter((id) => id < event.data.to),
|
||||
(store.session.pending[event.data.sessionID] ?? []).filter((item) => item.id < event.data.to),
|
||||
)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const position = draft.findIndex((item) => item.id >= event.data.to)
|
||||
@@ -1354,12 +1293,17 @@ export function createData(config: CreateDataInput) {
|
||||
status(sessionID: string) {
|
||||
return store.session.active[sessionID] ?? "idle"
|
||||
},
|
||||
// Inputs are the pending user and synthetic items; compactions are control items.
|
||||
input: {
|
||||
list(sessionID: string) {
|
||||
return store.session.input[sessionID] ?? []
|
||||
return (store.session.pending[sessionID] ?? []).flatMap((item) =>
|
||||
item.type === "compaction" ? [] : [item.id],
|
||||
)
|
||||
},
|
||||
has(sessionID: string, inboxID: string) {
|
||||
return store.session.input[sessionID]?.includes(inboxID) ?? false
|
||||
return (
|
||||
store.session.pending[sessionID]?.some((item) => item.id === inboxID && item.type !== "compaction") ?? false
|
||||
)
|
||||
},
|
||||
},
|
||||
pending: {
|
||||
@@ -1383,12 +1327,6 @@ export function createData(config: CreateDataInput) {
|
||||
const merged = inflight.length === 0 ? pending : [...pending, ...inflight]
|
||||
batch(() => {
|
||||
setStore("session", "pending", sessionID, reconcile(merged))
|
||||
setStore(
|
||||
"session",
|
||||
"input",
|
||||
sessionID,
|
||||
reconcile(merged.filter((item) => item.type !== "compaction").map((item) => item.id)),
|
||||
)
|
||||
merged.forEach(materializeInboxMessage)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"id": "be60f352-8da1-40e1-8d70-dc41121cfbc5",
|
||||
"prevIds": ["3fb67508-0196-4bae-b2bd-c08ece7583fd"],
|
||||
"id": "7f833e4e-de8a-4792-a726-e70daa8b5a4e",
|
||||
"prevIds": ["be60f352-8da1-40e1-8d70-dc41121cfbc5"],
|
||||
"ddl": [
|
||||
{
|
||||
"name": "account_state",
|
||||
@@ -1406,7 +1406,7 @@
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "time_suspended",
|
||||
"name": "execution_claimed_at",
|
||||
"entityType": "columns",
|
||||
"table": "session_v2"
|
||||
},
|
||||
@@ -2041,17 +2041,17 @@
|
||||
{
|
||||
"columns": [
|
||||
{
|
||||
"value": "time_suspended",
|
||||
"value": "execution_claimed_at",
|
||||
"isExpression": false
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"where": "\"session_v2\".\"time_suspended\" is not null",
|
||||
"where": "\"session_v2\".\"execution_claimed_at\" is not null",
|
||||
"origin": "manual",
|
||||
"name": "session_v2_time_suspended_idx",
|
||||
"name": "session_v2_execution_claimed_at_idx",
|
||||
"entityType": "indexes",
|
||||
"table": "session_v2"
|
||||
}
|
||||
],
|
||||
"renames": []
|
||||
"renames": ["session_v2.time_suspended->session_v2.execution_claimed_at"]
|
||||
}
|
||||
|
||||
+2
@@ -45,6 +45,7 @@ import m42 from "./migration/20260812181746_session_inbox.js"
|
||||
import m43 from "./migration/20260812213948_worktree.js"
|
||||
import m44 from "./migration/20260819222447_session_viewed_state.js"
|
||||
import m45 from "./migration/20260823191254_nullable_workspace_binding.js"
|
||||
import m46 from "./migration/20260903010538_execution_claimed_at.js"
|
||||
|
||||
export const migrations = [
|
||||
m00,
|
||||
@@ -93,4 +94,5 @@ export const migrations = [
|
||||
m43,
|
||||
m44,
|
||||
m45,
|
||||
m46,
|
||||
] satisfies DatabaseMigration.Migration[]
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260903010538_execution_claimed_at",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`ALTER TABLE \`session_v2\` RENAME COLUMN \`time_suspended\` TO \`execution_claimed_at\`;`)
|
||||
yield* tx.run(`DROP INDEX IF EXISTS \`session_v2_time_suspended_idx\`;`)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX \`session_v2_execution_claimed_at_idx\` ON \`session_v2\` (\`execution_claimed_at\`) WHERE "session_v2"."execution_claimed_at" is not null;`,
|
||||
)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export default migration
|
||||
@@ -214,7 +214,7 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
|
||||
\`idle_outcome\` text,
|
||||
\`time_compacting\` integer,
|
||||
\`time_archived\` integer,
|
||||
\`time_suspended\` integer,
|
||||
\`execution_claimed_at\` integer,
|
||||
\`resume_attempts\` integer DEFAULT 0 NOT NULL,
|
||||
CONSTRAINT \`fk_session_v2_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
@@ -272,7 +272,7 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
|
||||
yield* tx.run(`CREATE INDEX \`session_v2_workspace_idx\` ON \`session_v2\` (\`workspace_id\`);`)
|
||||
yield* tx.run(`CREATE INDEX \`session_v2_parent_idx\` ON \`session_v2\` (\`parent_id\`);`)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX \`session_v2_time_suspended_idx\` ON \`session_v2\` (\`time_suspended\`) WHERE "session_v2"."time_suspended" is not null;`,
|
||||
`CREATE INDEX \`session_v2_execution_claimed_at_idx\` ON \`session_v2\` (\`execution_claimed_at\`) WHERE "session_v2"."execution_claimed_at" is not null;`,
|
||||
)
|
||||
})
|
||||
},
|
||||
|
||||
@@ -772,7 +772,7 @@ function importNextDatabase(
|
||||
path, title, version, share_url, summary_additions, summary_deletions, summary_files,
|
||||
summary_diffs, metadata, cost, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read,
|
||||
tokens_cache_write, revert, permission, agent, model, time_created, time_updated, time_compacting,
|
||||
time_archived, time_suspended
|
||||
time_archived, execution_claimed_at
|
||||
) VALUES (
|
||||
${session.id}, ${projectID}, ${session.workspace_id}, ${session.parent_id},
|
||||
${session.fork_session_id}, ${session.fork_boundary}, ${session.slug}, ${session.directory},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as PluginSupervisor from "./supervisor.js"
|
||||
|
||||
import { Event } from "@opencode-ai/schema/config"
|
||||
import { Cause, Effect, Layer, PubSub, Stream } from "effect"
|
||||
import { Cause, Effect, Layer, Queue, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { ConfigPluginSource } from "../config/plugin/source.js"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
@@ -131,7 +131,6 @@ export const layer = Layer.effectDiscard(
|
||||
const updating = new Set<string>()
|
||||
let generation = 0
|
||||
let observed = 0
|
||||
const refresh = yield* PubSub.unbounded<void>()
|
||||
|
||||
const activate = Effect.fn("PluginSupervisor.activate")(function* () {
|
||||
const current = ++generation
|
||||
@@ -189,11 +188,24 @@ export const layer = Layer.effectDiscard(
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
})
|
||||
const reloads = Stream.merge(
|
||||
Stream.merge(
|
||||
Stream.merge(sources.changes(), Stream.fromPubSub(refresh)),
|
||||
bus.subscribe([Event.Updated, SdkPlugins.Updated]),
|
||||
),
|
||||
// Start source consumers before activation, without an extra merge/debounce boundary delaying them.
|
||||
// Each source owns its upstream subscriptions; the queue retains the latest observed request.
|
||||
const triggers = yield* Queue.sliding<number>(1)
|
||||
// Make accepted work visible to awaitActivation before coalescing the burst.
|
||||
const notify = Effect.gen(function* () {
|
||||
observed++
|
||||
if (!release) release = yield* registry.hold()
|
||||
yield* Queue.offer(triggers, observed)
|
||||
})
|
||||
const watch = <A>(stream: Stream.Stream<A>) =>
|
||||
stream.pipe(
|
||||
Stream.runForEach(() => notify),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* watch(sources.changes())
|
||||
yield* watch(Stream.fromEffectRepeat(Effect.sleep("24 hours")))
|
||||
yield* watch(bus.subscribe([Event.Updated, SdkPlugins.Updated]))
|
||||
yield* watch(
|
||||
updates.changes().pipe(
|
||||
Stream.filter((update) => packages.has(update.target)),
|
||||
Stream.tap((update) =>
|
||||
@@ -202,22 +214,10 @@ export const layer = Layer.effectDiscard(
|
||||
update.updating ? updating.add(update.target) : updating.delete(update.target)
|
||||
}),
|
||||
),
|
||||
Stream.map(() => undefined),
|
||||
),
|
||||
).pipe(
|
||||
// Make accepted work visible to awaitActivation before coalescing the burst.
|
||||
Stream.mapEffect(() =>
|
||||
Effect.gen(function* () {
|
||||
observed++
|
||||
if (!release) release = yield* registry.hold()
|
||||
return observed
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* Stream.concat(Stream.succeed(0), reloads).pipe(
|
||||
// Keep observing updates while activation runs, retaining only the latest generation request.
|
||||
Stream.buffer({ capacity: 1, strategy: "sliding" }),
|
||||
Stream.debounce("100 millis"),
|
||||
// Run initial activation immediately; debounce only later requests. One consumer serializes both.
|
||||
yield* Stream.concat(Stream.succeed(0), Stream.fromQueue(triggers).pipe(Stream.debounce("100 millis"))).pipe(
|
||||
Stream.runForEach((target) =>
|
||||
Effect.gen(function* () {
|
||||
yield* activate().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause })))
|
||||
@@ -229,12 +229,6 @@ export const layer = Layer.effectDiscard(
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
// The periodic refresh joins the reload feed above so it never activates concurrently with a change.
|
||||
yield* Effect.sleep("24 hours").pipe(
|
||||
Effect.andThen(PubSub.publish(refresh, undefined)),
|
||||
Effect.forever,
|
||||
Effect.forkScoped,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -53,7 +53,9 @@ export interface Prepared {
|
||||
* One request-scoped execution operation. Unknown and hook-removed calls
|
||||
* fail individually through the same seam.
|
||||
*/
|
||||
readonly executeTool: (input: Parameters<Tool.Snapshot["execute"]>[0]) => Effect.Effect<Tool.Result, ExecuteError>
|
||||
readonly executeTool: (
|
||||
input: Parameters<Tool.Snapshot["execute"]>[0],
|
||||
) => Effect.Effect<Tool.NormalizedResult, ExecuteError>
|
||||
}
|
||||
|
||||
interface PrepareInput {
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Model } from "@opencode-ai/schema/model"
|
||||
import type { RelativePath } from "@opencode-ai/schema/schema"
|
||||
import type { Snapshot } from "@opencode-ai/schema/snapshot"
|
||||
import { Effect, Fiber, Iterable } from "effect"
|
||||
import { isArrayNonEmpty, isReadonlyArrayNonEmpty } from "effect/Array"
|
||||
import { isReadonlyArrayNonEmpty } from "effect/Array"
|
||||
import { Bus } from "../../bus.js"
|
||||
import { SessionEvent } from "../event.js"
|
||||
import { SessionMessage } from "../message.js"
|
||||
@@ -12,7 +12,7 @@ import { SessionSchema } from "../schema.js"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { SessionUsage } from "../usage.js"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import type { Tool } from "../../tool.js"
|
||||
|
||||
type Input = {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
@@ -557,20 +557,15 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
})
|
||||
|
||||
/** Publishes one canonical terminal event for a locally executed tool call. */
|
||||
const toolExecution = Effect.fnUntraced(function* (id: string, name: string, result: Tool.Result) {
|
||||
const toolExecution = Effect.fnUntraced(function* (id: string, name: string, result: Tool.NormalizedResult) {
|
||||
const tool = tools.get(id)
|
||||
if (!tool?.called) return yield* Effect.die(new Error(`Tool execution before call: ${id}`))
|
||||
if (tool.name !== name)
|
||||
return yield* Effect.die(new Error(`Tool execution name changed for ${id}: ${tool.name} -> ${name}`))
|
||||
if (tool.settled) return yield* Effect.die(new Error(`Duplicate tool execution: ${id}`))
|
||||
tool.settled = true
|
||||
const content =
|
||||
typeof result.content === "string"
|
||||
? [{ type: "text" as const, text: result.content }]
|
||||
: result.content === undefined
|
||||
? []
|
||||
: [...result.content]
|
||||
if (!isArrayNonEmpty(content)) return yield* Effect.die(new Error(`Tool execution has no content: ${id}`))
|
||||
const content = result.content
|
||||
if (!isReadonlyArrayNonEmpty(content)) return yield* Effect.die(new Error(`Tool execution has no content: ${id}`))
|
||||
yield* bus.publish(SessionEvent.Tool.Success, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID,
|
||||
|
||||
@@ -62,17 +62,17 @@ export const SessionTable = sqliteTable(
|
||||
idle_outcome: text().$type<NonNullable<Session.Info["outcome"]>>(),
|
||||
time_compacting: integer(),
|
||||
time_archived: integer(),
|
||||
/** The execution claim timestamp (historical column name; see SessionStore.claim). */
|
||||
time_suspended: integer(),
|
||||
/** Write-ahead recovery marker; see SessionStore.claim. */
|
||||
execution_claimed_at: integer(),
|
||||
resume_attempts: integer().notNull().default(0),
|
||||
},
|
||||
(table) => [
|
||||
index("session_v2_project_idx").on(table.project_id),
|
||||
index("session_v2_workspace_idx").on(table.workspace_id),
|
||||
index("session_v2_parent_idx").on(table.parent_id),
|
||||
index("session_v2_time_suspended_idx")
|
||||
.on(table.time_suspended)
|
||||
.where(sql`${table.time_suspended} is not null`),
|
||||
index("session_v2_execution_claimed_at_idx")
|
||||
.on(table.execution_claimed_at)
|
||||
.where(sql`${table.execution_claimed_at} is not null`),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -191,7 +191,7 @@ const layer = Layer.effect(
|
||||
return yield* db
|
||||
.select({ sessionID: SessionTable.id })
|
||||
.from(SessionTable)
|
||||
.where(and(isNotNull(SessionTable.time_suspended), isNull(SessionTable.parent_id)))
|
||||
.where(and(isNotNull(SessionTable.execution_claimed_at), isNull(SessionTable.parent_id)))
|
||||
.all()
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
@@ -205,15 +205,15 @@ const layer = Layer.effect(
|
||||
// pinned so session ordering only moves on real changes.
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({ time_suspended: Date.now(), time_updated: sql`${SessionTable.time_updated}` })
|
||||
.where(and(eq(SessionTable.id, sessionID), isNull(SessionTable.time_suspended)))
|
||||
.set({ execution_claimed_at: Date.now(), time_updated: sql`${SessionTable.time_updated}` })
|
||||
.where(and(eq(SessionTable.id, sessionID), isNull(SessionTable.execution_claimed_at)))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
release: Effect.fn("SessionStore.release")(function* (sessionID) {
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({ time_suspended: null, resume_attempts: 0, time_updated: sql`${SessionTable.time_updated}` })
|
||||
.set({ execution_claimed_at: null, resume_attempts: 0, time_updated: sql`${SessionTable.time_updated}` })
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
@@ -221,10 +221,10 @@ const layer = Layer.effect(
|
||||
releaseChildClaims: Effect.fn("SessionStore.releaseChildClaims")((recoverable) =>
|
||||
db
|
||||
.update(SessionTable)
|
||||
.set({ time_suspended: null, resume_attempts: 0, time_updated: sql`${SessionTable.time_updated}` })
|
||||
.set({ execution_claimed_at: null, resume_attempts: 0, time_updated: sql`${SessionTable.time_updated}` })
|
||||
.where(
|
||||
and(
|
||||
isNotNull(SessionTable.time_suspended),
|
||||
isNotNull(SessionTable.execution_claimed_at),
|
||||
isNotNull(SessionTable.parent_id),
|
||||
recoverable.length > 0 ? notInArray(SessionTable.id, Array.from(recoverable)) : undefined,
|
||||
),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as ToolOutput from "./tool-output.js"
|
||||
|
||||
import path from "path"
|
||||
import type { Tool } from "@opencode-ai/schema/tool"
|
||||
import type { Tool } from "./tool.js"
|
||||
import { Context, Duration, Effect, Layer, Schedule } from "effect"
|
||||
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
@@ -15,7 +15,7 @@ export const MAX_BYTES = 50 * 1024 // 50 KiB
|
||||
export const RETENTION = Duration.days(7)
|
||||
export const DIRECTORY = "tool-output"
|
||||
|
||||
type Result = Tool.Result
|
||||
type Result = Tool.NormalizedResult
|
||||
|
||||
type Limits = {
|
||||
maxLines: number
|
||||
@@ -64,8 +64,7 @@ const layer = Layer.effect(
|
||||
|
||||
const truncate = Effect.fnUntraced(function* (result: Result) {
|
||||
if (result.metadata?.truncated !== undefined) return result
|
||||
const content =
|
||||
typeof result.content === "string" ? [{ type: "text" as const, text: result.content }] : (result.content ?? [])
|
||||
const content = result.content
|
||||
const text = content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n")
|
||||
const limits = state.get()
|
||||
const lines = text.split("\n")
|
||||
|
||||
@@ -42,6 +42,11 @@ export interface Interface extends State.Transformable<Editor> {
|
||||
readonly snapshot: (permissions?: Permission.Ruleset) => Effect.Effect<Snapshot>
|
||||
}
|
||||
|
||||
/** A local execution result after hooks and content normalization. */
|
||||
export interface NormalizedResult extends Tool.Result {
|
||||
readonly content: ReadonlyArray<Tool.Content>
|
||||
}
|
||||
|
||||
export interface Snapshot {
|
||||
readonly definitions: ReadonlyArray<ToolDefinition>
|
||||
readonly codeModeCatalog?: CodeModeCatalog.Inventory
|
||||
@@ -53,7 +58,7 @@ export interface Snapshot {
|
||||
readonly progress?: (update: Tool.Metadata) => Effect.Effect<void>
|
||||
/** Surviving request definitions, keyed by the names advertised after session context hooks. */
|
||||
readonly definitions?: ReadonlyMap<string, ToolDefinition>
|
||||
}) => Effect.Effect<Tool.Result & { readonly content: ReadonlyArray<Tool.Content> }, Tool.Error>
|
||||
}) => Effect.Effect<NormalizedResult, Tool.Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Tool") {}
|
||||
|
||||
@@ -26,7 +26,9 @@ describe("ConfigToolOutputPlugin.Plugin", () => {
|
||||
const plugins = yield* Plugin.Service
|
||||
yield* ConfigToolOutputPlugin.Plugin.effect(yield* PluginHost.make(plugins))
|
||||
|
||||
expect((yield* output.truncate({ content: "one\ntwo" })).metadata?.truncated).toBe(true)
|
||||
expect((yield* output.truncate({ content: [{ type: "text", text: "one\ntwo" }] })).metadata?.truncated).toBe(
|
||||
true,
|
||||
)
|
||||
|
||||
yield* config.setEntries([
|
||||
new Document({
|
||||
@@ -38,7 +40,7 @@ describe("ConfigToolOutputPlugin.Plugin", () => {
|
||||
])
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
for (let attempt = 0; attempt < 200; attempt++) {
|
||||
const result = yield* output.truncate({ content: "one\ntwo" })
|
||||
const result = yield* output.truncate({ content: [{ type: "text", text: "one\ntwo" }] })
|
||||
if (result.metadata?.truncated === false) return
|
||||
yield* Effect.sleep("10 millis")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { SqliteClient } from "@effect/sql-sqlite-bun"
|
||||
import { EffectDrizzleSqlite } from "@opencode-ai/core/database/drizzle"
|
||||
import { DatabaseMigration } from "@opencode-ai/core/database/migration"
|
||||
import { migrations } from "@opencode-ai/core/database/migration.gen"
|
||||
import executionClaimMigration from "@opencode-ai/core/database/migration/20260903010538_execution_claimed_at"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { sql } from "drizzle-orm"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
SqliteClient.layer({ filename: ":memory:", disableWAL: true }),
|
||||
Layer.succeed(Global.Service, Global.make()),
|
||||
),
|
||||
)
|
||||
|
||||
describe("execution claim column", () => {
|
||||
it.live("bootstraps the renamed column with an unclaimed default and its partial index", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = yield* EffectDrizzleSqlite.makeWithDefaults()
|
||||
yield* DatabaseMigration.apply(db)
|
||||
yield* db.run(sql`
|
||||
INSERT INTO project (id, worktree, time_created, time_updated, sandboxes)
|
||||
VALUES ('project', '/repo', 1, 2, '[]')
|
||||
`)
|
||||
yield* db.run(sql`
|
||||
INSERT INTO session_v2 (id, project_id, slug, directory, version, time_created, time_updated)
|
||||
VALUES ('session', 'project', 'session', '/repo', '2', 1, 2)
|
||||
`)
|
||||
|
||||
expect(yield* db.get(sql`SELECT execution_claimed_at, resume_attempts FROM session_v2`)).toEqual({
|
||||
execution_claimed_at: null,
|
||||
resume_attempts: 0,
|
||||
})
|
||||
expect(yield* db.get(sql`SELECT id FROM migration WHERE id = ${executionClaimMigration.id}`)).toEqual({
|
||||
id: executionClaimMigration.id,
|
||||
})
|
||||
expect(
|
||||
yield* db.all(sql`SELECT name FROM pragma_table_info('session_v2') WHERE name = 'time_suspended'`),
|
||||
).toEqual([])
|
||||
expect(
|
||||
yield* db.get(sql`
|
||||
SELECT name, partial FROM pragma_index_list('session_v2')
|
||||
WHERE name = 'session_v2_execution_claimed_at_idx'
|
||||
`),
|
||||
).toEqual({ name: "session_v2_execution_claimed_at_idx", partial: 1 })
|
||||
expect(yield* db.all(sql`SELECT name FROM pragma_index_info('session_v2_execution_claimed_at_idx')`)).toEqual([
|
||||
{ name: "execution_claimed_at" },
|
||||
])
|
||||
yield* DatabaseMigration.apply(db)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("migrates existing claims without resetting attempts, activity, or dependent history", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = yield* EffectDrizzleSqlite.makeWithDefaults()
|
||||
yield* db.run(sql`PRAGMA foreign_keys = ON`)
|
||||
yield* db.run(sql`
|
||||
CREATE TABLE session_v2 (
|
||||
id text PRIMARY KEY,
|
||||
parent_id text,
|
||||
title text,
|
||||
time_created integer NOT NULL,
|
||||
time_updated integer NOT NULL,
|
||||
time_suspended integer,
|
||||
resume_attempts integer DEFAULT 0 NOT NULL
|
||||
)
|
||||
`)
|
||||
yield* db.run(sql`
|
||||
CREATE INDEX session_v2_time_suspended_idx ON session_v2 (time_suspended)
|
||||
WHERE "session_v2"."time_suspended" IS NOT NULL
|
||||
`)
|
||||
yield* db.run(sql`
|
||||
CREATE TABLE session_message (
|
||||
id text PRIMARY KEY,
|
||||
session_id text NOT NULL REFERENCES session_v2(id) ON DELETE CASCADE,
|
||||
data text NOT NULL
|
||||
)
|
||||
`)
|
||||
yield* db.run(sql`
|
||||
INSERT INTO session_v2 VALUES
|
||||
('claimed', NULL, 'Claimed', 1, 2, 1234, 2),
|
||||
('child', 'claimed', 'Child', 3, 4, 5678, 1),
|
||||
('idle', NULL, 'Idle', 5, 6, NULL, 0)
|
||||
`)
|
||||
yield* db.run(sql`INSERT INTO session_message VALUES ('message', 'claimed', '{"text":"preserved"}')`)
|
||||
yield* db.run(sql`CREATE TABLE migration (id text PRIMARY KEY, time_completed integer NOT NULL)`)
|
||||
yield* Effect.forEach(
|
||||
migrations.filter((migration) => migration.id !== executionClaimMigration.id),
|
||||
(migration) => db.run(sql`INSERT INTO migration VALUES (${migration.id}, 1)`),
|
||||
)
|
||||
|
||||
yield* DatabaseMigration.apply(db)
|
||||
yield* DatabaseMigration.apply(db)
|
||||
|
||||
expect(yield* db.all(sql`SELECT * FROM session_v2 ORDER BY id`)).toEqual([
|
||||
{
|
||||
id: "child",
|
||||
parent_id: "claimed",
|
||||
title: "Child",
|
||||
time_created: 3,
|
||||
time_updated: 4,
|
||||
execution_claimed_at: 5678,
|
||||
resume_attempts: 1,
|
||||
},
|
||||
{
|
||||
id: "claimed",
|
||||
parent_id: null,
|
||||
title: "Claimed",
|
||||
time_created: 1,
|
||||
time_updated: 2,
|
||||
execution_claimed_at: 1234,
|
||||
resume_attempts: 2,
|
||||
},
|
||||
{
|
||||
id: "idle",
|
||||
parent_id: null,
|
||||
title: "Idle",
|
||||
time_created: 5,
|
||||
time_updated: 6,
|
||||
execution_claimed_at: null,
|
||||
resume_attempts: 0,
|
||||
},
|
||||
])
|
||||
expect(yield* db.all(sql`SELECT * FROM session_message`)).toEqual([
|
||||
{ id: "message", session_id: "claimed", data: '{"text":"preserved"}' },
|
||||
])
|
||||
expect(yield* db.all(sql`PRAGMA foreign_key_check`)).toEqual([])
|
||||
expect(yield* db.get(sql`SELECT count(*) AS count FROM migration`)).toEqual({ count: migrations.length })
|
||||
expect(
|
||||
yield* db.all(sql`
|
||||
SELECT name FROM pragma_index_list('session_v2') WHERE name = 'session_v2_time_suspended_idx'
|
||||
`),
|
||||
).toEqual([])
|
||||
expect(
|
||||
yield* db.all(sql`
|
||||
SELECT id FROM session_v2 INDEXED BY session_v2_execution_claimed_at_idx
|
||||
WHERE execution_claimed_at IS NOT NULL ORDER BY id
|
||||
`),
|
||||
).toEqual([{ id: "child" }, { id: "claimed" }])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||
|
||||
// Core is env-free, so the default ModelsDev node refreshes from models.dev
|
||||
// unless the graph says otherwise. Real-Location fixtures opt out here; the
|
||||
// test harness refuses any request that slips past.
|
||||
export const offlineModels = ModelsDev.node.replace(ModelsDev.configured({ fetch: false }))
|
||||
@@ -15,12 +15,14 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Formatter } from "../src/formatter"
|
||||
import { Location } from "../src/location"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { offlineModels } from "./fixture/models"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
offlineModels,
|
||||
]),
|
||||
)
|
||||
type ConfigInput = typeof Info.Encoded
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { offlineModels } from "./fixture/models"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { Database } from "../src/database/database"
|
||||
import { Bus } from "../src/bus"
|
||||
@@ -47,6 +48,7 @@ const instances = Layer.effect(
|
||||
)
|
||||
const bindings: LayerNode.Replacements = [
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
offlineModels,
|
||||
LocationServiceMap.node.replace(Layer.succeed(LocationServiceMap.Service, map)),
|
||||
Instance.node.replace(
|
||||
Layer.succeed(Instance.Service, {
|
||||
@@ -61,6 +63,7 @@ const instances = Layer.effect(
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node]), [
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
offlineModels,
|
||||
LocationServiceMap.node.replace(instances),
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -1,19 +1,48 @@
|
||||
import { test, type TestOptions } from "bun:test"
|
||||
import { Cause, Effect, Exit, Layer, type Scope } from "effect"
|
||||
import { TestClock, TestConsole } from "effect/testing"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
|
||||
type Body<A, E, R> = Effect.Effect<A, E, R> | (() => Effect.Effect<A, E, R>)
|
||||
|
||||
const body = <A, E, R>(value: Body<A, E, R>) => Effect.suspend(() => (typeof value === "function" ? value() : value))
|
||||
|
||||
const loopback = new Set(["127.0.0.1", "localhost", "[::1]"])
|
||||
|
||||
// Core is env-free, so nothing tells the default node graph to stay offline;
|
||||
// a test that boots it would phone home through FetchHttpClient. Every
|
||||
// FetchHttpClient reads this reference at request time, so refusing here covers
|
||||
// each node that shares the default client, while an explicit HttpClient
|
||||
// replacement never reaches it. Callers such as ModelsDev swallow request
|
||||
// failures, so the harness also records the attempt and fails the test itself.
|
||||
export const refuseNetwork = (violations: string[]): typeof fetch =>
|
||||
Object.assign(
|
||||
(input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
|
||||
if (loopback.has(new URL(url).hostname)) return fetch(input, init)
|
||||
const method = init?.method ?? (input instanceof Request ? input.method : "GET")
|
||||
const message = `test attempted network request: ${method} ${url} — provide an explicit HttpClient or disable the fetch`
|
||||
violations.push(message)
|
||||
return Promise.reject(new Error(message))
|
||||
},
|
||||
{ preconnect: fetch.preconnect },
|
||||
)
|
||||
|
||||
const run = <A, E, R, E2>(value: Body<A, E, R | Scope.Scope>, layer: Layer.Layer<R, E2>) =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* body(value).pipe(Effect.scoped, Effect.provide(layer), Effect.exit)
|
||||
const violations: string[] = []
|
||||
const exit = yield* body(value).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(layer),
|
||||
Effect.provideService(FetchHttpClient.Fetch, refuseNetwork(violations)),
|
||||
Effect.exit,
|
||||
)
|
||||
if (Exit.isFailure(exit)) {
|
||||
for (const err of Cause.prettyErrors(exit.cause)) {
|
||||
yield* Effect.logError(err)
|
||||
}
|
||||
}
|
||||
if (violations.length > 0) return yield* Effect.fail(new Error(violations.join("\n")))
|
||||
return yield* exit
|
||||
}).pipe(Effect.runPromise)
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { offlineModels } from "./fixture/models"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolDefinitions } from "./lib/tool"
|
||||
import { Database } from "../src/database/database"
|
||||
@@ -34,6 +35,7 @@ import { Tool } from "../src/tool"
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node]), [
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
offlineModels,
|
||||
]),
|
||||
)
|
||||
const activityLocations = Layer.effect(
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { it, refuseNetwork } from "./lib/effect"
|
||||
|
||||
describe("test harness network guard", () => {
|
||||
test("refuses requests to hosts other than loopback", async () => {
|
||||
const violations: string[] = []
|
||||
const refused = refuseNetwork(violations)
|
||||
await expect(refused("https://models.opencode.ai/api.json")).rejects.toThrow(
|
||||
"test attempted network request: GET https://models.opencode.ai/api.json — provide an explicit HttpClient or disable the fetch",
|
||||
)
|
||||
await expect(refused(new Request("https://example.invalid/", { method: "POST" }))).rejects.toThrow(
|
||||
"test attempted network request: POST https://example.invalid/",
|
||||
)
|
||||
expect(violations).toHaveLength(2)
|
||||
})
|
||||
|
||||
it.live("the default http client node requests through the harness fetch", () =>
|
||||
Effect.gen(function* () {
|
||||
const seen: string[] = []
|
||||
const response = yield* HttpClient.get("https://example.invalid/catalog").pipe(
|
||||
Effect.flatMap((response) => response.text),
|
||||
Effect.provide(AppNodeBuilder.build(LayerNodePlatform.httpClient)),
|
||||
Effect.provideService(
|
||||
FetchHttpClient.Fetch,
|
||||
Object.assign(
|
||||
(input: string | URL | Request) => {
|
||||
seen.push(typeof input === "string" ? input : input instanceof URL ? input.href : input.url)
|
||||
return Promise.resolve(new Response("from fetch"))
|
||||
},
|
||||
{ preconnect: fetch.preconnect },
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(response).toBe("from fetch")
|
||||
expect(seen).toEqual(["https://example.invalid/catalog"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("an explicit HttpClient replacement is what the node sees", () =>
|
||||
Effect.gen(function* () {
|
||||
const mock = HttpClient.make((request) =>
|
||||
Effect.succeed(HttpClientResponse.fromWeb(request, new Response("from mock"))),
|
||||
)
|
||||
const response = yield* HttpClient.get("https://example.invalid/catalog").pipe(
|
||||
Effect.flatMap((response) => response.text),
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(LayerNodePlatform.httpClient, [
|
||||
LayerNodePlatform.httpClient.replace(Layer.succeed(HttpClient.HttpClient, mock)),
|
||||
]),
|
||||
),
|
||||
)
|
||||
expect(response).toBe("from mock")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("loopback requests pass through to the real fetch", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* Effect.acquireRelease(
|
||||
Effect.sync(() => Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response("local") })),
|
||||
(server) => Effect.promise(() => server.stop(true)),
|
||||
)
|
||||
const response = yield* HttpClient.get(`http://127.0.0.1:${server.port}/`).pipe(
|
||||
Effect.flatMap((response) => response.text),
|
||||
Effect.provide(AppNodeBuilder.build(LayerNodePlatform.httpClient)),
|
||||
)
|
||||
expect(response).toBe("local")
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import { LLM } from "@opencode-ai/ai"
|
||||
import { LLMClient, RequestExecutor } from "@opencode-ai/ai/route"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
@@ -608,7 +609,16 @@ describe("OpencodePlugin", () => {
|
||||
draft.cost = cost(1)
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
// An env credential has no server metadata, so the plugin would ask the
|
||||
// default Console for remote config; answer 404 (no remote config) locally.
|
||||
yield* addPlugin().pipe(
|
||||
Effect.provideService(
|
||||
HttpClient.HttpClient,
|
||||
HttpClient.make((request) =>
|
||||
Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 404 }))),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(required(yield* catalog.provider.get(Provider.ID.opencode)).settings?.apiKey).toBeUndefined()
|
||||
expect(required(yield* catalog.model.get(Provider.ID.opencode, Model.ID.make("paid"))).enabled).toBe(true)
|
||||
}),
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { describe, expect, setDefaultTimeout } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Deferred, Duration, Effect, Layer, LayerMap, Schedule } from "effect"
|
||||
import { Deferred, Duration, Effect, Fiber, Layer, LayerMap, Schedule } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Event } from "@opencode-ai/schema/config"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
@@ -19,7 +20,9 @@ import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { tempGlobalLayer } from "../fixture/global"
|
||||
import { offlineModels } from "../fixture/models"
|
||||
import { tmpdirScoped } from "../fixture/tmpdir"
|
||||
import { advance } from "../lib/clock"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
// Real Location boot with plugin-directory discovery, so local plugin files are loaded and reloaded.
|
||||
@@ -54,13 +57,15 @@ const npmLayer = Layer.succeed(
|
||||
const instances = Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
Effect.gen(function* () {
|
||||
const watcher = yield* Watcher.Test
|
||||
const map = yield* LayerMap.make((ref: Location.Ref) => Instance.layer(ref, { replacements: bindings }), {
|
||||
idleTimeToLive: Duration.infinity,
|
||||
})
|
||||
const bindings: LayerNode.Replacements = [
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
offlineModels,
|
||||
Npm.node.replace(npmLayer),
|
||||
Watcher.node.replace(Watcher.configured({ enabled: false })),
|
||||
Watcher.node.replace(Layer.succeed(Watcher.Service, watcher)),
|
||||
LocationServiceMap.node.replace(Layer.succeed(LocationServiceMap.Service, map)),
|
||||
Instance.node.replace(
|
||||
Layer.succeed(Instance.Service, {
|
||||
@@ -70,13 +75,14 @@ const instances = Layer.effect(
|
||||
]
|
||||
return map
|
||||
}),
|
||||
)
|
||||
).pipe(Layer.provide(Watcher.testLayer))
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
offlineModels,
|
||||
LocationServiceMap.node.replace(instances),
|
||||
]),
|
||||
]).pipe(Layer.provideMerge(Watcher.testLayer)),
|
||||
)
|
||||
|
||||
const greeter = (command: string) => `export default {
|
||||
@@ -104,6 +110,61 @@ const failed = (plugins: Plugin.Interface) =>
|
||||
)
|
||||
|
||||
describe("PluginSupervisor reload", () => {
|
||||
;(["discovered", "configured"] as const).forEach((mode) => {
|
||||
it.effect(`retains a ${mode} plugin change during initial activation`, () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* tmpdirScoped()
|
||||
const file = path.join(
|
||||
directory.path,
|
||||
mode === "discovered" ? ".opencode/plugins/greeter.ts" : "external/greeter/index.ts",
|
||||
)
|
||||
yield* Effect.promise(async () => {
|
||||
await Bun.write(file, greeter("greet-v1"))
|
||||
await fs.utimes(file, new Date(0), new Date(0))
|
||||
if (mode === "configured") {
|
||||
await Bun.write(
|
||||
path.join(directory.path, ".opencode/opencode.json"),
|
||||
JSON.stringify({ plugins: [path.dirname(file)] }),
|
||||
)
|
||||
}
|
||||
})
|
||||
const entered = yield* Deferred.make<void>()
|
||||
const gate = yield* Deferred.make<void>()
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
yield* sdk.register(
|
||||
define({
|
||||
id: "gated",
|
||||
effect: () => Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(gate))),
|
||||
}),
|
||||
)
|
||||
const watcher = yield* Watcher.Test
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
yield* Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
yield* Deferred.await(entered)
|
||||
// The real ConfigPluginSource merges config-root changes and configured-path watches.
|
||||
// Emit while setup is blocked, without a bus event that could mask a lost source trigger.
|
||||
yield* Effect.promise(async () => {
|
||||
await Bun.write(file, greeter("greet-v2"))
|
||||
await fs.utimes(file, new Date(), new Date())
|
||||
})
|
||||
yield* watcher.emit({ path: file, type: "update" })
|
||||
const ready = yield* plugins.awaitActivation.pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Deferred.succeed(gate, undefined)
|
||||
yield* advance(() => ready.pollUnsafe() !== undefined)
|
||||
yield* Fiber.join(ready)
|
||||
|
||||
expect(yield* commands.get("greet-v1")).toBeUndefined()
|
||||
expect(yield* commands.get("greet-v2")).toBeDefined()
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(directory.path) }))),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.live("keeps the running generation when an updated local plugin fails to import", () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* tmpdirScoped()
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Duration, Effect, Layer, LayerMap } from "effect"
|
||||
import { Deferred, Duration, Effect, Layer, LayerMap, Stream } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Command } from "@opencode-ai/core/command"
|
||||
import { ConfigPluginSource } from "@opencode-ai/core/config/plugin/source"
|
||||
import { Instance } from "@opencode-ai/core/instance"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
@@ -14,19 +16,35 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Database } from "../../src/database/database"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { tempGlobalLayer } from "../fixture/global"
|
||||
import { offlineModels } from "../fixture/models"
|
||||
import { tmpdirScoped } from "../fixture/tmpdir"
|
||||
import { advance } from "../lib/clock"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const id = Plugin.ID.make("account-prompts")
|
||||
|
||||
// Host and instance plugins share one ID; each registers a distinct command so the winner is observable.
|
||||
const greeter = (command: string) =>
|
||||
const greeter = (command: string, plugin: string = id) =>
|
||||
define({
|
||||
id,
|
||||
id: plugin,
|
||||
effect: (ctx) =>
|
||||
ctx.command.transform((editor) => editor.add({ name: command, execute: () => Effect.void })).pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
// Every supervisor activation scans the config plugin operations once, so counting scans counts activations.
|
||||
const source = { activations: 0 }
|
||||
const sourceLayer = Layer.succeed(
|
||||
ConfigPluginSource.Service,
|
||||
ConfigPluginSource.Service.of({
|
||||
operations: () =>
|
||||
Effect.sync(() => {
|
||||
source.activations++
|
||||
return []
|
||||
}),
|
||||
changes: () => Stream.never,
|
||||
}),
|
||||
)
|
||||
|
||||
const instances = Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
Effect.gen(function* () {
|
||||
@@ -37,6 +55,8 @@ const instances = Layer.effect(
|
||||
)
|
||||
const bindings: LayerNode.Replacements = [
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
offlineModels,
|
||||
ConfigPluginSource.node.replace(sourceLayer),
|
||||
LocationServiceMap.node.replace(Layer.succeed(LocationServiceMap.Service, map)),
|
||||
Instance.node.replace(
|
||||
Layer.succeed(Instance.Service, {
|
||||
@@ -51,6 +71,7 @@ const instances = Layer.effect(
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
offlineModels,
|
||||
LocationServiceMap.node.replace(instances),
|
||||
]),
|
||||
)
|
||||
@@ -94,4 +115,117 @@ describe("PluginSupervisor", () => {
|
||||
).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("activates the initial generation without waiting for the reload debounce", () =>
|
||||
Effect.gen(function* () {
|
||||
source.activations = 0
|
||||
const directory = yield* tmpdirScoped()
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
yield* Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
// The TestClock never advances here, so any timer between boot and the first activation would hang this.
|
||||
yield* plugins.awaitActivation
|
||||
expect(yield* commands.get("instance-greet")).toBeDefined()
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(directory.path) }))),
|
||||
)
|
||||
expect(source.activations).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("refreshes every 24 hours without adding an immediate reload", () =>
|
||||
Effect.gen(function* () {
|
||||
source.activations = 0
|
||||
const directory = yield* tmpdirScoped()
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
yield* Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
yield* plugins.awaitActivation
|
||||
yield* TestClock.adjust("23 hours")
|
||||
expect(source.activations).toBe(1)
|
||||
|
||||
yield* TestClock.adjust("1 hour")
|
||||
yield* advance(() => source.activations === 2)
|
||||
yield* plugins.awaitActivation
|
||||
|
||||
yield* TestClock.adjust("24 hours")
|
||||
yield* advance(() => source.activations === 3)
|
||||
yield* plugins.awaitActivation
|
||||
expect(source.activations).toBe(3)
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(directory.path) }))),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reloads for a trigger published while the initial generation is activating", () =>
|
||||
Effect.gen(function* () {
|
||||
source.activations = 0
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
const entered = yield* Deferred.make<void>()
|
||||
const gate = yield* Deferred.make<void>()
|
||||
yield* sdk.register(
|
||||
define({
|
||||
id: "gated",
|
||||
effect: () => Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(gate))),
|
||||
}),
|
||||
)
|
||||
let late = false
|
||||
const directory = yield* tmpdirScoped()
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
yield* Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
yield* Deferred.await(entered)
|
||||
// Generation 0 is mid-setup: the reload feed must already be subscribed for this update to count.
|
||||
yield* sdk.register(
|
||||
define({
|
||||
id: "late",
|
||||
effect: (ctx) =>
|
||||
ctx.command
|
||||
.transform((editor) => editor.add({ name: "late-greet", execute: () => Effect.void }))
|
||||
.pipe(Effect.tap(() => Effect.sync(() => (late = true)))),
|
||||
}),
|
||||
)
|
||||
yield* Deferred.succeed(gate, undefined)
|
||||
yield* advance(() => late)
|
||||
yield* plugins.awaitActivation
|
||||
expect(yield* commands.get("late-greet")).toBeDefined()
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(directory.path) }))),
|
||||
)
|
||||
expect(source.activations).toBe(2)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("coalesces a burst of reload triggers after the initial generation into one activation", () =>
|
||||
Effect.gen(function* () {
|
||||
source.activations = 0
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
const directory = yield* tmpdirScoped()
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
yield* Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const commands = yield* Command.Service
|
||||
yield* plugins.awaitActivation
|
||||
expect(source.activations).toBe(1)
|
||||
|
||||
yield* sdk.register(greeter("greet-a", "a"))
|
||||
yield* sdk.register(greeter("greet-b", "b"))
|
||||
yield* sdk.register(greeter("greet-c", "c"))
|
||||
yield* advance(() => source.activations > 1)
|
||||
yield* plugins.awaitActivation
|
||||
expect(yield* commands.get("greet-a")).toBeDefined()
|
||||
expect(yield* commands.get("greet-c")).toBeDefined()
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(directory.path) }))),
|
||||
)
|
||||
expect(source.activations).toBe(2)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,5 +1 @@
|
||||
import path from "path"
|
||||
|
||||
process.env.OPENCODE_DB = ":memory:"
|
||||
process.env.OPENCODE_MODELS_PATH = path.join(import.meta.dir, "plugin", "fixtures", "models-dev.json")
|
||||
process.env.OPENCODE_DISABLE_MODELS_FETCH = "true"
|
||||
|
||||
@@ -13,6 +13,7 @@ import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { offlineModels } from "./fixture/models"
|
||||
import { tmpdirScoped } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
@@ -24,6 +25,7 @@ const it = testEffect(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, Session.node, LocationServiceMap.node]),
|
||||
[
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
offlineModels,
|
||||
Watcher.node.replace(Watcher.configured({ enabled: false })),
|
||||
SessionExecution.node.replace(SessionExecution.noopLayer),
|
||||
],
|
||||
|
||||
@@ -35,6 +35,7 @@ import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { Expected } from "./lib/session-message"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import { offlineModels } from "./fixture/models"
|
||||
import { promptLocationNode } from "./fixture/prompt-location"
|
||||
import { globalProjectNode } from "./lib/project"
|
||||
import { tmpdirScoped } from "./fixture/tmpdir"
|
||||
@@ -61,7 +62,11 @@ const it = testEffect(
|
||||
const liveIt = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, Project.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[Bus.node.replace(Bus.configured({ persist: true })), SessionExecution.node.replace(SessionExecution.noopLayer)],
|
||||
[
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
SessionExecution.node.replace(SessionExecution.noopLayer),
|
||||
offlineModels,
|
||||
],
|
||||
),
|
||||
)
|
||||
const projectIt = testEffect(
|
||||
@@ -227,7 +232,7 @@ describe("Session.create", () => {
|
||||
).toMatchObject({
|
||||
time_created: before.time_created,
|
||||
time_updated: before.time_updated,
|
||||
time_suspended: before.time_suspended,
|
||||
execution_claimed_at: before.execution_claimed_at,
|
||||
resume_attempts: before.resume_attempts,
|
||||
})
|
||||
// Repeated resolution announces the directory's identity exactly once.
|
||||
|
||||
@@ -63,10 +63,10 @@ describe("SessionExecution lifecycle", () => {
|
||||
const parent = Session.ID.make("ses_recover_parent")
|
||||
const child = Session.ID.make("ses_recover_child")
|
||||
const idle = Session.ID.make("ses_recover_idle")
|
||||
yield* seedSessions(database, [parent], { time_suspended: Date.now() })
|
||||
yield* seedSessions(database, [parent], { execution_claimed_at: Date.now() })
|
||||
yield* seedSessions(database, [idle])
|
||||
// Children recover through background Job records, never through the root claim sweep.
|
||||
yield* seedSessions(database, [child], { time_suspended: Date.now(), parent_id: parent })
|
||||
yield* seedSessions(database, [child], { execution_claimed_at: Date.now(), parent_id: parent })
|
||||
|
||||
expect(yield* store.listSuspended()).toEqual([parent])
|
||||
|
||||
@@ -224,7 +224,7 @@ describe("SessionExecution lifecycle", () => {
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const sessionIDs = Array.from({ length: 5 }, (_, index) => Session.ID.make(`ses_resume_concurrent_${index}`))
|
||||
yield* seedSessions(database, sessionIDs, { time_suspended: Date.now() })
|
||||
yield* seedSessions(database, sessionIDs, { execution_claimed_at: Date.now() })
|
||||
|
||||
const fourStarted = yield* Deferred.make<void>()
|
||||
const started: Session.ID[] = []
|
||||
@@ -251,7 +251,7 @@ describe("SessionExecution lifecycle", () => {
|
||||
const bus = yield* Bus.Service
|
||||
const first = Session.ID.make("ses_resume_first")
|
||||
const second = Session.ID.make("ses_resume_second")
|
||||
yield* seedSessions(database, [first, second], { time_suspended: Date.now() })
|
||||
yield* seedSessions(database, [first, second], { execution_claimed_at: Date.now() })
|
||||
|
||||
const drained: string[] = []
|
||||
const bothDraining = yield* Deferred.make<void>()
|
||||
@@ -297,7 +297,7 @@ describe("SessionExecution lifecycle", () => {
|
||||
const bus = yield* Bus.Service
|
||||
const sessionID = Session.ID.make("ses_resume_exhausted")
|
||||
// A claim from a dead process, already resumed twice without completing.
|
||||
yield* seedSessions(database, [sessionID], { time_suspended: Date.now(), resume_attempts: 2 })
|
||||
yield* seedSessions(database, [sessionID], { execution_claimed_at: Date.now(), resume_attempts: 2 })
|
||||
|
||||
const drained: string[] = []
|
||||
const failures: SessionEvent.Execution.Failed[] = []
|
||||
@@ -322,7 +322,7 @@ describe("SessionExecution lifecycle", () => {
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const sessionID = Session.ID.make("ses_resume_counted")
|
||||
yield* seedSessions(database, [sessionID], { time_suspended: Date.now() })
|
||||
yield* seedSessions(database, [sessionID], { execution_claimed_at: Date.now() })
|
||||
|
||||
const draining = yield* Deferred.make<void>()
|
||||
const scope = yield* Scope.make()
|
||||
@@ -388,7 +388,7 @@ describe("SessionRestart background recovery", () => {
|
||||
const parent = Session.ID.make("ses_background_recovery_parent")
|
||||
const child = Session.ID.make("ses_background_recovery_child")
|
||||
yield* seedSessions(database, [parent])
|
||||
yield* seedSessions(database, [child], { parent_id: parent, time_suspended: Date.now() })
|
||||
yield* seedSessions(database, [child], { parent_id: parent, execution_claimed_at: Date.now() })
|
||||
yield* seedBackground(jobs, parent, [
|
||||
{ id: "sh_background_orphan", shellID: "sh_background_orphan", command: "sleep 60" },
|
||||
])
|
||||
@@ -600,7 +600,7 @@ describe("SessionRestart background recovery", () => {
|
||||
const store = yield* SessionStore.Service
|
||||
const bus = yield* Bus.Service
|
||||
const parent = Session.ID.make("ses_background_claimed_parent")
|
||||
yield* seedSessions(database, [parent], { time_suspended: Date.now() })
|
||||
yield* seedSessions(database, [parent], { execution_claimed_at: Date.now() })
|
||||
yield* seedBackground(jobs, parent, [{ id: "call-claimed-shell", shellID: "sh_claimed", command: "sleep 60" }])
|
||||
|
||||
const observed = yield* Deferred.make<string[]>()
|
||||
@@ -641,7 +641,7 @@ describe("SessionRestart background recovery", () => {
|
||||
const database = yield* Database.Service
|
||||
const jobs = yield* Job.Service
|
||||
const sessionID = Session.ID.make("ses_shell_recovery_exhausted")
|
||||
yield* seedSessions(database, [sessionID], { time_suspended: Date.now(), resume_attempts: 2 })
|
||||
yield* seedSessions(database, [sessionID], { execution_claimed_at: Date.now(), resume_attempts: 2 })
|
||||
yield* seedBackground(jobs, sessionID, [
|
||||
{ id: "call-exhausted-shell", shellID: "sh_exhausted", command: "sleep 60" },
|
||||
])
|
||||
@@ -688,7 +688,7 @@ describe("SessionRestart background recovery", () => {
|
||||
yield* seedSessions(database, [parent])
|
||||
yield* seedSessions(database, [child], {
|
||||
parent_id: parent,
|
||||
time_suspended: Date.now(),
|
||||
execution_claimed_at: Date.now(),
|
||||
resume_attempts: resumeAttempts,
|
||||
})
|
||||
const shell = seedBackground(jobs, child, [
|
||||
@@ -782,8 +782,8 @@ describe("SessionRestart background recovery", () => {
|
||||
const parent = Session.ID.make("ses_subagent_recovery_parent")
|
||||
const child = Session.ID.make("ses_subagent_recovery_child")
|
||||
const unrelated = Session.ID.make("ses_subagent_unrelated_child")
|
||||
yield* seedSessions(database, [parent], { time_suspended: Date.now(), resume_attempts: 1 })
|
||||
yield* seedSessions(database, [child, unrelated], { parent_id: parent, time_suspended: Date.now() })
|
||||
yield* seedSessions(database, [parent], { execution_claimed_at: Date.now(), resume_attempts: 1 })
|
||||
yield* seedSessions(database, [child, unrelated], { parent_id: parent, execution_claimed_at: Date.now() })
|
||||
yield* jobs.start({
|
||||
id: child,
|
||||
type: "subagent",
|
||||
@@ -959,7 +959,7 @@ describe("SessionRestart background recovery", () => {
|
||||
Session.ID.make("ses_subagent_budget_child_1"),
|
||||
Session.ID.make("ses_subagent_budget_child_2"),
|
||||
]
|
||||
yield* seedSessions(database, [parent], { time_suspended: Date.now(), resume_attempts: resumeAttempts })
|
||||
yield* seedSessions(database, [parent], { execution_claimed_at: Date.now(), resume_attempts: resumeAttempts })
|
||||
yield* seedSessions(database, children, { parent_id: parent })
|
||||
const complete = yield* Deferred.make<string>()
|
||||
for (const child of children) {
|
||||
@@ -1033,7 +1033,11 @@ describe("SessionRestart background recovery", () => {
|
||||
const parent = Session.ID.make("ses_subagent_exhausted_parent")
|
||||
const child = Session.ID.make("ses_subagent_exhausted_child")
|
||||
yield* seedSessions(database, [parent])
|
||||
yield* seedSessions(database, [child], { parent_id: parent, time_suspended: Date.now(), resume_attempts: 2 })
|
||||
yield* seedSessions(database, [child], {
|
||||
parent_id: parent,
|
||||
execution_claimed_at: Date.now(),
|
||||
resume_attempts: 2,
|
||||
})
|
||||
yield* jobs.start({
|
||||
id: child,
|
||||
type: "subagent",
|
||||
@@ -1273,7 +1277,9 @@ function seedInbox(
|
||||
function seedSessions(
|
||||
database: Database.Service["Service"],
|
||||
sessionIDs: ReadonlyArray<Session.ID>,
|
||||
values: Partial<Pick<typeof SessionTable.$inferInsert, "time_suspended" | "resume_attempts" | "parent_id">> = {},
|
||||
values: Partial<
|
||||
Pick<typeof SessionTable.$inferInsert, "execution_claimed_at" | "resume_attempts" | "parent_id">
|
||||
> = {},
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
yield* database.db
|
||||
@@ -1302,7 +1308,7 @@ function seedSessions(
|
||||
|
||||
function claims(database: Database.Service["Service"]) {
|
||||
return database.db
|
||||
.select({ id: SessionTable.id, claimed: SessionTable.time_suspended })
|
||||
.select({ id: SessionTable.id, claimed: SessionTable.execution_claimed_at })
|
||||
.from(SessionTable)
|
||||
.all()
|
||||
.pipe(
|
||||
|
||||
@@ -18,6 +18,7 @@ import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionRunner } from "@opencode-ai/core/session/runner/index"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { offlineModels } from "./fixture/models"
|
||||
import { tmpdirScoped } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { globalProjectNode } from "./lib/project"
|
||||
@@ -25,7 +26,7 @@ import { globalProjectNode } from "./lib/project"
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[Project.node.replace(globalProjectNode), SessionExecution.node.replace(SessionExecution.noopLayer)],
|
||||
[Project.node.replace(globalProjectNode), SessionExecution.node.replace(SessionExecution.noopLayer), offlineModels],
|
||||
),
|
||||
)
|
||||
const itWithActiveExecution = testEffect(
|
||||
@@ -47,7 +48,7 @@ const itWithActiveExecution = testEffect(
|
||||
(ref: Location.Ref) =>
|
||||
Layer.merge(
|
||||
LayerNode.compile(Location.boundNode(ref), {
|
||||
replacements: [Project.node.replace(globalProjectNode)],
|
||||
replacements: [Project.node.replace(globalProjectNode), offlineModels],
|
||||
}),
|
||||
Layer.succeed(SessionRunner.Service, { drain: () => Effect.never }),
|
||||
) as unknown as Layer.Layer<LocationServices>,
|
||||
|
||||
@@ -549,19 +549,19 @@ describe("SessionProjector", () => {
|
||||
Effect.gen(function* () {
|
||||
const db = yield* seedSession()
|
||||
const bus = yield* Bus.Service
|
||||
const suspended = () =>
|
||||
const claim = () =>
|
||||
db
|
||||
.select({ timeSuspended: SessionTable.time_suspended })
|
||||
.select({ executionClaimedAt: SessionTable.execution_claimed_at })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
yield* bus.publish(SessionEvent.Execution.Interrupted, { sessionID, reason: "shutdown" })
|
||||
expect((yield* suspended())?.timeSuspended).toBeNull()
|
||||
expect((yield* claim())?.executionClaimedAt).toBeNull()
|
||||
|
||||
yield* bus.publish(SessionEvent.Execution.Started, { sessionID })
|
||||
expect((yield* suspended())?.timeSuspended).toBeNull()
|
||||
expect((yield* claim())?.executionClaimedAt).toBeNull()
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import { SessionEnvironment } from "@opencode-ai/core/session/environment"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { globalProjectNode } from "./lib/project"
|
||||
import { offlineModels } from "./fixture/models"
|
||||
import { tmpdirScoped } from "./fixture/tmpdir"
|
||||
|
||||
const closed: Session.ID[] = []
|
||||
@@ -50,6 +51,7 @@ const it = testEffect(
|
||||
Project.node.replace(globalProjectNode),
|
||||
SessionExecution.node.replace(SessionExecution.noopLayer),
|
||||
SessionModelTransport.node.replace(transport),
|
||||
offlineModels,
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -23,6 +23,7 @@ import { Money } from "@opencode-ai/schema/money"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { offlineModels } from "./fixture/models"
|
||||
import { tmpdirScoped } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
@@ -33,6 +34,7 @@ const it = testEffect(
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
SessionExecution.node.replace(SessionExecution.noopLayer),
|
||||
offlineModels,
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator
|
||||
import { Shell } from "@opencode-ai/core/shell"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { location } from "./fixture/location"
|
||||
import { offlineModels } from "./fixture/models"
|
||||
import { tmpdirScoped } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
@@ -59,6 +60,7 @@ const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Bus.node, Session.node, SessionExecution.node, LocationServiceMap.node]), [
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
SessionExecution.node.replace(executionLayer.pipe(Layer.provide(controlLayer))),
|
||||
offlineModels,
|
||||
]).pipe(Layer.provideMerge(controlLayer)),
|
||||
)
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ for (const fixture of [
|
||||
executeTool: () =>
|
||||
Effect.sync(() => {
|
||||
executions++
|
||||
return { content: "Completed tool" }
|
||||
return { content: [{ type: "text", text: "Completed tool" }] }
|
||||
}),
|
||||
},
|
||||
retry: (_cause, _error, retry) =>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Effect } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { ToolOutput } from "@opencode-ai/core/tool-output"
|
||||
import type { Tool } from "@opencode-ai/core/tool"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Identifier } from "@opencode-ai/core/id/id"
|
||||
@@ -36,7 +37,7 @@ describe("ToolOutput", () => {
|
||||
(service, fs) =>
|
||||
Effect.gen(function* () {
|
||||
const output = { items: [1, 2, 3] }
|
||||
const result = yield* service.truncate({ output, content: "one\ntwo\nthree" })
|
||||
const result = yield* service.truncate({ output, content: [{ type: "text", text: "one\ntwo\nthree" }] })
|
||||
expect(result.output).toBe(output)
|
||||
expect(result.metadata).toMatchObject({ truncated: true })
|
||||
const outputPath = result.metadata?.outputPath
|
||||
@@ -56,7 +57,7 @@ describe("ToolOutput", () => {
|
||||
withStore(
|
||||
(output) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* output.truncate({ content: "one\ntwo" })
|
||||
const result = yield* output.truncate({ content: [{ type: "text", text: "one\ntwo" }] })
|
||||
expect(result.content).toEqual([
|
||||
{ type: "text", text: "one" },
|
||||
{
|
||||
@@ -91,8 +92,9 @@ describe("ToolOutput", () => {
|
||||
it.live("skips results that report a truncation state", () =>
|
||||
withStore((output) =>
|
||||
Effect.gen(function* () {
|
||||
const truncated = { content: "one\ntwo", metadata: { truncated: true, source: "tool" } }
|
||||
const retained = { content: "one\ntwo", metadata: { truncated: false, source: "tool" } }
|
||||
const content: Tool.NormalizedResult["content"] = [{ type: "text", text: "one\ntwo" }]
|
||||
const truncated = { content, metadata: { truncated: true, source: "tool" } }
|
||||
const retained = { content, metadata: { truncated: false, source: "tool" } }
|
||||
expect(yield* output.truncate(truncated)).toBe(truncated)
|
||||
expect(yield* output.truncate(retained)).toBe(retained)
|
||||
}),
|
||||
@@ -112,8 +114,8 @@ describe("ToolOutput", () => {
|
||||
withStore(
|
||||
(output) =>
|
||||
Effect.gen(function* () {
|
||||
expect(yield* output.truncate({ content: "one\ntwo\n" })).toEqual({
|
||||
content: "one\ntwo\n",
|
||||
expect(yield* output.truncate({ content: [{ type: "text", text: "one\ntwo\n" }] })).toEqual({
|
||||
content: [{ type: "text", text: "one\ntwo\n" }],
|
||||
metadata: { truncated: false },
|
||||
})
|
||||
}),
|
||||
@@ -125,7 +127,7 @@ describe("ToolOutput", () => {
|
||||
withStore(
|
||||
(output) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* output.truncate({ content: "one\n" })
|
||||
const result = yield* output.truncate({ content: [{ type: "text", text: "one\n" }] })
|
||||
expect(result.content).toEqual([
|
||||
{ type: "text", text: "one" },
|
||||
{ type: "text", text: expect.stringMatching(/^\.\.\. 1 byte truncated; full content saved to /) },
|
||||
|
||||
@@ -3,11 +3,13 @@ import { Agent } from "@opencode-ai/core/agent"
|
||||
import type { Permission } from "@opencode-ai/core/permission"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Image } from "@opencode-ai/core/image"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { State } from "@opencode-ai/core/state"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import type { Info } from "@opencode-ai/schema/tool"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { codeModeListings, executeTool, toolDefinitions } from "./lib/tool"
|
||||
import { Deferred, Effect, Exit, Fiber, Layer, Logger, Schema, SchemaGetter, SchemaIssue, Scope } from "effect"
|
||||
import { z } from "zod"
|
||||
@@ -35,7 +37,9 @@ const imageStore = Layer.mock(Image.Service, {
|
||||
})
|
||||
},
|
||||
})
|
||||
const registryLayer = AppNodeBuilder.build(Tool.node, [Image.node.replace(imageStore)])
|
||||
const registryLayer = AppNodeBuilder.build(LayerNode.group([Tool.node, PluginHooks.node]), [
|
||||
Image.node.replace(imageStore),
|
||||
])
|
||||
const it = testEffect(registryLayer)
|
||||
const identity = {
|
||||
agent: Agent.ID.make("build"),
|
||||
@@ -843,6 +847,36 @@ describe("Tool", () => {
|
||||
])
|
||||
}),
|
||||
)
|
||||
;[
|
||||
{ name: "string", content: "hooked", text: "hooked" },
|
||||
{ name: "empty string", content: "", text: "" },
|
||||
{ name: "missing content", content: undefined, text: '{"text":"hooked"}' },
|
||||
{ name: "empty content array", content: [], text: '{"text":"hooked"}' },
|
||||
].forEach((input) => {
|
||||
it.effect(`normalizes ${input.name} after the final tool hook`, () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
yield* transform(service, { echo: make() }, { codemode: false })
|
||||
yield* hooks.register("tool", "execute.after", (event) =>
|
||||
Effect.sync(() => {
|
||||
if (event.status !== "completed") return
|
||||
event.result = {
|
||||
output: { text: "hooked" },
|
||||
content: input.content,
|
||||
metadata: { source: "hook" },
|
||||
}
|
||||
}),
|
||||
)
|
||||
const snapshot = yield* service.snapshot()
|
||||
expect(yield* snapshot.execute(call("echo"))).toEqual({
|
||||
output: { text: "hooked" },
|
||||
content: [{ type: "text", text: input.text }],
|
||||
metadata: { source: "hook" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("normalizes image tool output once and drops unresizable images", () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -40,6 +40,7 @@ import { ToolOutput } from "@opencode-ai/core/tool-output"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { tmpdir, tmpdirScoped } from "./fixture/tmpdir"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { offlineModels } from "./fixture/models"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
import { Expected } from "./lib/session-message"
|
||||
@@ -155,6 +156,7 @@ const replacements = [
|
||||
SessionExecution.node.replace(executionNode),
|
||||
Permission.node.replace(permission),
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
offlineModels,
|
||||
] satisfies LayerNode.Replacements
|
||||
const productionIt = testEffect(AppNodeBuilder.build(nodes, replacements))
|
||||
const it = testEffect(
|
||||
@@ -165,6 +167,7 @@ const permissionIt = testEffect(
|
||||
SessionExecution.node.replace(executionNode),
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
PluginSupervisor.node.replace(shellPluginSupervisor),
|
||||
offlineModels,
|
||||
]),
|
||||
)
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import { SubagentTool } from "@opencode-ai/core/tool/plugin/subagent"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { offlineModels } from "./fixture/models"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
|
||||
|
||||
@@ -120,6 +121,7 @@ const nodes = LayerNode.group([
|
||||
const replacements = [
|
||||
SessionExecution.node.replace(executionNode),
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
offlineModels,
|
||||
] satisfies LayerNode.Replacements
|
||||
const productionIt = testEffect(AppNodeBuilder.build(nodes, replacements))
|
||||
const it = testEffect(
|
||||
@@ -128,6 +130,7 @@ const it = testEffect(
|
||||
const completionIt = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([nodes, SessionRestart.node, KV.node]), [
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
offlineModels,
|
||||
PluginSupervisor.node.replace(subagentPluginSupervisor),
|
||||
LayerNodePlatform.llmClient.replace(TestLLM.testLayer({ fallback: TestLLM.text(childText, "completion") })),
|
||||
SessionRunnerModel.node.replace(
|
||||
|
||||
@@ -65,7 +65,7 @@ const session = (
|
||||
idle_outcome: null,
|
||||
time_compacting: 3,
|
||||
time_archived: null,
|
||||
time_suspended: null,
|
||||
execution_claimed_at: null,
|
||||
resume_attempts: 0,
|
||||
...overrides,
|
||||
})
|
||||
@@ -870,6 +870,7 @@ describe("V1Migration database workflow", () => {
|
||||
('msg_next', 'ses_next', 'user', 4, 12, 13, '{"text":"from next''s history","time":{"created":12}}'),
|
||||
('msg_source_existing', 'ses_existing', 'user', 2, 12, 13, '{"text":"source","time":{"created":12}}'),
|
||||
('msg_orphan', 'ses_orphan', 'user', 0, 12, 13, '{"text":"orphan","time":{"created":12}}');
|
||||
UPDATE session SET time_suspended = 1234 WHERE id = 'ses_next';
|
||||
`)
|
||||
source.close()
|
||||
|
||||
@@ -901,6 +902,9 @@ describe("V1Migration database workflow", () => {
|
||||
agent: "build",
|
||||
model: '{"id":"model","providerID":"provider"}',
|
||||
})
|
||||
expect(
|
||||
yield* db.get(sql`SELECT execution_claimed_at, resume_attempts FROM session_v2 WHERE id = 'ses_next'`),
|
||||
).toEqual({ execution_claimed_at: 1234, resume_attempts: 0 })
|
||||
expect(
|
||||
yield* db
|
||||
.select({ directory: SessionTable.directory })
|
||||
@@ -988,8 +992,8 @@ describe("V1Migration database workflow", () => {
|
||||
const database = yield* Database.Service
|
||||
expect(yield* V1Migration.run({ nextDatabasePath: filename })).toEqual({ status: "completed" })
|
||||
expect(
|
||||
yield* database.db.get(sql`SELECT fork_boundary, time_suspended FROM session_v2 WHERE id = 'ses_next'`),
|
||||
).toEqual({ fork_boundary: null, time_suspended: null })
|
||||
yield* database.db.get(sql`SELECT fork_boundary, execution_claimed_at FROM session_v2 WHERE id = 'ses_next'`),
|
||||
).toEqual({ fork_boundary: null, execution_claimed_at: null })
|
||||
expect(
|
||||
yield* database.db.get(
|
||||
sql`SELECT icon_url, icon_url_override, icon_color, commands FROM project WHERE id = 'next-project'`,
|
||||
|
||||
@@ -10,8 +10,6 @@ import type {
|
||||
} from "@opencode-ai/app/wsl/types"
|
||||
import { Effect } from "effect"
|
||||
import { nativeT } from "../native/translations"
|
||||
import { WSL_SERVERS_KEY } from "../storage/keys"
|
||||
import { getStore } from "../storage/store"
|
||||
import {
|
||||
installWslRuntimeElevated,
|
||||
listInstalledWslDistros,
|
||||
@@ -38,8 +36,8 @@ type WslServersControllerOptions = {
|
||||
spawnSidecar: SpawnSidecar
|
||||
installCli: (distro: string, cli: WslCliBuild) => Promise<void>
|
||||
installDistro: (distro: string) => Promise<void>
|
||||
readServers?: () => WslServerConfig[]
|
||||
writeServers?: (servers: WslServerConfig[]) => void
|
||||
readServers: () => WslServerConfig[]
|
||||
writeServers: (servers: WslServerConfig[]) => void
|
||||
probeDistro?: typeof probeWslDistro
|
||||
resolveCli?: typeof resolveWslCli
|
||||
readCliVersion?: typeof readWslCliVersion
|
||||
@@ -60,8 +58,6 @@ export const createWslServersController = Effect.fn("WslServers.make")(function*
|
||||
const sidecars = new Map<string, RunningSidecar>()
|
||||
const starts = new Map<string, symbol>()
|
||||
let closed = false
|
||||
const readServers = options.readServers ?? readPersistedServers
|
||||
const writeServers = options.writeServers ?? writePersistedServers
|
||||
const probeDistro = options.probeDistro ?? probeWslDistro
|
||||
|
||||
const emit = () => {
|
||||
@@ -79,7 +75,7 @@ export const createWslServersController = Effect.fn("WslServers.make")(function*
|
||||
}
|
||||
|
||||
const refreshFromStore = () => {
|
||||
const persisted = readServers()
|
||||
const persisted = options.readServers()
|
||||
const items: WslServerItem[] = persisted.map((config) => {
|
||||
const existing = state.servers.find((item) => item.config.id === config.id)
|
||||
return {
|
||||
@@ -291,7 +287,7 @@ export const createWslServersController = Effect.fn("WslServers.make")(function*
|
||||
id,
|
||||
distro,
|
||||
}
|
||||
writeServers([...readServers(), config])
|
||||
options.writeServers([...options.readServers(), config])
|
||||
setState({
|
||||
servers: [...state.servers, { config, runtime: { kind: "starting" } }],
|
||||
})
|
||||
@@ -302,8 +298,8 @@ export const createWslServersController = Effect.fn("WslServers.make")(function*
|
||||
async removeServer(id: string) {
|
||||
const distro = state.servers.find((item) => item.config.id === id)?.config.distro
|
||||
await stopServer(id)
|
||||
const remaining = readServers().filter((item) => item.id !== id)
|
||||
writeServers(remaining)
|
||||
const remaining = options.readServers().filter((item) => item.id !== id)
|
||||
options.writeServers(remaining)
|
||||
setState({
|
||||
servers: state.servers.filter((item) => item.config.id !== id),
|
||||
...(distro ? removeDistroState(state, distro) : {}),
|
||||
@@ -334,35 +330,6 @@ function initialState(): WslServersState {
|
||||
}
|
||||
}
|
||||
|
||||
function readPersistedServers(): WslServerConfig[] {
|
||||
const store = getStore()
|
||||
const existing = store.get(WSL_SERVERS_KEY)
|
||||
if (existing && typeof existing === "object") {
|
||||
const record = existing as { servers?: unknown }
|
||||
const list = Array.isArray(record.servers) ? record.servers : []
|
||||
return list.flatMap(normalizePersistedServer)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function writePersistedServers(servers: WslServerConfig[]) {
|
||||
getStore().set(WSL_SERVERS_KEY, { servers })
|
||||
}
|
||||
|
||||
function normalizePersistedServer(value: unknown): WslServerConfig[] {
|
||||
if (!value || typeof value !== "object") return []
|
||||
const record = value as Record<string, unknown>
|
||||
const distro = typeof record.distro === "string" && record.distro.length > 0 ? record.distro : null
|
||||
if (!distro) return []
|
||||
const id = typeof record.id === "string" && record.id.length > 0 ? record.id : wslServerIdForDistro(distro)
|
||||
return [
|
||||
{
|
||||
id,
|
||||
distro,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function cliCheck(
|
||||
distro: string,
|
||||
resolvedPath: string | null,
|
||||
|
||||
@@ -3,6 +3,7 @@ export * as Wsl from "./start"
|
||||
import { Context, Effect, Exit, FileSystem, Layer, Path } from "effect"
|
||||
import { Shutdown } from "../lifecycle/shutdown"
|
||||
import { DesktopCli } from "../service/desktop-cli"
|
||||
import { WSL_SERVERS_KEY } from "../storage/keys"
|
||||
import { WslIpc } from "./ipc"
|
||||
|
||||
export interface Interface extends WslIpc.Interface {
|
||||
@@ -27,7 +28,8 @@ export const layer = Layer.effect(
|
||||
const makeWsl = Effect.fn("Wsl.make")(function* (cli: DesktopCli.Resolved) {
|
||||
if (process.platform !== "win32") return { ...WslIpc.create(), stop: Effect.void }
|
||||
|
||||
const { createWslServersController } = yield* Effect.promise(() => import("./servers"))
|
||||
const { createWslServersController, wslServerIdForDistro } = yield* Effect.promise(() => import("./servers"))
|
||||
const { getStore } = yield* Effect.promise(() => import("../storage/store"))
|
||||
const { spawnWslSidecar } = yield* Effect.promise(() => import("./sidecar"))
|
||||
const { installWslCli, installWslDistro } = yield* Effect.promise(() => import("./runtime"))
|
||||
const context = yield* Effect.context<FileSystem.FileSystem | Path.Path>()
|
||||
@@ -36,6 +38,21 @@ const makeWsl = Effect.fn("Wsl.make")(function* (cli: DesktopCli.Resolved) {
|
||||
const local = cli.wslBuild
|
||||
const controller = yield* createWslServersController({
|
||||
cli: { version: cli.version },
|
||||
readServers: () => {
|
||||
const existing = getStore().get(WSL_SERVERS_KEY)
|
||||
if (!existing || typeof existing !== "object") return []
|
||||
const record = existing as { servers?: unknown }
|
||||
const list = Array.isArray(record.servers) ? record.servers : []
|
||||
return list.flatMap((value: unknown) => {
|
||||
if (!value || typeof value !== "object") return []
|
||||
const record = value as Record<string, unknown>
|
||||
const distro = typeof record.distro === "string" && record.distro.length > 0 ? record.distro : null
|
||||
if (!distro) return []
|
||||
const id = typeof record.id === "string" && record.id.length > 0 ? record.id : wslServerIdForDistro(distro)
|
||||
return [{ id, distro }]
|
||||
})
|
||||
},
|
||||
writeServers: (servers) => getStore().set(WSL_SERVERS_KEY, { servers }),
|
||||
installDistro: (distro) => run(installWslDistro(distro)),
|
||||
installCli: local
|
||||
? async (distro) => {
|
||||
|
||||
@@ -11,6 +11,7 @@ export const startServer = Effect.fnUntraced(function* (directory: string) {
|
||||
database: { path: ":memory:" },
|
||||
config: { directory },
|
||||
fs: { filewatcher: false },
|
||||
models: { fetch: false },
|
||||
})
|
||||
return {
|
||||
base: HttpServer.formatAddress(server.address),
|
||||
|
||||
@@ -47,6 +47,7 @@ it.live("uses base configuration without depending on process.cwd()", () =>
|
||||
database: { path: ":memory:" },
|
||||
config: { directory: global },
|
||||
fs: { filewatcher: false },
|
||||
models: { fetch: false },
|
||||
},
|
||||
{ overrides: [Generate.node.replace(generate)] },
|
||||
)
|
||||
|
||||
@@ -195,7 +195,8 @@ it.live(
|
||||
expect(yield* Effect.promise<unknown>(() => response.json())).toEqual({ data: [] })
|
||||
}
|
||||
}
|
||||
expect(boots).toEqual([])
|
||||
// Reading permissions or forms builds the Session's Instance and starts its plugin activation at once; the
|
||||
// ordering assertion after the prompts is what proves each Session boots exactly once.
|
||||
|
||||
for (const config of configs) {
|
||||
const session = yield* sessions.get(config.id)
|
||||
|
||||
@@ -55,7 +55,12 @@ it.live("updates completed assistant message content through the session HTTP AP
|
||||
}),
|
||||
)
|
||||
const handler = yield* ServerFetch.make(
|
||||
{ app: { version: "test-version" }, database: { path: ":memory:" }, fs: { filewatcher: false } },
|
||||
{
|
||||
app: { version: "test-version" },
|
||||
database: { path: ":memory:" },
|
||||
fs: { filewatcher: false },
|
||||
models: { fetch: false },
|
||||
},
|
||||
{
|
||||
overrides: [
|
||||
SessionExecution.node.replace(
|
||||
|
||||
@@ -81,7 +81,12 @@ it.live("maps a failing base provider to HTTP 503 instead of null metadata", ()
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-vcs-failure-")))
|
||||
const handler = yield* ServerFetch.make(
|
||||
{ database: { path: ":memory:" }, config: { directory: tmp.path }, fs: { filewatcher: false } },
|
||||
{
|
||||
database: { path: ":memory:" },
|
||||
config: { directory: tmp.path },
|
||||
fs: { filewatcher: false },
|
||||
models: { fetch: false },
|
||||
},
|
||||
{
|
||||
overrides: [
|
||||
SdkPlugins.node.replace(
|
||||
|
||||
@@ -12,7 +12,7 @@ story("merges follow-up patches into one stack with a distinct file count", asyn
|
||||
await root.getByRole("button", { name: "Start follow-up patch" }).click()
|
||||
await expect(
|
||||
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
|
||||
).toHaveText("1 Shell, 2 Patch")
|
||||
).toHaveText(/^3 /)
|
||||
await expect(patches).toHaveCount(1)
|
||||
await expect(patches.getByText("2 files", { exact: true })).toBeVisible()
|
||||
await root.getByRole("button", { name: "Finish follow-up patch" }).click()
|
||||
|
||||
@@ -9,7 +9,7 @@ const png = Buffer.from(
|
||||
|
||||
story.beforeEach(async ({ mount }) => {
|
||||
const root = await mount("current-tool-group--mixed-tools")
|
||||
await expect(root.getByRole("button", { name: "Used 1 Shell, 1 Read, 2 Agents", exact: true })).toBeVisible()
|
||||
await expect(root.getByRole("button", { name: "Used 4 Shell, Read, Agent", exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
for (const grouped of [true, false]) {
|
||||
|
||||
@@ -19,7 +19,7 @@ for (const tool of ["shell", "execute", "subagent"]) {
|
||||
await expect(group).toHaveAttribute("data-timeline-part-ids", "tool_context_lifecycle,tool_shell_lifecycle")
|
||||
await expect(
|
||||
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
|
||||
).toHaveText(`1 Read, 1 ${tool === "subagent" ? "Agent" : tool[0]!.toUpperCase() + tool.slice(1)}`)
|
||||
).toHaveText(/^2 /)
|
||||
await expect(timeline.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(1)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", String(open))
|
||||
expect(await original!.evaluate((node) => node.isConnected)).toBe(true)
|
||||
@@ -187,10 +187,10 @@ for (const locale of ["de", "ar"] as const) {
|
||||
await timeline.getByRole("button", { name: "Complete read" }).click()
|
||||
await timeline.getByRole("button", { name: "Complete glob" }).click()
|
||||
const group = timeline.locator('[data-timeline-part-ids="tool_context_read,tool_context_glob"]')
|
||||
await expect(group.getByRole("button")).toHaveAccessibleName(/^Used 1 .+, 1 /)
|
||||
await expect(group.getByRole("button")).toHaveAccessibleName(/^Used 2 /)
|
||||
await expect(
|
||||
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
|
||||
).toHaveText(/^1 .+, 1 /)
|
||||
).toHaveText(/^2 /)
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", locale)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ story("renders every tool error outcome without leaking hidden tools", async ({
|
||||
const group = timeline.locator(`[data-timeline-part-ids="${names.map((name) => `tool_error_${name}`).join(",")}"]`)
|
||||
await expect(
|
||||
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
|
||||
).toHaveText("1 Shell, 1 Edit, 1 Write, 1 Patch, 1 Webfetch, 1 Web Search, 1 Agent, 1 Skill, 1 mcp_probe")
|
||||
).toHaveText(new RegExp(`^${names.length} `))
|
||||
await group.getByRole("button").click()
|
||||
await expect(timeline.locator('[data-kind="tool-error-card"]')).toHaveCount(names.length + 1)
|
||||
const dismissed = timeline.locator('[data-timeline-part-id="tool_error_question_dismissed"]')
|
||||
@@ -70,7 +70,7 @@ story("transitions shell and question through running error outcomes", async ({
|
||||
// Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts
|
||||
story("labels all web search provider variants", async ({ mount }) => {
|
||||
const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "providers" } })
|
||||
await timeline.getByRole("button", { name: "Used 1 Parallel Web Search, 1 Exa Web Search, 1 Web Search" }).click()
|
||||
await timeline.getByRole("button", { name: "Used 3 Parallel Web Search, Exa Web Search, Web Search" }).click()
|
||||
const tools = timeline.locator('[data-component="context-tool-group-list"]')
|
||||
await expect(tools.getByRole("button", { name: /Parallel Web Search/ })).toBeVisible()
|
||||
await expect(tools.getByRole("button", { name: /Exa Web Search/ })).toBeVisible()
|
||||
@@ -102,11 +102,11 @@ story("labels read tools from their path input", async ({ mount }) => {
|
||||
// Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts
|
||||
story("labels skill tools from IDs and result metadata", async ({ mount }) => {
|
||||
const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "skills" } })
|
||||
const group = timeline.locator('[data-timeline-part-ids="tool_skill_id,tool_skill_name,tool_skill_agent"]')
|
||||
await expect(group.getByRole("button")).toHaveAccessibleName("Used 2 Skills, 1 Agent")
|
||||
const group = timeline.locator('[data-timeline-part-ids="tool_skill_id,tool_skill_name"]')
|
||||
await expect(group.getByRole("button")).toHaveAccessibleName("Used 2 Skill")
|
||||
await expect(
|
||||
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
|
||||
).toHaveText("2 Skills, 1 Agent")
|
||||
).toHaveText("2 Skill")
|
||||
await group.getByRole("button").click()
|
||||
const loaded = group.locator('[data-component="tool-loaded-item"]')
|
||||
await expect(loaded).toHaveCount(1)
|
||||
@@ -128,10 +128,10 @@ story("groups every collapsed tool until visible text separates the stack", asyn
|
||||
'[data-timeline-part-ids="tool_boundary_glob,tool_boundary_grep,tool_boundary_shell,tool_boundary_list"]',
|
||||
)
|
||||
await expect(group).toBeVisible()
|
||||
await expect(group.getByRole("button")).toHaveAccessibleName("Used 1 Glob, 1 Grep, 1 Shell, 1 List")
|
||||
await expect(group.getByRole("button")).toHaveAccessibleName("Used 4 Glob, Grep, Shell, List")
|
||||
await expect(
|
||||
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
|
||||
).toHaveText("1 Glob, 1 Grep, 1 Shell, 1 List")
|
||||
).toHaveText("4 Glob, Grep, Shell, List")
|
||||
await expect(timeline.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(3)
|
||||
await expect(timeline.locator('[data-timeline-spacing="content"]')).toHaveCount(2)
|
||||
await expect(timeline.locator('[data-timeline-spacing="content"]').nth(0)).toHaveCSS("padding-top", "16px")
|
||||
|
||||
@@ -6,8 +6,8 @@ for (const open of [true, false]) {
|
||||
async ({ mount }, info) => {
|
||||
const root = await mount("current-session-file-changes--appending-tool-calls")
|
||||
const group = root.locator('[data-component="collapsed-tool-group"]')
|
||||
const trigger = group.getByRole("button", { name: /^Used 1 Shell, \d+ Patch$/ })
|
||||
await expect(trigger).toHaveAccessibleName("Used 1 Shell, 1 Patch")
|
||||
const trigger = group.getByRole("button", { name: /^Used \d+ Shell, Patch$/ })
|
||||
await expect(trigger).toHaveAccessibleName("Used 2 Shell, Patch")
|
||||
await trigger.click()
|
||||
const shell = group.locator('[data-timeline-part-id="tool_shell_existing"] [data-slot="collapsible-trigger"]')
|
||||
await group.locator('[data-timeline-part-id="tool_patch_existing"]').evaluate((element) => {
|
||||
@@ -33,8 +33,8 @@ for (const open of [true, false]) {
|
||||
await root.getByRole("button", { name: "Append tool call", exact: true }).click()
|
||||
await expect(
|
||||
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
|
||||
).toHaveText(`1 Shell, ${count - 1} Patch`)
|
||||
await expect(trigger).toHaveAccessibleName(`Used 1 Shell, ${count - 1} Patch`)
|
||||
).toHaveText(`${count} Shell, Patch`)
|
||||
await expect(trigger).toHaveAccessibleName(`Used ${count} Shell, Patch`)
|
||||
await expect(diff).toBeVisible()
|
||||
await root
|
||||
.locator('[data-component="session-timeline"]')
|
||||
|
||||
@@ -6,14 +6,14 @@ for (const reasoningDefaultOpen of [false, true]) {
|
||||
async ({ mount }) => {
|
||||
const root = await mount("current-tool-group--mixed-reasoning", { args: { reasoningDefaultOpen } })
|
||||
const group = root.locator('[data-component="collapsed-tool-group"]')
|
||||
const used = group.getByRole("button", { name: /^Used \d+ Read, \d+ Skills?$/ })
|
||||
const used = group.getByRole("button", { name: /^Used \d+ Read, 3 Skill$/ })
|
||||
const first = group.locator('[data-timeline-part-id="reasoning_first"]')
|
||||
const second = group.locator('[data-timeline-part-id="reasoning_second"]')
|
||||
await expect(used).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(used).toHaveAccessibleName("Used 1 Read, 3 Skills")
|
||||
await expect(used).toHaveAccessibleName("Used 1 Read, 3 Skill")
|
||||
await expect(
|
||||
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
|
||||
).toHaveText("1 Read, 3 Skills")
|
||||
).toHaveText("1 Read, 3 Skill")
|
||||
await expect(group.locator('[data-slot="context-tool-group-item"]')).toHaveText([
|
||||
/Read.*group\.ts/,
|
||||
/Thought/,
|
||||
@@ -34,10 +34,10 @@ for (const reasoningDefaultOpen of [false, true]) {
|
||||
)
|
||||
await first.getByRole("button", { name: "Thought", exact: true }).click()
|
||||
await root.getByRole("button", { name: "Append follow-up read", exact: true }).click()
|
||||
await expect(used).toHaveAccessibleName("Used 2 Read, 3 Skills")
|
||||
await expect(used).toHaveAccessibleName("Used 2 Read, 3 Skill")
|
||||
await expect(
|
||||
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
|
||||
).toHaveText("2 Read, 3 Skills")
|
||||
).toHaveText("2 Read, 3 Skill")
|
||||
await expect(group.locator('[data-slot="context-tool-group-item"]')).toHaveText([
|
||||
/Read.*group\.ts/,
|
||||
/Thought/,
|
||||
@@ -70,10 +70,10 @@ for (const reasoningDefaultOpen of [false, true]) {
|
||||
story("summarizes subagents as Agent while retaining their card titles", async ({ mount }) => {
|
||||
const root = await mount("current-tool-group--mixed-tools")
|
||||
const group = root.locator('[data-component="collapsed-tool-group"]')
|
||||
await expect(group.getByRole("button", { name: "Used 1 Shell, 1 Read, 2 Agents", exact: true })).toBeVisible()
|
||||
await expect(group.getByRole("button", { name: "Used 1 Shell, 1 Read, 2 Agent", exact: true })).toBeVisible()
|
||||
await expect(
|
||||
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
|
||||
).toHaveText("1 Shell, 1 Read, 2 Agents")
|
||||
).toHaveText("1 Shell, 1 Read, 2 Agent")
|
||||
const gap = await group.evaluate((element) => {
|
||||
const title = element.querySelector('[data-component="context-tool-group-trigger"]')!.getBoundingClientRect()
|
||||
const arrow = element.querySelector('[data-slot="collapsible-arrow-icon"]')!.getBoundingClientRect()
|
||||
@@ -88,9 +88,9 @@ for (const width of [840, 390]) {
|
||||
await page.setViewportSize({ width, height: 600 })
|
||||
const root = await mount("current-tool-group--mixed-tools")
|
||||
const group = root.locator('[data-component="collapsed-tool-group"]')
|
||||
const trigger = group.getByRole("button", { name: "Used 1 Shell, 1 Read, 2 Agents", exact: true })
|
||||
const trigger = group.getByRole("button", { name: "Used 1 Shell, 1 Read, 2 Agent", exact: true })
|
||||
const header = group.locator('[data-component="context-tool-group-trigger"]')
|
||||
await expect(header.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Shell, 1 Read, 2 Agents")
|
||||
await expect(header.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Shell, 1 Read, 2 Agent")
|
||||
await expect(header.locator('[data-component="tag"]')).toHaveCount(0)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
for (const action of ["click", "Enter", "Space"] as const) {
|
||||
|
||||
@@ -193,22 +193,8 @@ const LoadingSpecializedSkills = {
|
||||
title="Loading specialized skills"
|
||||
description="Active and completed skills display their identifier or resolved name."
|
||||
document={storyDocument([
|
||||
storyTool("tool_skill_id", "skill", "completed", { id: "frontend-design" }),
|
||||
storyTool("tool_skill_id", "skill", "running", { id: "frontend-design" }),
|
||||
storyTool("tool_skill_name", "skill", "completed", { id: "opencode" }, { metadata: { name: "OpenCode" } }),
|
||||
storyTool(
|
||||
"tool_skill_agent",
|
||||
"subagent",
|
||||
"completed",
|
||||
{
|
||||
description: "Review skill usage",
|
||||
agent: "explore",
|
||||
prompt: "Review the loaded skills.",
|
||||
},
|
||||
{
|
||||
output: "The loaded skills are ready.",
|
||||
metadata: { sessionID: "session_child_review", status: "completed" },
|
||||
},
|
||||
),
|
||||
])}
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -507,20 +507,11 @@ export function CurrentContextToolGroup(props: {
|
||||
: tool.name === "subagent"
|
||||
? i18n.t("ui.tool.agent.default")
|
||||
: getToolInfo(tool.name, input, currentToolMetadata(tool)).title
|
||||
const current = counts.get(name)
|
||||
counts.set(name, {
|
||||
count: (current?.count ?? 0) + 1,
|
||||
plural:
|
||||
tool.name === "skill"
|
||||
? "ui.messagePart.tools.skill"
|
||||
: tool.name === "subagent"
|
||||
? "ui.messagePart.tools.agent"
|
||||
: undefined,
|
||||
})
|
||||
counts.set(name, (counts.get(name) ?? 0) + 1)
|
||||
return counts
|
||||
}, new Map<string, { count: number; plural?: "ui.messagePart.tools.skill" | "ui.messagePart.tools.agent" }>()),
|
||||
}, new Map<string, number>()),
|
||||
]
|
||||
.map(([name, item]) => (item.plural ? i18n.plural(item.plural, item.count) : `${item.count} ${name}`))
|
||||
.map(([name, count]) => `${count} ${name}`)
|
||||
.join(", "),
|
||||
)
|
||||
const label = createMemo(() => {
|
||||
|
||||
@@ -1129,6 +1129,10 @@ test("removes committed revert messages from local state", async () => {
|
||||
expect(data.session.message.list(sessionID).map((message) => message.id)).toEqual(["msg_001"])
|
||||
expect(data.session.message.get(sessionID, "msg_002")).toBeUndefined()
|
||||
expect(data.session.message.get(sessionID, "msg_003")).toBeUndefined()
|
||||
// The projector also drops inbox items enqueued at or after the boundary, without a cancel event.
|
||||
expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual(["msg_001"])
|
||||
expect(data.session.input.list(sessionID)).toEqual(["msg_001"])
|
||||
expect(data.session.input.has(sessionID, "msg_002")).toBe(false)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
|
||||
@@ -111,10 +111,6 @@ const source = {
|
||||
"ui.messagePart.context.match.one": "({{count}} match)",
|
||||
"ui.messagePart.context.match.other": "({{count}} matches)",
|
||||
"ui.messagePart.tools.used": "Used {{tools}}",
|
||||
"ui.messagePart.tools.skill.one": "{{count}} Skill",
|
||||
"ui.messagePart.tools.skill.other": "{{count}} Skills",
|
||||
"ui.messagePart.tools.agent.one": "{{count}} Agent",
|
||||
"ui.messagePart.tools.agent.other": "{{count}} Agents",
|
||||
|
||||
"ui.list.loading": "Loading",
|
||||
"ui.list.empty": "No results",
|
||||
|
||||
+19
-10
@@ -31,16 +31,25 @@ export namespace UpdateArtifact {
|
||||
if (!isRecord(token) || typeof token.value !== "string")
|
||||
throw new Error("GitHub OIDC response did not include a token")
|
||||
|
||||
const response = await fetch("https://update.opencode.ai/api/publish", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.value}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(artifact),
|
||||
})
|
||||
if (response.ok) return
|
||||
throw new Error(`Failed to publish update artifact: ${response.status} ${await response.text()}`)
|
||||
for (const attempt of [0, 1, 2]) {
|
||||
const response = await fetch("https://update.opencode.ai/api/publish", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.value}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(artifact),
|
||||
})
|
||||
if (response.ok) return
|
||||
// D1 can commit the upsert and still time out. Replaying the same artifact is safe.
|
||||
if (response.status >= 500 && attempt < 2) {
|
||||
await response.arrayBuffer()
|
||||
console.warn(`Update artifact publication returned ${response.status}; retrying`)
|
||||
await Bun.sleep((attempt + 1) * 1_000)
|
||||
continue
|
||||
}
|
||||
throw new Error(`Failed to publish update artifact: ${response.status} ${await response.text()}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function desktopMetadata(version: string, repo: string) {
|
||||
|
||||
Reference in New Issue
Block a user