mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-11 20:19:53 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1efe923426 | |||
| df7fa12b15 |
@@ -310,6 +310,27 @@ test("unrelated managed port occupancy reports an actionable conflict", async ()
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
test("managed service startup reports an actionable port conflict", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-managed-conflict-"))
|
||||
const registration = path.join(root, "state", "opencode", "service-local.json")
|
||||
const message =
|
||||
"Managed service port 49374 on 127.0.0.1 is already in use by another process. " +
|
||||
"Configure another port with `opencode service set port <port>` and start the service again."
|
||||
|
||||
try {
|
||||
await expect(
|
||||
Effect.runPromise(
|
||||
Service.ensure({
|
||||
file: registration,
|
||||
command: [process.execPath, "-e", `console.error(${JSON.stringify(message)}); process.exit(1)`],
|
||||
}).pipe(Effect.provide(NodeFileSystem.layer)),
|
||||
),
|
||||
).rejects.toThrow(message)
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
test("unresponsive managed port occupancy reports a bounded conflict", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-unresponsive-conflict-"))
|
||||
const recognizing = Promise.withResolvers<void>()
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
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 {
|
||||
contenderFailure,
|
||||
contenderFinished,
|
||||
type ServiceContender,
|
||||
spawnServiceContender,
|
||||
} from "../service-contender.js"
|
||||
|
||||
export * from "../service.js"
|
||||
/** Contents of the local service registration file. */
|
||||
@@ -17,11 +22,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,7 +52,7 @@ 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<ServiceContender>()
|
||||
let timeouts: { readonly info: Info; readonly count: number } | undefined
|
||||
let announced = false
|
||||
let lastSpawn = 0
|
||||
@@ -68,13 +68,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
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 }
|
||||
return spawnServiceContender(command, args)
|
||||
},
|
||||
catch: (cause) => new Error("Failed to start server", { cause }),
|
||||
})
|
||||
@@ -133,20 +127,6 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
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,13 @@
|
||||
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 {
|
||||
contenderFailure,
|
||||
contenderFinished,
|
||||
type ServiceContender,
|
||||
spawnServiceContender,
|
||||
} from "../service-contender.js"
|
||||
import type { ServiceHealth, ServiceStopResponse } from "./generated/types.js"
|
||||
|
||||
export * from "../service.js"
|
||||
@@ -13,11 +18,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,7 +33,7 @@ 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<ServiceContender>()
|
||||
let timeouts: { readonly info: Info; readonly count: number } | undefined
|
||||
let announced = false
|
||||
let lastSpawn = 0
|
||||
@@ -48,13 +48,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
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 }
|
||||
return spawnServiceContender(command, args)
|
||||
} catch (cause) {
|
||||
throw new Error("Failed to start server", { cause })
|
||||
}
|
||||
@@ -107,20 +101,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,48 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process"
|
||||
|
||||
export type ServiceContender = {
|
||||
readonly child: ChildProcess
|
||||
readonly error: () => Error | undefined
|
||||
readonly closed: () => boolean
|
||||
readonly stderr: () => string
|
||||
}
|
||||
|
||||
const stderrLimit = 8 * 1024
|
||||
|
||||
export function spawnServiceContender(command: string, args: ReadonlyArray<string>): ServiceContender {
|
||||
const child = spawn(command, args, { detached: true, stdio: ["ignore", "ignore", "pipe"] })
|
||||
let error: Error | undefined
|
||||
let closed = false
|
||||
let stderr = Buffer.alloc(0)
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
stderr = Buffer.concat([stderr, chunk]).subarray(-stderrLimit)
|
||||
})
|
||||
if (child.stderr !== null && "unref" in child.stderr && typeof child.stderr.unref === "function")
|
||||
child.stderr.unref()
|
||||
child.once("error", (cause) => {
|
||||
error = new Error("Failed to start server", { cause })
|
||||
})
|
||||
child.once("close", () => {
|
||||
closed = true
|
||||
})
|
||||
child.unref()
|
||||
return { child, error: () => error, closed: () => closed, stderr: () => stderr.toString("utf8").trim() }
|
||||
}
|
||||
|
||||
export function contenderFailure(contender: ServiceContender) {
|
||||
const error = contender.error()
|
||||
if (error !== undefined) return error
|
||||
if (contender.child.exitCode !== null && contender.child.exitCode !== 0)
|
||||
return startupError(`Server process exited with code ${contender.child.exitCode}`, contender.stderr())
|
||||
if (contender.child.signalCode !== null)
|
||||
return startupError(`Server process terminated by ${contender.child.signalCode}`, contender.stderr())
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function contenderFinished(contender: ServiceContender) {
|
||||
return contender.error() !== undefined || contender.closed()
|
||||
}
|
||||
|
||||
function startupError(message: string, stderr: string) {
|
||||
return new Error(stderr ? `${message}\n${stderr}` : message)
|
||||
}
|
||||
@@ -3,6 +3,10 @@ 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 === "stderr-failed") {
|
||||
process.stderr.write("x".repeat(16_384) + "\nactionable startup failure\n")
|
||||
process.exit(1)
|
||||
}
|
||||
if (mode === "record-start") {
|
||||
await writeFile(registration + ".started", "")
|
||||
process.exit(1)
|
||||
|
||||
@@ -70,6 +70,21 @@ test("reports a failed registered service", async () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("reports a bounded contender stderr tail with native promises", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
const error = await Service.ensure({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [process.execPath, fixture, registration, "stderr-failed"],
|
||||
}).catch((error: unknown) => error)
|
||||
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
if (!(error instanceof Error)) throw error
|
||||
expect(error.message).toContain("actionable startup failure")
|
||||
expect(error.message.length).toBeLessThan(9_000)
|
||||
}, 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,23 @@ test("reports a contender that fails to start", async () => {
|
||||
).rejects.toThrow("Server process exited with code 1")
|
||||
}, 10_000)
|
||||
|
||||
test("reports a bounded contender stderr tail", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
const error = await run(
|
||||
Service.ensure({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [process.execPath, fixture, registration, "stderr-failed"],
|
||||
}),
|
||||
).catch((error: unknown) => error)
|
||||
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
if (!(error instanceof Error)) throw error
|
||||
expect(error.message).toContain("actionable startup failure")
|
||||
expect(error.message.length).toBeLessThan(9_000)
|
||||
}, 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 []
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user