mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-11 20:19:53 -04:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a44316f9f2 | |||
| 37a8c6c916 | |||
| 23b0688a7f | |||
| d0dd49bd84 |
@@ -348,7 +348,10 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
continue
|
||||
}
|
||||
}
|
||||
messages.push({ role: "user", content })
|
||||
const previous = messages.at(-1)
|
||||
if (previous?.role === "user")
|
||||
messages[messages.length - 1] = { role: "user", content: [...previous.content, ...content] }
|
||||
else messages.push({ role: "user", content })
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -392,7 +395,10 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
const cachePoint = BedrockCache.block(breakpoints, part.cache)
|
||||
if (cachePoint) content.push(cachePoint)
|
||||
}
|
||||
messages.push({ role: "user", content })
|
||||
const previous = messages.at(-1)
|
||||
if (previous?.role === "user")
|
||||
messages[messages.length - 1] = { role: "user", content: [...previous.content, ...content] }
|
||||
else messages.push({ role: "user", content })
|
||||
}
|
||||
|
||||
return messages
|
||||
|
||||
Vendored
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:bedrock-converse",
|
||||
"provider:amazon-bedrock",
|
||||
"protocol:bedrock-converse",
|
||||
"tool",
|
||||
"tool-loop",
|
||||
"parallel"
|
||||
],
|
||||
"name": "bedrock-converse/continues-after-parallel-tool-results",
|
||||
"recordedAt": "2026-08-11T16:41:46.482Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.amazon.nova-micro-v1%3A0/converse-stream",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"modelId\":\"us.amazon.nova-micro-v1:0\",\"messages\":[{\"role\":\"user\",\"content\":[{\"text\":\"Compare the weather in Paris and London.\"}]},{\"role\":\"assistant\",\"content\":[{\"toolUse\":{\"toolUseId\":\"weather_paris\",\"name\":\"get_weather\",\"input\":{\"city\":\"Paris\"}}},{\"toolUse\":{\"toolUseId\":\"weather_london\",\"name\":\"get_weather\",\"input\":{\"city\":\"London\"}}}]},{\"role\":\"user\",\"content\":[{\"toolResult\":{\"toolUseId\":\"weather_paris\",\"content\":[{\"json\":{\"temperature\":22,\"condition\":\"sunny\"}}],\"status\":\"success\"}},{\"toolResult\":{\"toolUseId\":\"weather_london\",\"content\":[{\"json\":{\"temperature\":14,\"condition\":\"rainy\"}}],\"status\":\"success\"}}]}],\"system\":[{\"text\":\"After receiving both tool results, reply exactly: Paris is sunny; London is rainy.\"}],\"inferenceConfig\":{\"maxTokens\":40,\"temperature\":0},\"toolConfig\":{\"tools\":[{\"toolSpec\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"inputSchema\":{\"json\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}}]}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "application/vnd.amazon.eventstream"
|
||||
},
|
||||
"body": "AAAAqAAAAFKgEDvmCzpldmVudC10eXBlBwAMbWVzc2FnZVN0YXJ0DTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFEiLCJyb2xlIjoiYXNzaXN0YW50In189ig4AAAAzgAAAFfGCE2ECzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IlBhcmlzIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE1OT1BRUlNUVVYifWttETIAAADDAAAAVz6YiTULOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIGlzIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE0ifRHJ8Q0AAACqAAAAV6q6nAkLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIHN1bm55In0sInAiOiJhYmNkZWZnaGlqayJ98ZCy6gAAALAAAABXgOoTKgs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiI7In0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2In020bBKAAAAygAAAFcziOtECzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IiBMb25kb24ifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUCJ9ew04hAAAAK4AAABXXzo6yQs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIgaXMifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxciJ9yK3bdAAAALcAAABXMsrPOgs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIgcmFpbnkifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eCJ9JoCYVwAAALoAAABXyloLiws6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIuIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRiJ9WJwR8wAAAMEAAABXRFjaVQs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU4ifYzp4V0AAAChAAAAVqptnY4LOmV2ZW50LXR5cGUHABBjb250ZW50QmxvY2tTdG9wDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQyJ9AHyeLwAAAJUAAABRYKgWaws6ZXZlbnQtdHlwZQcAC21lc3NhZ2VTdG9wDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0Iiwic3RvcFJlYXNvbiI6ImVuZF90dXJuIn2HCXz0AAABBgAAAE6wWpX7CzpldmVudC10eXBlBwAIbWV0YWRhdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJtZXRyaWNzIjp7ImxhdGVuY3lNcyI6MTA5MH0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE1OT1BRUlNUVSIsInVzYWdlIjp7ImlucHV0VG9rZW5zIjo1MjEsIm91dHB1dFRva2VucyI6OSwic2VydmVyVG9vbFVzYWdlIjp7fSwidG90YWxUb2tlbnMiOjUzMH19Uwfxiw==",
|
||||
"bodyEncoding": "base64"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -255,6 +255,57 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("merges parallel tool results into one user message", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
id: "req_parallel_history",
|
||||
model,
|
||||
messages: [
|
||||
Message.user("Compare the weather."),
|
||||
Message.assistant([
|
||||
ToolCallPart.make({ id: "tool_paris", name: "lookup", input: { city: "Paris" } }),
|
||||
ToolCallPart.make({ id: "tool_london", name: "lookup", input: { city: "London" } }),
|
||||
]),
|
||||
Message.tool({ id: "tool_paris", name: "lookup", result: { forecast: "sunny" } }),
|
||||
Message.tool({ id: "tool_london", name: "lookup", result: { forecast: "rainy" } }),
|
||||
],
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "user", content: [{ text: "Compare the weather." }] },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ toolUse: { toolUseId: "tool_paris", name: "lookup", input: { city: "Paris" } } },
|
||||
{ toolUse: { toolUseId: "tool_london", name: "lookup", input: { city: "London" } } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
toolResult: {
|
||||
toolUseId: "tool_paris",
|
||||
content: [{ json: { forecast: "sunny" } }],
|
||||
status: "success",
|
||||
},
|
||||
},
|
||||
{
|
||||
toolResult: {
|
||||
toolUseId: "tool_london",
|
||||
content: [{ json: { forecast: "rainy" } }],
|
||||
status: "success",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers image content in tool-result messages", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -1165,4 +1216,39 @@ describe("Bedrock Converse recorded", () => {
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
recorded.effect.with("continues after parallel tool results", { tags: ["tool", "tool-loop", "parallel"] }, () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
id: "recorded_bedrock_parallel_tool_results",
|
||||
model: recordedModel(),
|
||||
system: "After receiving both tool results, reply exactly: Paris is sunny; London is rainy.",
|
||||
messages: [
|
||||
Message.user("Compare the weather in Paris and London."),
|
||||
Message.assistant([
|
||||
ToolCallPart.make({ id: "weather_paris", name: weatherToolName, input: { city: "Paris" } }),
|
||||
ToolCallPart.make({ id: "weather_london", name: weatherToolName, input: { city: "London" } }),
|
||||
]),
|
||||
Message.tool({
|
||||
id: "weather_paris",
|
||||
name: weatherToolName,
|
||||
result: { temperature: 22, condition: "sunny" },
|
||||
}),
|
||||
Message.tool({
|
||||
id: "weather_london",
|
||||
name: weatherToolName,
|
||||
result: { temperature: 14, condition: "rainy" },
|
||||
}),
|
||||
],
|
||||
tools: [weatherTool],
|
||||
cache: "none",
|
||||
generation: { maxTokens: 40, temperature: 0 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(response.text.trim()).toBe("Paris is sunny; London is rainy.")
|
||||
expect(response.finishReason?.normalized).toBe("stop")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -39,6 +39,8 @@ export const run = Effect.fnUntraced(function* (options: Options) {
|
||||
})
|
||||
|
||||
const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
const serviceErrorFormat = process.env.OPENCODE_SERVICE_ERROR_FORMAT
|
||||
delete process.env.OPENCODE_SERVICE_ERROR_FORMAT
|
||||
const global = yield* Global.Service
|
||||
if (options.mode === "service") yield* Effect.sync(() => process.chdir(global.home))
|
||||
return yield* Effect.scoped(
|
||||
@@ -127,15 +129,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
if (serviceOptions === undefined || port === undefined || !addressInUse(error)) return Effect.fail(error)
|
||||
return recognizeIncumbent(serviceOptions, hostname, port).pipe(
|
||||
Effect.flatMap((found) =>
|
||||
found
|
||||
? Effect.void
|
||||
: Effect.fail(
|
||||
new Error(
|
||||
`Managed service port ${port} on ${hostname} is already in use by another process. ` +
|
||||
"Configure another port with `opencode service set port <port>` and start the service again.",
|
||||
{ cause: error },
|
||||
),
|
||||
),
|
||||
found ? Effect.void : managedPortInUse(hostname, port, error, serviceErrorFormat),
|
||||
),
|
||||
)
|
||||
}),
|
||||
@@ -214,6 +208,17 @@ function serviceURL(hostname: string, port: number) {
|
||||
return `http://${hostname.includes(":") ? `[${hostname}]` : hostname}:${port}`
|
||||
}
|
||||
|
||||
function managedPortInUse(hostname: string, port: number, cause: unknown, format?: string) {
|
||||
const message =
|
||||
`Managed service port ${port} on ${hostname} is already in use by another process. ` +
|
||||
"Configure another port with `opencode service set port <port>` and start the service again."
|
||||
const failure = new Error(message, { cause })
|
||||
if (format !== "plain") return Effect.fail(failure)
|
||||
return Effect.sync(() => process.stderr.write(`OPENCODE_SERVICE_ERROR:${message}\n`)).pipe(
|
||||
Effect.andThen(Effect.fail(failure)),
|
||||
)
|
||||
}
|
||||
|
||||
function truthy(value?: string) {
|
||||
return value === "1" || value?.toLowerCase() === "true"
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
|
||||
import { Effect, FileSystem, Option, Schedule, Schema } from "effect"
|
||||
import { spawn, type ChildProcess } from "node:child_process"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import type { DiscoverOptions, Endpoint, EnsureOptions, StopOptions } from "../service.js"
|
||||
import { ServiceProcess } from "../service-process.js"
|
||||
|
||||
export * from "../service.js"
|
||||
/** Contents of the local service registration file. */
|
||||
@@ -17,11 +17,6 @@ export type Info = import("../service.js").Info
|
||||
// is all a client needs to connect. The daemon's own configuration (port,
|
||||
// persisted password) is CLI-owned and never read here.
|
||||
|
||||
type Contender = {
|
||||
readonly child: ChildProcess
|
||||
readonly error: () => Error | undefined
|
||||
}
|
||||
|
||||
// Read-only lookup: registration file plus health check and version gate.
|
||||
// Never spawns; escalation to ensure() is the caller's policy.
|
||||
/** Discover a healthy, compatible local service without starting one. */
|
||||
@@ -52,11 +47,12 @@ const discoverLocal = Effect.fnUntraced(function* (options: DiscoverOptions) {
|
||||
// becomes discoverable. A contender is never killed merely for slow startup.
|
||||
/** Ensure a healthy, compatible local service is running. */
|
||||
export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOptions = {}) {
|
||||
const contenders = new Set<Contender>()
|
||||
const contenders = new Set<ServiceProcess.Contender>()
|
||||
let timeouts: { readonly info: Info; readonly count: number } | undefined
|
||||
let announced = false
|
||||
let lastSpawn = 0
|
||||
let spawnDelay = 5_000
|
||||
let lastFailure: Error | undefined
|
||||
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) =>
|
||||
Effect.sync(() => {
|
||||
if (announced) return
|
||||
@@ -67,15 +63,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
|
||||
if (command === undefined) return yield* Effect.fail(new Error("Missing service command"))
|
||||
return yield* Effect.try({
|
||||
try: () => {
|
||||
const child = spawn(command, args, { detached: true, stdio: "ignore" })
|
||||
let error: Error | undefined
|
||||
child.once("error", (cause) => {
|
||||
error = new Error("Failed to start server", { cause })
|
||||
})
|
||||
child.unref()
|
||||
return { child, error: () => error }
|
||||
},
|
||||
try: () => ServiceProcess.start(command, args),
|
||||
catch: (cause) => new Error("Failed to start server", { cause }),
|
||||
})
|
||||
})
|
||||
@@ -108,8 +96,9 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
return Option.none<LocalService>()
|
||||
} else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now()
|
||||
|
||||
const finished = [...contenders].filter(contenderFinished)
|
||||
const failure = finished.map(contenderFailure).find((error): error is Error => error !== undefined)
|
||||
const finished = [...contenders].filter(ServiceProcess.finished)
|
||||
const failure = finished.map(ServiceProcess.failure).find((error): error is Error => error !== undefined)
|
||||
if (failure !== undefined) lastFailure = failure
|
||||
if (finished.some((item) => item.child.exitCode === 0)) {
|
||||
spawnDelay = Math.min(spawnDelay * 2, 30_000)
|
||||
}
|
||||
@@ -129,24 +118,10 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
}),
|
||||
)
|
||||
if (Option.isNone(found))
|
||||
return yield* Effect.fail(new Error("Timed out waiting for the background service to start"))
|
||||
return yield* Effect.fail(lastFailure ?? new Error("Timed out waiting for the background service to start"))
|
||||
return found.value.endpoint
|
||||
})
|
||||
|
||||
function contenderFailure(contender: Contender) {
|
||||
const error = contender.error()
|
||||
if (error !== undefined) return error
|
||||
if (contender.child.exitCode !== null && contender.child.exitCode !== 0)
|
||||
return new Error(`Server process exited with code ${contender.child.exitCode}`)
|
||||
if (contender.child.signalCode !== null)
|
||||
return new Error(`Server process terminated by ${contender.child.signalCode}`)
|
||||
return undefined
|
||||
}
|
||||
|
||||
function contenderFinished(contender: Contender) {
|
||||
return contender.error() !== undefined || contender.child.exitCode !== null || contender.child.signalCode !== null
|
||||
}
|
||||
|
||||
/** Stop the registered local service. */
|
||||
export const stop = Effect.fn("service.stop")(function* (options: StopOptions = {}) {
|
||||
const existing = yield* find(options)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { spawn, type ChildProcess } from "node:child_process"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import type { DiscoverOptions, Endpoint, Info, EnsureOptions, StopOptions } from "../service.js"
|
||||
import { ServiceProcess } from "../service-process.js"
|
||||
import type { ServiceHealth, ServiceStopResponse } from "./generated/types.js"
|
||||
|
||||
export * from "../service.js"
|
||||
@@ -13,11 +13,6 @@ export * from "../service.js"
|
||||
// intentionally implemented with Node APIs so Promise clients do not need
|
||||
// Effect or @effect/platform-node at runtime.
|
||||
|
||||
type Contender = {
|
||||
readonly child: ChildProcess
|
||||
readonly error: () => Error | undefined
|
||||
}
|
||||
|
||||
/** Discover a healthy, compatible local service without starting one. */
|
||||
export async function discover(options: DiscoverOptions = {}) {
|
||||
return (await discoverLocal(options))?.endpoint
|
||||
@@ -33,11 +28,12 @@ async function discoverLocal(options: DiscoverOptions) {
|
||||
/** Ensure a healthy, compatible local service is running. */
|
||||
export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
const deadline = Date.now() + 120_000
|
||||
const contenders = new Set<Contender>()
|
||||
const contenders = new Set<ServiceProcess.Contender>()
|
||||
let timeouts: { readonly info: Info; readonly count: number } | undefined
|
||||
let announced = false
|
||||
let lastSpawn = 0
|
||||
let spawnDelay = 5_000
|
||||
let lastFailure: Error | undefined
|
||||
|
||||
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) => {
|
||||
if (announced) return
|
||||
@@ -47,21 +43,11 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
const spawnContender = () => {
|
||||
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
|
||||
if (command === undefined) throw new Error("Missing service command")
|
||||
try {
|
||||
const child = spawn(command, args, { detached: true, stdio: "ignore" })
|
||||
let error: Error | undefined
|
||||
child.once("error", (cause) => {
|
||||
error = new Error("Failed to start server", { cause })
|
||||
})
|
||||
child.unref()
|
||||
return { child, error: () => error }
|
||||
} catch (cause) {
|
||||
throw new Error("Failed to start server", { cause })
|
||||
}
|
||||
return ServiceProcess.start(command, args)
|
||||
}
|
||||
|
||||
while (true) {
|
||||
if (Date.now() >= deadline) throw new Error("Timed out waiting for the background service to start")
|
||||
if (Date.now() >= deadline) throw lastFailure ?? new Error("Timed out waiting for the background service to start")
|
||||
const registration = await registered(options.file, true)
|
||||
if (registration.timedOut && registration.info !== undefined) {
|
||||
timeouts = {
|
||||
@@ -89,8 +75,9 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
}
|
||||
} else {
|
||||
if (lastSpawn === 0 && registration.info !== undefined) lastSpawn = Date.now()
|
||||
const finished = [...contenders].filter(contenderFinished)
|
||||
const failure = finished.map(contenderFailure).find((error) => error !== undefined)
|
||||
const finished = [...contenders].filter(ServiceProcess.finished)
|
||||
const failure = finished.map(ServiceProcess.failure).find((error) => error !== undefined)
|
||||
if (failure !== undefined) lastFailure = failure
|
||||
if (finished.some((item) => item.child.exitCode === 0)) {
|
||||
spawnDelay = Math.min(spawnDelay * 2, 30_000)
|
||||
}
|
||||
@@ -107,20 +94,6 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
}
|
||||
}
|
||||
|
||||
function contenderFailure(contender: Contender) {
|
||||
const error = contender.error()
|
||||
if (error !== undefined) return error
|
||||
if (contender.child.exitCode !== null && contender.child.exitCode !== 0)
|
||||
return new Error(`Server process exited with code ${contender.child.exitCode}`)
|
||||
if (contender.child.signalCode !== null)
|
||||
return new Error(`Server process terminated by ${contender.child.signalCode}`)
|
||||
return undefined
|
||||
}
|
||||
|
||||
function contenderFinished(contender: Contender) {
|
||||
return contender.error() !== undefined || contender.child.exitCode !== null || contender.child.signalCode !== null
|
||||
}
|
||||
|
||||
/** Stop the registered local service. */
|
||||
export async function stop(options: StopOptions = {}) {
|
||||
const existing = await find(options)
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
export * as ServiceProcess from "./service-process"
|
||||
|
||||
import { spawn, type ChildProcess } from "node:child_process"
|
||||
|
||||
const errorPrefix = "OPENCODE_SERVICE_ERROR:"
|
||||
|
||||
export type Contender = {
|
||||
readonly child: ChildProcess
|
||||
readonly error: () => Error | undefined
|
||||
readonly startupError: () => string
|
||||
}
|
||||
|
||||
export function start(command: string, args: ReadonlyArray<string>) {
|
||||
try {
|
||||
const child = spawn(command, args, {
|
||||
detached: true,
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
env: { ...process.env, OPENCODE_SERVICE_ERROR_FORMAT: "plain" },
|
||||
})
|
||||
let error: Error | undefined
|
||||
let pending = ""
|
||||
let startupError = ""
|
||||
child.once("error", (cause) => {
|
||||
error = new Error("Failed to start server", { cause })
|
||||
})
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
const lines = (pending + chunk.toString()).split(/\r?\n/)
|
||||
pending = lines.pop()?.slice(-64 * 1024) ?? ""
|
||||
const message = lines.findLast((line) => line.startsWith(errorPrefix))
|
||||
if (message !== undefined) startupError = message.slice(errorPrefix.length)
|
||||
})
|
||||
unref(child.stderr)
|
||||
child.unref()
|
||||
return { child, error: () => error, startupError: () => startupError } satisfies Contender
|
||||
} catch (cause) {
|
||||
throw new Error("Failed to start server", { cause })
|
||||
}
|
||||
}
|
||||
|
||||
export function failure(contender: Contender) {
|
||||
const error = contender.error()
|
||||
if (error !== undefined) return error
|
||||
if (contender.child.exitCode !== null && contender.child.exitCode !== 0)
|
||||
return new Error(contender.startupError() || `Server process exited with code ${contender.child.exitCode}`)
|
||||
if (contender.child.signalCode !== null)
|
||||
return new Error(`Server process terminated by ${contender.child.signalCode}`)
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function finished(contender: Contender) {
|
||||
return contender.error() !== undefined || contender.child.exitCode !== null || contender.child.signalCode !== null
|
||||
}
|
||||
|
||||
function unref(stream: ChildProcess["stderr"]) {
|
||||
if (!stream || !("unref" in stream) || typeof stream.unref !== "function") return
|
||||
stream.unref()
|
||||
}
|
||||
@@ -3,6 +3,11 @@ import { appendFile, rename, writeFile } from "node:fs/promises"
|
||||
const [registration, mode, delay] = process.argv.slice(2)
|
||||
if (registration === undefined || mode === undefined) throw new Error("Missing service fixture arguments")
|
||||
if (mode === "failed") process.exit(1)
|
||||
if (mode === "failed-message") {
|
||||
console.error("sensitive startup detail")
|
||||
console.error("OPENCODE_SERVICE_ERROR:Managed service port is already in use")
|
||||
process.exit(1)
|
||||
}
|
||||
if (mode === "record-start") {
|
||||
await writeFile(registration + ".started", "")
|
||||
process.exit(1)
|
||||
|
||||
@@ -70,6 +70,19 @@ test("reports a failed registered service", async () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("reports the native contender's startup error", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
|
||||
await expect(
|
||||
Service.ensure({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [process.execPath, fixture, registration, "failed-message"],
|
||||
}),
|
||||
).rejects.toThrow(/^Managed service port is already in use$/)
|
||||
}, 10_000)
|
||||
|
||||
test("evicts an unresponsive registered service before starting its replacement", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
|
||||
@@ -197,6 +197,20 @@ test("reports a contender that fails to start", async () => {
|
||||
).rejects.toThrow("Server process exited with code 1")
|
||||
}, 10_000)
|
||||
|
||||
test("reports the contender's startup error", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
await expect(
|
||||
run(
|
||||
Service.ensure({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [process.execPath, fixture, registration, "failed-message"],
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow(/^Managed service port is already in use$/)
|
||||
}, 10_000)
|
||||
|
||||
test("reports a contender terminated by a signal", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Message, ToolCallPart, ToolResultPart, type ContentPart, type ProviderMetadata } from "@opencode-ai/ai"
|
||||
import { Option, Schema } from "effect"
|
||||
import { fileURLToPath } from "url"
|
||||
import type { Model } from "../../model"
|
||||
import { SessionMessage } from "../message"
|
||||
import type { FileAttachment } from "@opencode-ai/schema/prompt"
|
||||
@@ -15,13 +14,6 @@ const media = (file: FileAttachment): ContentPart => ({
|
||||
metadata: file.description === undefined ? undefined : { description: file.description },
|
||||
})
|
||||
|
||||
const attachmentLocation = (file: FileAttachment) => {
|
||||
if (file.source.type !== "uri") return undefined
|
||||
const url = URL.parse(file.source.uri)
|
||||
if (url?.protocol !== "file:") return undefined
|
||||
return fileURLToPath(url, { windows: url.hostname !== "" || /^\/[a-zA-Z]:\//.test(url.pathname) })
|
||||
}
|
||||
|
||||
const textAttachment = (file: FileAttachment): ContentPart => ({
|
||||
type: "text",
|
||||
text: `\n\n${[
|
||||
@@ -44,7 +36,7 @@ const textAttachment = (file: FileAttachment): ContentPart => ({
|
||||
const directoryAttachment = (file: FileAttachment): ContentPart => ({
|
||||
type: "text",
|
||||
text: `\n\n${[
|
||||
`Attached directory: ${attachmentLocation(file) ?? file.name ?? (file.source.type === "uri" ? file.source.uri : "directory")}`,
|
||||
`Attached directory: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "directory")}`,
|
||||
file.description === undefined ? undefined : `Description: ${file.description}`,
|
||||
file.data.length === 0 ? undefined : "",
|
||||
file.data.length === 0 ? undefined : Buffer.from(file.data, "base64").toString("utf8"),
|
||||
@@ -63,10 +55,7 @@ const directoryAttachment = (file: FileAttachment): ContentPart => ({
|
||||
const attachmentContent = (file: FileAttachment): ContentPart[] => {
|
||||
if (file.mime === "text/plain") return [textAttachment(file)]
|
||||
if (file.mime === "application/x-directory") return [directoryAttachment(file)]
|
||||
if (imageMimes.has(file.mime)) {
|
||||
const location = attachmentLocation(file)
|
||||
return [...(location === undefined ? [] : [Message.text(`Attached file: ${location}`)]), media(file)]
|
||||
}
|
||||
if (imageMimes.has(file.mime)) return [media(file)]
|
||||
return []
|
||||
}
|
||||
|
||||
|
||||
@@ -21,9 +21,7 @@ Usage notes:
|
||||
- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label`
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
questions: Schema.Array(Question.Prompt)
|
||||
.check(Schema.isNonEmpty())
|
||||
.annotate({ description: "Questions to ask" }),
|
||||
questions: Schema.Array(Question.Prompt).check(Schema.isNonEmpty()).annotate({ description: "Questions to ask" }),
|
||||
})
|
||||
|
||||
export const Output = Schema.Struct({
|
||||
|
||||
@@ -249,14 +249,13 @@ Recent work
|
||||
])
|
||||
})
|
||||
|
||||
test("exposes admitted reference directory source paths in model context", () => {
|
||||
test("lowers directory attachments as directory context", () => {
|
||||
const directory = FileAttachment.make({
|
||||
data: Base64.make(Buffer.from("lib/\nindex.ts").toString("base64")),
|
||||
mime: "application/x-directory",
|
||||
source: { type: "uri", uri: "file:///references/harness-engineering" },
|
||||
name: "harness-engineering",
|
||||
source: { type: "uri", uri: "file:///project/src" },
|
||||
name: "src/",
|
||||
})
|
||||
expect(directory.source).toEqual({ type: "uri", uri: "file:///references/harness-engineering" })
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
SessionMessage.User.make({
|
||||
@@ -278,8 +277,8 @@ Recent work
|
||||
{ type: "text", text: "Review this directory" },
|
||||
{
|
||||
type: "text",
|
||||
text: "\n\nAttached directory: /references/harness-engineering\n\nlib/\nindex.ts",
|
||||
metadata: { attachment: { source: directory.source, name: "harness-engineering" } },
|
||||
text: "\n\nAttached directory: src/\n\nlib/\nindex.ts",
|
||||
metadata: { attachment: { source: directory.source, name: "src/" } },
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -315,7 +314,7 @@ Recent work
|
||||
expect(messages).toHaveLength(1)
|
||||
expect(messages[0]?.content.map((part) => (part.type === "text" ? part.text : part.type))).toEqual([
|
||||
"Review these attachments",
|
||||
"\n\nAttached directory: /project/src\n\nindex.ts",
|
||||
"\n\nAttached directory: src/\n\nindex.ts",
|
||||
"\n\nAttached file: main.ts\n\nexport const value = 1",
|
||||
])
|
||||
})
|
||||
@@ -342,9 +341,7 @@ Recent work
|
||||
)
|
||||
|
||||
expect(messages).toHaveLength(1)
|
||||
expect(messages[0]?.content).toMatchObject([
|
||||
{ type: "text", text: "\n\nAttached directory: /project/src\n\nindex.ts" },
|
||||
])
|
||||
expect(messages[0]?.content).toMatchObject([{ type: "text", text: "\n\nAttached directory: src/\n\nindex.ts" }])
|
||||
})
|
||||
|
||||
test("uses materialized image data as provider media and drops unsupported attachments", () => {
|
||||
@@ -376,64 +373,6 @@ Recent work
|
||||
])
|
||||
})
|
||||
|
||||
test("exposes admitted local image source paths before provider media", () => {
|
||||
const data = Base64.make("AAECAw==")
|
||||
const image = FileAttachment.make({
|
||||
data,
|
||||
mime: "image/png",
|
||||
source: { type: "uri", uri: "file:///project/IMG_3480.JPG" },
|
||||
name: "IMG_3480.JPG",
|
||||
})
|
||||
expect(image.source).toEqual({ type: "uri", uri: "file:///project/IMG_3480.JPG" })
|
||||
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
SessionMessage.User.make({
|
||||
id: id("user-local-image-path"),
|
||||
type: "user",
|
||||
text: "Inspect this image",
|
||||
files: [image],
|
||||
time: { created },
|
||||
}),
|
||||
],
|
||||
model,
|
||||
)
|
||||
|
||||
expect(messages[0]?.content).toEqual([
|
||||
{ type: "text", text: "Inspect this image" },
|
||||
{ type: "text", text: "Attached file: /project/IMG_3480.JPG" },
|
||||
{ type: "media", mediaType: "image/png", data, filename: "IMG_3480.JPG" },
|
||||
])
|
||||
})
|
||||
|
||||
test("does not add attachment location text for non-local provider media", () => {
|
||||
const data = Base64.make("AAECAw==")
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
SessionMessage.User.make({
|
||||
id: id("user-remote-image"),
|
||||
type: "user",
|
||||
text: "Inspect this image",
|
||||
files: [
|
||||
FileAttachment.make({
|
||||
data,
|
||||
mime: "image/png",
|
||||
source: { type: "uri", uri: "https://example.com/image.png" },
|
||||
name: "image.png",
|
||||
}),
|
||||
],
|
||||
time: { created },
|
||||
}),
|
||||
],
|
||||
model,
|
||||
)
|
||||
|
||||
expect(messages[0]?.content).toEqual([
|
||||
{ type: "text", text: "Inspect this image" },
|
||||
{ type: "media", mediaType: "image/png", data, filename: "image.png" },
|
||||
])
|
||||
})
|
||||
|
||||
test("deduplicates provider media while preserving durable attachment references", () => {
|
||||
const data = Base64.make("AAECAw==")
|
||||
const messages = toLLMMessages(
|
||||
|
||||
@@ -12,6 +12,19 @@ export const ModelHandler = HttpApiBuilder.group(Api, "server.model", (handlers)
|
||||
.handle(
|
||||
"model.list",
|
||||
Effect.fn(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.flush.pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: "5 seconds",
|
||||
orElse: () =>
|
||||
Effect.fail(
|
||||
new ServiceUnavailableError({
|
||||
message: "Model catalog initialization timed out",
|
||||
service: "model.catalog",
|
||||
}),
|
||||
),
|
||||
}),
|
||||
)
|
||||
const catalog = yield* Catalog.Service
|
||||
return yield* response(catalog.model.available())
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { tmpdir } from "../../core/test/fixture/tmpdir"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { ServerProcess } from "../src/process"
|
||||
|
||||
it.live("waits for plugin initialization before listing models", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir("opencode-model-endpoint-")),
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(
|
||||
path.join(tmp.path, "opencode.json"),
|
||||
JSON.stringify({
|
||||
providers: {
|
||||
custom: {
|
||||
package: "aisdk:@ai-sdk/openai-compatible",
|
||||
settings: { apiKey: "secret" },
|
||||
models: { chat: {} },
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
const server = yield* ServerProcess.start<never, never>({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
password: "secret",
|
||||
app: { version: "test-version" },
|
||||
database: { path: ":memory:" },
|
||||
config: { directory: tmp.path },
|
||||
fs: { filewatcher: false },
|
||||
})
|
||||
const url = new URL("/api/model", HttpServer.formatAddress(server.address))
|
||||
url.searchParams.set("location[directory]", tmp.path)
|
||||
const response = yield* Effect.promise(() =>
|
||||
fetch(url, { headers: { authorization: `Basic ${btoa("opencode:secret")}` } }),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const body: unknown = yield* Effect.promise(() => response.json())
|
||||
if (!isRecord(body) || !Array.isArray(body["data"])) throw new Error("Expected a model list response")
|
||||
expect(
|
||||
body["data"].some((model) => isRecord(model) && model["providerID"] === "custom" && model["id"] === "chat"),
|
||||
).toBeTrue()
|
||||
}),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
Reference in New Issue
Block a user