feat(voice): harden controller runtime
This commit is contained in:
parent
1d772f4896
commit
4f235fc59b
44 changed files with 6852 additions and 951 deletions
248
packages/voice/test/opencode.test.ts
Normal file
248
packages/voice/test/opencode.test.ts
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { OpenCode } from "@opencode-ai/client/promise"
|
||||
import { createOpenCodeBridge } from "../src/opencode"
|
||||
import type { CompletionStore } from "../src/completion-store"
|
||||
|
||||
test("renames only a session discovered by the voice controller", async () => {
|
||||
const requests: Request[] = []
|
||||
const session = {
|
||||
id: "ses_known",
|
||||
projectID: "project-1",
|
||||
cost: "0",
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 2 },
|
||||
title: "Old title",
|
||||
location: { directory: "/workspace" },
|
||||
}
|
||||
const fetch = Object.assign(
|
||||
async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const request = input instanceof Request ? input : new Request(input.toString(), init)
|
||||
requests.push(request)
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/api/event") return new Response("", { headers: { "content-type": "text/event-stream" } })
|
||||
if (url.pathname === "/api/project")
|
||||
return Response.json([
|
||||
{
|
||||
id: "project-1",
|
||||
worktree: "/workspace",
|
||||
time: { created: 1, updated: 2 },
|
||||
sandboxes: [],
|
||||
},
|
||||
])
|
||||
if (url.pathname === "/api/session" && request.method === "GET")
|
||||
return Response.json({ data: [session], cursor: {} })
|
||||
if (url.pathname === "/api/session/ses_known" && request.method === "GET") return Response.json({ data: session })
|
||||
if (url.pathname === "/api/session/ses_known/rename" && request.method === "POST")
|
||||
return new Response(null, { status: 204 })
|
||||
return new Response("Not found", { status: 404 })
|
||||
},
|
||||
{ preconnect: () => {} },
|
||||
)
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "https://opencode.test",
|
||||
fetch,
|
||||
})
|
||||
const bridge = await createOpenCodeBridge({
|
||||
client,
|
||||
directory: "/workspace",
|
||||
model: { providerID: "openai", id: "gpt-test" },
|
||||
notify: () => {},
|
||||
completionStore: memoryCompletionStore(),
|
||||
})
|
||||
const execute = async (name: string, input: Record<string, unknown>) => (await bridge.execute(name, input)).output
|
||||
|
||||
try {
|
||||
expect(new Set(bridge.definitions.map((tool) => tool.name)).size).toBe(bridge.definitions.length)
|
||||
expect(bridge.definitions.every((tool) => tool.name.length > 0 && tool.description.length > 0)).toBe(true)
|
||||
expect(bridge.definitions.find((tool) => tool.name === "reply_form")).toMatchObject({
|
||||
parameters: {
|
||||
properties: { answer: { type: "object", additionalProperties: true } },
|
||||
},
|
||||
})
|
||||
expect(bridge.definitions.find((tool) => tool.name === "rename_session")).toMatchObject({
|
||||
description: expect.stringContaining("does not prompt, wake, or interrupt"),
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["session_id", "title"],
|
||||
},
|
||||
})
|
||||
|
||||
expect(await execute("rename_session", { session_id: "ses_unknown", title: "Nope" })).toEqual({
|
||||
status: "error",
|
||||
message: "Use a session ID returned by find_sessions or start_session.",
|
||||
retryable: false,
|
||||
})
|
||||
expect(requests.some((request) => new URL(request.url).pathname.includes("ses_unknown"))).toBe(false)
|
||||
|
||||
await execute("find_sessions", {
|
||||
query: null,
|
||||
scope: "current_project",
|
||||
recency: "any",
|
||||
limit: 10,
|
||||
})
|
||||
expect(await execute("rename_session", { session_id: "ses_known", title: " " })).toEqual({
|
||||
status: "error",
|
||||
message: "A non-empty session title is required.",
|
||||
retryable: false,
|
||||
})
|
||||
expect(await execute("rename_session", { session_id: "ses_known", title: "Old title" })).toEqual({
|
||||
status: "unchanged",
|
||||
session_id: "ses_known",
|
||||
previous_title: "Old title",
|
||||
title: "Old title",
|
||||
})
|
||||
expect(requests.some((request) => new URL(request.url).pathname.endsWith("/rename"))).toBe(false)
|
||||
|
||||
expect(await execute("rename_session", { session_id: "ses_known", title: " New title " })).toEqual({
|
||||
status: "renamed",
|
||||
session_id: "ses_known",
|
||||
previous_title: "Old title",
|
||||
title: "New title",
|
||||
})
|
||||
const rename = requests.find((request) => new URL(request.url).pathname.endsWith("/rename"))
|
||||
expect(rename?.method).toBe("POST")
|
||||
expect(await rename?.json()).toEqual({ title: "New title" })
|
||||
} finally {
|
||||
await bridge.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("retries a lost completion wait and prompts a started session by ID", async () => {
|
||||
const requests: Request[] = []
|
||||
const prompts: Array<{ readonly id: string; readonly text: string }> = []
|
||||
const notifications: Array<unknown> = []
|
||||
const traces: Array<{ readonly event: string; readonly data?: Record<string, unknown> }> = []
|
||||
let waits = 0
|
||||
const fetch = Object.assign(
|
||||
async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const request = input instanceof Request ? input : new Request(input.toString(), init)
|
||||
requests.push(request)
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/api/event") return new Response("", { headers: { "content-type": "text/event-stream" } })
|
||||
if (url.pathname === "/api/session" && request.method === "POST")
|
||||
return Response.json({
|
||||
data: {
|
||||
id: "ses_focused",
|
||||
projectID: "project-1",
|
||||
cost: "0",
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 1 },
|
||||
title: "Focused session",
|
||||
location: { directory: "/workspace" },
|
||||
},
|
||||
})
|
||||
if (url.pathname === "/api/session/ses_focused/prompt" && request.method === "POST") {
|
||||
const body = await request.json()
|
||||
if (
|
||||
typeof body !== "object" ||
|
||||
body === null ||
|
||||
!("id" in body) ||
|
||||
typeof body.id !== "string" ||
|
||||
!("text" in body) ||
|
||||
typeof body.text !== "string"
|
||||
)
|
||||
return new Response("Invalid prompt", { status: 400 })
|
||||
prompts.push({ id: body.id, text: body.text })
|
||||
return Response.json({ data: { id: body.id, admittedSeq: prompts.length } })
|
||||
}
|
||||
if (url.pathname === "/api/session/ses_focused/wait" && request.method === "POST") {
|
||||
waits += 1
|
||||
if (waits === 1) throw new Error("connection closed")
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
if (url.pathname === "/api/session/ses_focused/message" && request.method === "GET")
|
||||
return Response.json({
|
||||
data: prompts.toReversed().map((prompt) => ({ id: prompt.id, type: "user", text: prompt.text })),
|
||||
cursor: {},
|
||||
})
|
||||
return new Response("Not found", { status: 404 })
|
||||
},
|
||||
{ preconnect: () => {} },
|
||||
)
|
||||
const bridge = await createOpenCodeBridge({
|
||||
client: OpenCode.make({ baseUrl: "https://opencode.test", fetch }),
|
||||
directory: "/workspace",
|
||||
model: { providerID: "openai", id: "gpt-test" },
|
||||
notify: (notification) => notifications.push(notification),
|
||||
trace: (event, data) => traces.push({ event, data }),
|
||||
completionStore: memoryCompletionStore(),
|
||||
})
|
||||
const execute = async (name: string, input: Record<string, unknown>) => (await bridge.execute(name, input)).output
|
||||
|
||||
try {
|
||||
expect(bridge.definitions.some((tool) => tool.name === "continue_session")).toBe(false)
|
||||
const started = await bridge.execute("start_session", { text: "First task", project_id: null })
|
||||
expect(started.output).toMatchObject({
|
||||
status: "started",
|
||||
session_id: "ses_focused",
|
||||
})
|
||||
expect(started.admittedPrompt).toEqual({ sessionID: "ses_focused", promptID: prompts[0].id })
|
||||
expect(
|
||||
await execute("prompt_session", { session_id: "ses_focused", text: "Follow-up task" }),
|
||||
).toMatchObject({
|
||||
status: "started",
|
||||
session_id: "ses_focused",
|
||||
})
|
||||
|
||||
await Bun.sleep(1_100)
|
||||
expect(prompts.map((prompt) => prompt.text)).toEqual(["First task", "Follow-up task"])
|
||||
expect(requests.filter((request) => new URL(request.url).pathname.endsWith("/wait")).length).toBeGreaterThanOrEqual(
|
||||
3,
|
||||
)
|
||||
expect(traces.some((entry) => entry.event === "opencode.wait.retrying")).toBe(true)
|
||||
expect(notifications.some((value) => JSON.stringify(value).includes("opencode.prompt.failed"))).toBe(false)
|
||||
} finally {
|
||||
await bridge.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("discards a restored prompt whose session no longer exists", async () => {
|
||||
const removed: Array<{ readonly sessionID: string; readonly promptID: string }> = []
|
||||
const handle = { sessionID: "ses_missing", promptID: "msg_orphaned" }
|
||||
const fetch = Object.assign(
|
||||
async (input: string | URL | Request) => {
|
||||
const request = input instanceof Request ? input : new Request(input.toString())
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/api/event") return new Response("", { headers: { "content-type": "text/event-stream" } })
|
||||
if (url.pathname === "/api/session/ses_missing")
|
||||
return Response.json(
|
||||
{ _tag: "SessionNotFoundError", sessionID: handle.sessionID, message: `Session not found: ${handle.sessionID}` },
|
||||
{ status: 404 },
|
||||
)
|
||||
return new Response("Not found", { status: 404 })
|
||||
},
|
||||
{ preconnect: () => {} },
|
||||
)
|
||||
const store = {
|
||||
...memoryCompletionStore(),
|
||||
entries: () => [{ status: "pending" as const, handle }],
|
||||
remove: async (entry) => {
|
||||
removed.push(entry)
|
||||
},
|
||||
} satisfies CompletionStore
|
||||
|
||||
const bridge = await createOpenCodeBridge({
|
||||
client: OpenCode.make({ baseUrl: "https://opencode.test", fetch }),
|
||||
directory: "/workspace",
|
||||
model: { providerID: "openai", id: "gpt-test" },
|
||||
notify: () => {},
|
||||
completionStore: store,
|
||||
})
|
||||
try {
|
||||
expect(removed).toEqual([handle])
|
||||
} finally {
|
||||
await bridge.close()
|
||||
}
|
||||
})
|
||||
|
||||
function memoryCompletionStore(): CompletionStore {
|
||||
return {
|
||||
entries: () => [],
|
||||
admitting: async () => {},
|
||||
pending: async () => {},
|
||||
completed: async () => {},
|
||||
remove: async () => {},
|
||||
close: async () => {},
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue