mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-29 13:06:13 +00:00
Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a3d837100 | ||
|
|
e3bda5e2d0 | ||
|
|
ad9117b107 | ||
|
|
6b7a1d419c | ||
|
|
c0d0f5f4bc | ||
|
|
095ed63ea0 | ||
|
|
fa5ccac707 | ||
|
|
6809be2d0a | ||
|
|
a5f8869b35 | ||
|
|
1c9c5305a6 | ||
|
|
cf014bf2c1 | ||
|
|
8e7190f795 | ||
|
|
82b6e0e316 | ||
|
|
5b39f5184f | ||
|
|
1da25727b2 | ||
|
|
51d53f45c1 | ||
|
|
f779b2748a | ||
|
|
f2fb191f53 | ||
|
|
0b32bdf1e5 | ||
|
|
2751813454 | ||
|
|
f61858e683 | ||
|
|
87525e00b9 | ||
|
|
803b7718b8 | ||
|
|
8a24a01bff | ||
|
|
9e39a4fbdf | ||
|
|
d0baff184b | ||
|
|
d82a0b28a9 | ||
|
|
bd379e13cb | ||
|
|
6e1f783aec | ||
|
|
edef6a4b15 | ||
|
|
5990679ebd | ||
|
|
b0c8a8c827 |
@@ -2,3 +2,4 @@ packages/core/migration/**/snapshot.json linguist-generated
|
||||
packages/core/src/database/migration.gen.ts linguist-generated
|
||||
packages/core/src/models-dev/snapshot.txt linguist-generated
|
||||
packages/core/src/**/*.txt text eol=lf
|
||||
packages/httpapi-codegen/test/generated/*.ts text eol=lf
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit generated client files directly.
|
||||
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk` composes Client, Core, and Server.
|
||||
- Current implementation changes belong in `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
|
||||
- This repository does not use Changesets. Do not add `.changeset` files; follow the existing release workflow instead.
|
||||
- The default branch in this repo is `v2`.
|
||||
- Base all new branches and worktrees on `v2`, or `origin/v2` when the local `v2` ref is unavailable. Do not base them on `dev`.
|
||||
- Local `main` ref may not exist; use `v2` or `origin/v2` for diffs.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import type { Content } from "@opencode-ai/schema/tool"
|
||||
import { HttpTransport } from "../route/transport/index.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
@@ -845,6 +845,21 @@ const onOutputTextDone = (state: ParserState, event: Event, id: string): StepRes
|
||||
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, id) }, events]
|
||||
}
|
||||
|
||||
const decodeMessagePart = Schema.decodeUnknownOption(
|
||||
Schema.Union([OpenResponsesOutputText, Schema.Struct({ type: Schema.tag("refusal"), refusal: Schema.String })]),
|
||||
)
|
||||
|
||||
const decodeSummaryPart = Schema.decodeUnknownOption(OpenResponsesReasoningSummaryText)
|
||||
|
||||
const decodeReasoningPart = Schema.decodeUnknownOption(
|
||||
Schema.Struct({ type: Schema.tag("reasoning_text"), text: Schema.String }),
|
||||
)
|
||||
|
||||
const joinReasoningText = (parts: ReadonlyArray<string | undefined>) => {
|
||||
if (!parts.some((part) => part !== undefined && part.length > 0)) return undefined
|
||||
return parts.filter((part) => part !== undefined).join("\n\n")
|
||||
}
|
||||
|
||||
export const outputItemID = (state: ParserState, event: Event) =>
|
||||
event.output_index === undefined ? event.item_id : (state.outputItems[event.output_index] ?? event.item_id)
|
||||
|
||||
@@ -1065,24 +1080,33 @@ const onFunctionCallArgumentsDelta = Effect.fn("OpenResponses.onFunctionCallArgu
|
||||
return [{ ...state, lifecycle, tools: result.tools }, events] satisfies StepResult
|
||||
})
|
||||
|
||||
const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (state: ParserState, event: Event) {
|
||||
const item = event.item
|
||||
const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
state: ParserState,
|
||||
item: Event["item"],
|
||||
) {
|
||||
if (!item) return [state, NO_EVENTS] satisfies StepResult
|
||||
|
||||
if (item.type === "message" && item.id !== undefined) {
|
||||
const message = state.message?.id === item.id ? state.message : undefined
|
||||
const itemPhase = messagePhase(item.phase)
|
||||
const phase = itemPhase === undefined && state.message?.id === item.id ? state.message.phase : itemPhase
|
||||
const phase = itemPhase === undefined ? message?.phase : itemPhase
|
||||
const parts: ReadonlyArray<unknown> = Array.isArray(item.content) ? item.content : []
|
||||
const content: string[] = []
|
||||
for (const part of parts) {
|
||||
const decoded = Option.getOrUndefined(decodeMessagePart(part))
|
||||
if (!decoded) continue
|
||||
content.push(decoded.type === "output_text" ? decoded.text : decoded.refusal)
|
||||
}
|
||||
const text = content.length > 0 ? content.join("") : undefined
|
||||
const metadata = providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) })
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle =
|
||||
message && text ? Lifecycle.textStart(state.lifecycle, events, item.id, metadata) : state.lifecycle
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.textEnd(
|
||||
state.lifecycle,
|
||||
events,
|
||||
item.id,
|
||||
providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) }),
|
||||
),
|
||||
message: state.message?.id === item.id ? undefined : state.message,
|
||||
lifecycle: Lifecycle.textEnd(lifecycle, events, item.id, metadata, text),
|
||||
message: message ? undefined : state.message,
|
||||
},
|
||||
events,
|
||||
] satisfies StepResult
|
||||
@@ -1137,17 +1161,33 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
}
|
||||
|
||||
if (isReasoningItem(item)) {
|
||||
const events: LLMEvent[] = []
|
||||
if (state.reasoningItems[item.id]?.open === false) return [state, NO_EVENTS] satisfies StepResult
|
||||
const metadata = reasoningMetadata(state, item)
|
||||
const summaryParts: ReadonlyArray<unknown> = Array.isArray(item.summary) ? item.summary : []
|
||||
const summary: Array<string | undefined> = []
|
||||
for (const part of summaryParts) {
|
||||
const decoded = Option.getOrUndefined(decodeSummaryPart(part))
|
||||
// Keep missing entries so the array still matches the provider's summary indexes.
|
||||
summary.push(decoded?.text)
|
||||
}
|
||||
const reasoningParts: ReadonlyArray<unknown> = Array.isArray(item.content) ? item.content : []
|
||||
const content: string[] = []
|
||||
for (const part of reasoningParts) {
|
||||
const decoded = Option.getOrUndefined(decodeReasoningPart(part))
|
||||
if (decoded) content.push(decoded.text)
|
||||
}
|
||||
const itemText = joinReasoningText(summary) ?? joinReasoningText(content)
|
||||
const events: LLMEvent[] = []
|
||||
const reasoningItem = state.reasoningItems[item.id]
|
||||
if (reasoningItem) {
|
||||
if (!reasoningItem.open) return [state, NO_EVENTS] satisfies StepResult
|
||||
const lifecycle = Object.entries(reasoningItem.summaryParts)
|
||||
.filter((entry) => entry[1] === "active" || entry[1] === "can-conclude")
|
||||
.reduce(
|
||||
(lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${entry[0]}`, metadata),
|
||||
state.lifecycle,
|
||||
)
|
||||
const fragments = Object.entries(reasoningItem.summaryParts)
|
||||
let lifecycle = state.lifecycle
|
||||
for (const [index, status] of fragments) {
|
||||
if (status === "concluded") continue
|
||||
// Do not repeat earlier summaries that were already emitted as separate fragments.
|
||||
const finalText = fragments.length === 1 ? itemText : summary[Number(index)]
|
||||
lifecycle = Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${index}`, metadata, finalText || undefined)
|
||||
}
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
@@ -1167,7 +1207,13 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
if (!state.lifecycle.reasoning.has(item.id)) {
|
||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata: metadata }))
|
||||
events.push(LLMEvent.reasoningEnd({ id: item.id, providerMetadata: metadata }))
|
||||
events.push(
|
||||
LLMEvent.reasoningEnd({
|
||||
id: item.id,
|
||||
providerMetadata: metadata,
|
||||
text: itemText,
|
||||
}),
|
||||
)
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
@@ -1195,32 +1241,24 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
})
|
||||
|
||||
const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (state: ParserState, event: Event) {
|
||||
const reconciled =
|
||||
event.type === "response.completed"
|
||||
? yield* Effect.reduce(
|
||||
event.response?.output ?? [],
|
||||
() => [state, NO_EVENTS] satisfies StepResult,
|
||||
([current, events], item) => {
|
||||
const id = item.id ?? (item.type === "function_call" ? item.call_id : undefined)
|
||||
if (
|
||||
id === undefined ||
|
||||
((item.type !== "function_call" || !current.tools[id]) &&
|
||||
(item.type !== "reasoning" || !current.reasoningItems[id]?.open))
|
||||
)
|
||||
return Effect.succeed([current, events] satisfies StepResult)
|
||||
return onOutputItemDone(current, { type: "response.output_item.done", item }).pipe(
|
||||
Effect.map(([next, emitted]) => [next, [...events, ...emitted]] satisfies StepResult),
|
||||
)
|
||||
},
|
||||
)
|
||||
: ([state, NO_EVENTS] satisfies StepResult)
|
||||
const current = reconciled[0]
|
||||
let current = state
|
||||
const events: LLMEvent[] = []
|
||||
if (event.type === "response.completed") {
|
||||
for (const item of event.response?.output ?? []) {
|
||||
const id = item.id ?? (item.type === "function_call" ? item.call_id : undefined)
|
||||
if (id === undefined) continue
|
||||
if (item.type !== "function_call" || !current.tools[id]) continue
|
||||
const [next, emitted] = yield* onOutputItemDone(current, item)
|
||||
current = next
|
||||
events.push(...emitted)
|
||||
}
|
||||
}
|
||||
// Some compatible providers omit output_item.done even after completing the response.
|
||||
const pending =
|
||||
event.type === "response.completed"
|
||||
? yield* ToolStream.finishAll(current.id, current.tools)
|
||||
: { tools: current.tools, events: NO_EVENTS }
|
||||
const events: LLMEvent[] = [...reconciled[1], ...pending.events]
|
||||
events.push(...pending.events)
|
||||
const hasFunctionCall =
|
||||
pending.events.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
|
||||
current.hasFunctionCall
|
||||
@@ -1346,7 +1384,7 @@ export const step = (state: ParserState, input: Event) => {
|
||||
if (event.type === "response.output_item.done") {
|
||||
if (event.item?.type === "message" && event.item.id === undefined)
|
||||
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
|
||||
return onOutputItemDone(state, event)
|
||||
return onOutputItemDone(state, event.item)
|
||||
}
|
||||
if (event.type === "response.completed" || event.type === "response.incomplete") return onResponseFinish(state, event)
|
||||
if (event.type === "response.failed") return providerFailure(event, `${state.name} response failed`)
|
||||
|
||||
@@ -815,7 +815,12 @@ const mapFinishReason = Effect.fn("OpenAIChat.mapFinishReason")(function* (event
|
||||
case "tool_calls":
|
||||
return "tool-calls" as const
|
||||
default:
|
||||
return "unknown" as const
|
||||
return yield* new AIError({
|
||||
reason: new UnknownProviderError({
|
||||
message: `Provider finish_reason: ${reason}`,
|
||||
body: ProviderShared.encodeJson(event),
|
||||
}),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1054,17 +1059,25 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
events.push(...result.events)
|
||||
}
|
||||
|
||||
if (finishReason !== undefined && state.finishReason === undefined && Object.keys(pendingTools).length > 0)
|
||||
const incompleteTools = finishReason?.normalized === "content-filter" || finishReason?.normalized === "length"
|
||||
if (
|
||||
finishReason !== undefined &&
|
||||
!incompleteTools &&
|
||||
state.finishReason === undefined &&
|
||||
Object.keys(pendingTools).length
|
||||
)
|
||||
return yield* ProviderShared.eventError(
|
||||
ADAPTER,
|
||||
"OpenAI Chat tool call delta is missing id or name",
|
||||
ProviderShared.encodeJson(event),
|
||||
)
|
||||
|
||||
// Finalize accumulated tool inputs eagerly when finish_reason arrives so
|
||||
// valid calls and malformed local calls settle independently.
|
||||
// Filtering or truncation terminates the response without confirming pending tool calls.
|
||||
const finished =
|
||||
finishReason !== undefined && state.finishReason === undefined && Object.keys(tools).length > 0
|
||||
finishReason !== undefined &&
|
||||
!incompleteTools &&
|
||||
state.finishReason === undefined &&
|
||||
Object.keys(tools).length > 0
|
||||
? yield* ToolStream.finishAll(ADAPTER, tools)
|
||||
: undefined
|
||||
|
||||
|
||||
@@ -76,6 +76,8 @@ const INVALID_REQUEST_CODES = new Set(["invalid_prompt", "invalid_request_error"
|
||||
const RATE_LIMIT_TEXT = /rate increased too quickly|rate[-_\s]?limit|too[_\s]?many[_\s]?requests/i
|
||||
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
|
||||
const CONTENT_POLICY_TEXT = /content[-_\s]?policy|content_filter|safety/i
|
||||
const SERVER_ERROR_TEXT =
|
||||
/\b(?:try again|(?:please |you can )?retry (?:the |this |your )?request|try (?:the |this |your )?request again|(?:currently |temporarily )?at capacity|overloaded|temporarily unavailable|service[-_\s]?unavailable|(?:server|internal)[-_\s]?error|server (?:is )?busy|provider returned (?:an )?error|resource[-_\s]?exhausted|upstream (?:connect|connection|request)|request buffer limit while retrying upstream)\b/i
|
||||
|
||||
export interface ProviderFailure {
|
||||
readonly message: string
|
||||
@@ -139,6 +141,9 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
|
||||
input.status === 408 ||
|
||||
input.status === 409 ||
|
||||
(input.status !== undefined && input.status >= 500) ||
|
||||
((input.status === undefined || input.status < 400) &&
|
||||
!codes.some((code) => INVALID_REQUEST_CODES.has(code)) &&
|
||||
SERVER_ERROR_TEXT.test(text)) ||
|
||||
codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable"))
|
||||
)
|
||||
return new ProviderInternalError({
|
||||
|
||||
@@ -267,7 +267,7 @@ export const CachePolicyObject = Schema.Struct({
|
||||
Schema.Union([
|
||||
Schema.Literal("latest-user-message"),
|
||||
Schema.Literal("latest-assistant"),
|
||||
Schema.Struct({ tail: Schema.Number }),
|
||||
Schema.Struct({ tail: Schema.Natural }),
|
||||
]),
|
||||
),
|
||||
ttlSeconds: Schema.optional(Schema.Number),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { CacheHint, LLM, Message } from "../src/index.js"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { CacheHint, CachePolicyObject, LLM, Message } from "../src/index.js"
|
||||
import { Auth } from "../src/route.js"
|
||||
import { compileRequest } from "../src/route/client.js"
|
||||
import { AmazonBedrock, GoogleVertexMessages } from "../src/providers.js"
|
||||
@@ -29,6 +29,37 @@ const geminiModel = Gemini.route
|
||||
})
|
||||
.model({ id: "gemini-2.5-flash" })
|
||||
|
||||
const decodeCachePolicyObject = Schema.decodeUnknownSync(CachePolicyObject)
|
||||
|
||||
describe("cache policy schema", () => {
|
||||
test.each([0, 2])("accepts messages.tail count %d when decoding and constructing", (tail) => {
|
||||
expect(decodeCachePolicyObject({ messages: { tail } })).toEqual({ messages: { tail } })
|
||||
expect(
|
||||
LLM.request({
|
||||
model: anthropicModel,
|
||||
prompt: "hi",
|
||||
cache: { messages: { tail } },
|
||||
}).cache,
|
||||
).toEqual({ messages: { tail } })
|
||||
})
|
||||
|
||||
test.each([
|
||||
["negative", -1],
|
||||
["fraction", 1.5],
|
||||
["NaN", Number.NaN],
|
||||
["Infinity", Number.POSITIVE_INFINITY],
|
||||
])("rejects a %s messages.tail when decoding and constructing", (_name, tail) => {
|
||||
expect(() => decodeCachePolicyObject({ messages: { tail } })).toThrow()
|
||||
expect(() =>
|
||||
LLM.request({
|
||||
model: anthropicModel,
|
||||
prompt: "hi",
|
||||
cache: { messages: { tail } },
|
||||
}),
|
||||
).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("applyCachePolicy", () => {
|
||||
it.effect("undefined cache resolves to 'auto' (the recommended default)", () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -316,6 +347,19 @@ describe("applyCachePolicy", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
test("messages: { tail: 0 } marks no message boundaries", () => {
|
||||
const request = LLM.request({
|
||||
model: anthropicModel,
|
||||
messages: [Message.user("u1"), Message.assistant("a1")],
|
||||
cache: { messages: { tail: 0 } },
|
||||
})
|
||||
|
||||
expect(applyCachePolicy(request)).toBe(request)
|
||||
expect(
|
||||
request.messages.flatMap((message) => message.content.map((part) => ("cache" in part ? part.cache : undefined))),
|
||||
).toEqual([undefined, undefined])
|
||||
})
|
||||
|
||||
it.effect("'latest-assistant' marks the last assistant message", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
|
||||
@@ -81,6 +81,46 @@ describe("provider error classification", () => {
|
||||
).toEqual(["ProviderInternal", "ProviderInternal", "ProviderInternal"])
|
||||
})
|
||||
|
||||
test("classifies retryable server messages as provider internal", () => {
|
||||
const message =
|
||||
"The model is currently at capacity due to high demand. Please try again in a few minutes, or use a higher service tier for priority processing."
|
||||
|
||||
expect(
|
||||
[
|
||||
message,
|
||||
"Try again",
|
||||
"Please retry your request shortly.",
|
||||
"You can retry the request.",
|
||||
"Try your request again.",
|
||||
"The service is temporarily at capacity.",
|
||||
"The model is overloaded.",
|
||||
"Service unavailable",
|
||||
"Internal server error",
|
||||
"The server is busy.",
|
||||
"Provider returned error",
|
||||
"Provider returned an error",
|
||||
"ResourceExhausted",
|
||||
"Upstream connection failed",
|
||||
"Exceeded request buffer limit while retrying upstream",
|
||||
].map((message) => classifyProviderFailure({ message })._tag),
|
||||
).toEqual(Array(15).fill("ProviderInternal"))
|
||||
expect(
|
||||
classifyProviderFailure({ message: "Provider request failed", rawBody: "Please try again later." })._tag,
|
||||
).toBe("ProviderInternal")
|
||||
})
|
||||
|
||||
test("prioritizes specific failures over retryable server text", () => {
|
||||
expect(
|
||||
[
|
||||
classifyProviderFailure({ message: "Invalid credentials, try again", status: 401 }),
|
||||
classifyProviderFailure({ message: "Quota exceeded, try again", status: 429 }),
|
||||
classifyProviderFailure({ message: "Rate limit exceeded, try again" }),
|
||||
classifyProviderFailure({ message: "Upstream request failed: validation failed", status: 400 }),
|
||||
classifyProviderFailure({ message: "Try again", status: 200 }),
|
||||
].map((failure) => failure._tag),
|
||||
).toEqual(["Authentication", "QuotaExceeded", "RateLimit", "InvalidRequest", "ProviderInternal"])
|
||||
})
|
||||
|
||||
test("classifies transient client statuses as provider internal", () => {
|
||||
expect([408, 409].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag)).toEqual([
|
||||
"ProviderInternal",
|
||||
@@ -111,7 +151,6 @@ describe("provider error classification", () => {
|
||||
expect(classifyProviderFailure({ message: '{"type":"error","error":{"code":123}}' })._tag).toBe("UnknownProvider")
|
||||
expect(classifyProviderFailure({ message: "not-json" })._tag).toBe("UnknownProvider")
|
||||
expect(classifyProviderFailure({ message: "network error" })._tag).toBe("UnknownProvider")
|
||||
expect(classifyProviderFailure({ message: "Provider returned error" })._tag).toBe("UnknownProvider")
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMEvent } from "../../src/index.js"
|
||||
import { OpenResponses } from "../../src/protocols/open-responses.js"
|
||||
import { configure } from "../../src/providers/openai-compatible-responses.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { fixedResponse } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
const request = LLM.request({
|
||||
model: configure({ apiKey: "test-key", baseURL: "https://responses.example.test/v1" }).model("example-model"),
|
||||
prompt: "Respond.",
|
||||
})
|
||||
const completed = { type: "response.completed", response: { id: "resp_1" } }
|
||||
const generate = (...events: OpenResponses.Event[]) =>
|
||||
LLMClient.generate(request).pipe(Effect.provide(fixedResponse(sseEvents(...events))))
|
||||
|
||||
describe("Open Responses completed item text", () => {
|
||||
;["Draft expanded", "D", "Replacement", ""].forEach((text) => {
|
||||
it.effect(`replaces streamed text with completed item text ${JSON.stringify(text)}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* generate(
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Draft" },
|
||||
{ type: "response.output_text.done", item_id: "msg_1", text: "Part final" },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "message", id: "msg_1", phase: "final_answer", content: [{ type: "output_text", text }] },
|
||||
},
|
||||
completed,
|
||||
)
|
||||
expect(response.text).toBe(text)
|
||||
expect(response.events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["Draft"])
|
||||
expect(response.events.filter(LLMEvent.is.textEnd)).toEqual([
|
||||
{
|
||||
type: "text-end",
|
||||
id: "msg_1",
|
||||
text,
|
||||
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "final_answer" } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("joins completed text and refusal parts without streamed text", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* generate(
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "message",
|
||||
id: "msg_1",
|
||||
content: [
|
||||
{ type: "output_text", text: "Answer. " },
|
||||
{ type: "refusal", refusal: "Cannot help." },
|
||||
],
|
||||
},
|
||||
},
|
||||
completed,
|
||||
)
|
||||
expect(response.text).toBe("Answer. Cannot help.")
|
||||
expect(response.events.filter(LLMEvent.is.textStart)).toHaveLength(1)
|
||||
expect(response.events.filter(LLMEvent.is.textEnd)).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not create an empty text fragment for an empty completed message", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* generate(
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
|
||||
{ type: "response.output_text.done", item_id: "msg_1", text: "" },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "message", id: "msg_1", content: [{ type: "output_text", text: "" }] },
|
||||
},
|
||||
completed,
|
||||
)
|
||||
expect(response.message.content).toEqual([])
|
||||
expect(response.events.filter(LLMEvent.is.textStart)).toEqual([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("Open Responses completed item reasoning", () => {
|
||||
;[
|
||||
{
|
||||
name: "summary",
|
||||
summary: [
|
||||
{ type: "summary_text", text: "Final" },
|
||||
{ type: "summary_text", text: "summary" },
|
||||
],
|
||||
content: [{ type: "reasoning_text", text: "Raw" }],
|
||||
text: "Final\n\nsummary",
|
||||
},
|
||||
{
|
||||
name: "raw text",
|
||||
summary: [
|
||||
{ type: "summary_text", text: "" },
|
||||
{ type: "summary_text", text: "" },
|
||||
],
|
||||
content: [{ type: "reasoning_text", text: "Raw" }],
|
||||
text: "Raw",
|
||||
},
|
||||
{
|
||||
name: "streamed fallback",
|
||||
summary: [
|
||||
{ type: "summary_text", text: "" },
|
||||
{ type: "summary_text", text: "" },
|
||||
],
|
||||
content: [
|
||||
{ type: "reasoning_text", text: "" },
|
||||
{ type: "reasoning_text", text: "" },
|
||||
],
|
||||
text: "Draft",
|
||||
},
|
||||
].forEach((fixture) => {
|
||||
it.effect(`uses ${fixture.name} at item completion`, () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* generate(
|
||||
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "Draft" },
|
||||
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", text: "Part final" },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
summary: fixture.summary,
|
||||
content: fixture.content,
|
||||
encrypted_content: "encrypted",
|
||||
},
|
||||
},
|
||||
completed,
|
||||
)
|
||||
expect(response.reasoning).toBe(fixture.text)
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
|
||||
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||
"openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "encrypted" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("replaces only the still-open summary without repeating earlier text", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* generate(
|
||||
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First " },
|
||||
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 1, delta: "draft" },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
summary: [
|
||||
{ type: "summary_text", text: "First " },
|
||||
{ type: "summary_text", text: "final" },
|
||||
],
|
||||
},
|
||||
},
|
||||
completed,
|
||||
)
|
||||
expect(response.reasoning).toBe("First final")
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd).map((event) => event.text)).toEqual([undefined, "final"])
|
||||
}),
|
||||
)
|
||||
})
|
||||
;["response.completed", "response.incomplete"].forEach((type) => {
|
||||
it.effect(`keeps streamed text when part finals are followed by ${type} without item completion`, () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* generate(
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", content_index: 0, delta: "Hel" },
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", content_index: 1, delta: "world" },
|
||||
{ type: "response.output_text.done", item_id: "msg_1", content_index: 0, text: "Hello " },
|
||||
{
|
||||
type: "response.content_part.done",
|
||||
item_id: "msg_1",
|
||||
content_index: 0,
|
||||
part: { type: "output_text", text: "Hello " },
|
||||
},
|
||||
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "Draft" },
|
||||
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", text: "Part final" },
|
||||
{
|
||||
type: "response.reasoning_summary_part.done",
|
||||
item_id: "rs_1",
|
||||
summary_index: 0,
|
||||
part: { type: "summary_text", text: "Part final" },
|
||||
},
|
||||
{
|
||||
type,
|
||||
response: {
|
||||
id: "resp_1",
|
||||
incomplete_details: type === "response.incomplete" ? { reason: "max_output_tokens" } : undefined,
|
||||
},
|
||||
},
|
||||
)
|
||||
expect(response.text).toBe("Helworld")
|
||||
expect(response.reasoning).toBe("Draft")
|
||||
expect(response.events.filter(LLMEvent.is.textEnd).map((event) => event.text)).toEqual([undefined])
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd).map((event) => event.text)).toEqual([undefined])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -129,7 +129,7 @@ describe("Open Responses basic-item lifecycles", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves done-only encrypted reasoning without replaying its summary or late events", () =>
|
||||
it.effect("preserves done-only reasoning text and encryption without replaying late events", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = {
|
||||
type: "reasoning",
|
||||
@@ -157,6 +157,7 @@ describe("Open Responses basic-item lifecycles", () => {
|
||||
{
|
||||
type: "reasoning-end",
|
||||
id: "rs_1",
|
||||
text: "Not streamed",
|
||||
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
])
|
||||
@@ -301,7 +302,7 @@ describe("Open Responses basic-item lifecycles", () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("recovers pending items in completed output order with terminal encrypted metadata", () =>
|
||||
it.effect("recovers pending calls without reconciling terminal reasoning", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* collect(
|
||||
{
|
||||
@@ -325,11 +326,6 @@ describe("Open Responses basic-item lifecycles", () => {
|
||||
},
|
||||
)
|
||||
expect(events.slice(5, -2)).toEqual([
|
||||
{
|
||||
type: "reasoning-end",
|
||||
id: "rs_1:0",
|
||||
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "terminal-state" } },
|
||||
},
|
||||
{
|
||||
type: "tool-input-end",
|
||||
id: "call_1",
|
||||
@@ -341,8 +337,10 @@ describe("Open Responses basic-item lifecycles", () => {
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
input: { query: "final" },
|
||||
providerExecuted: undefined,
|
||||
providerMetadata: { "openai-compatible": { itemId: "fc_1" } },
|
||||
},
|
||||
{ type: "reasoning-end", id: "rs_1:0" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1460,6 +1460,162 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not finalize streamed tool calls when content is filtered", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
deltaChunk({
|
||||
tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: '{"query":"weather"' } }],
|
||||
}),
|
||||
deltaChunk({}, "content_filter"),
|
||||
)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.events).toEqual([
|
||||
{ type: "step-start", index: 0 },
|
||||
{
|
||||
type: "tool-input-start",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
providerExecuted: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{
|
||||
type: "tool-input-delta",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
text: '{"query":"weather"',
|
||||
input: { query: "weather" },
|
||||
},
|
||||
{
|
||||
type: "step-finish",
|
||||
index: 0,
|
||||
reason: { normalized: "content-filter", raw: "content_filter" },
|
||||
usage: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{ type: "finish", reason: { normalized: "content-filter", raw: "content_filter" }, usage: undefined },
|
||||
])
|
||||
expect(response.toolCalls).toEqual([])
|
||||
|
||||
const missingIdentity = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
deltaChunk({ tool_calls: [{ index: 0, id: "call_2", function: { arguments: "{}" } }] }),
|
||||
deltaChunk({}, "content_filter"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(missingIdentity.finishReason).toEqual({ normalized: "content-filter", raw: "content_filter" })
|
||||
expect(missingIdentity.toolCalls).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not finalize streamed tool calls when output is truncated", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
deltaChunk({
|
||||
tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: '{"query":"weather"}' } }],
|
||||
}),
|
||||
deltaChunk({}, "length"),
|
||||
)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.events).toEqual([
|
||||
{ type: "step-start", index: 0 },
|
||||
{
|
||||
type: "tool-input-start",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
providerExecuted: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{
|
||||
type: "tool-input-delta",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
text: '{"query":"weather"}',
|
||||
input: { query: "weather" },
|
||||
},
|
||||
{
|
||||
type: "step-finish",
|
||||
index: 0,
|
||||
reason: { normalized: "length", raw: "length" },
|
||||
usage: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{ type: "finish", reason: { normalized: "length", raw: "length" }, usage: undefined },
|
||||
])
|
||||
expect(response.toolCalls).toEqual([])
|
||||
|
||||
const missingIdentity = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
deltaChunk({ tool_calls: [{ index: 0, id: "call_2", function: { arguments: "{}" } }] }),
|
||||
deltaChunk({}, "length"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(missingIdentity.finishReason).toEqual({ normalized: "length", raw: "length" })
|
||||
expect(missingIdentity.toolCalls).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects unknown finish reasons without finalizing streamed tool calls", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
deltaChunk({
|
||||
tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: '{"query":"weather"' } }],
|
||||
}),
|
||||
deltaChunk({}, "future_reason"),
|
||||
)
|
||||
const events = yield* Ref.make<ReadonlyArray<LLMEvent>>([])
|
||||
const error = yield* LLMClient.stream(
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
}),
|
||||
).pipe(
|
||||
Stream.tap((event) => Ref.update(events, (current) => [...current, event])),
|
||||
Stream.runDrain,
|
||||
Effect.provide(fixedResponse(body)),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({
|
||||
reason: { _tag: "UnknownProvider" },
|
||||
message: "Provider finish_reason: future_reason",
|
||||
})
|
||||
expect(yield* Ref.get(events)).toEqual([
|
||||
{ type: "step-start", index: 0 },
|
||||
{
|
||||
type: "tool-input-start",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
providerExecuted: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{
|
||||
type: "tool-input-delta",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
text: '{"query":"weather"',
|
||||
input: { query: "weather" },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores empty identity fields on later tool call deltas", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
|
||||
@@ -554,6 +554,19 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
reason: { _tag: "UnknownProvider" },
|
||||
message: "Provider reported an error (finish_reason: error)",
|
||||
})
|
||||
|
||||
const unknown = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(deltaChunk({}, "future_reason")))),
|
||||
Effect.flip,
|
||||
)
|
||||
expect(unknown).toMatchObject({
|
||||
reason: { _tag: "UnknownProvider" },
|
||||
message: "Provider finish_reason: future_reason",
|
||||
})
|
||||
expect(decodeJson(unknown.reason.body ?? "")).toMatchObject({
|
||||
id: "chatcmpl_fixture",
|
||||
choices: [{ finish_reason: "future_reason" }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -581,17 +594,13 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves provider finish outcomes in the common reason algebra", () =>
|
||||
it.effect("preserves content-filter finishes in the common reason algebra", () =>
|
||||
Effect.gen(function* () {
|
||||
const filtered = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(deltaChunk({}, "content_filter")))),
|
||||
)
|
||||
const future = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(deltaChunk({}, "future_reason")))),
|
||||
)
|
||||
|
||||
expect(filtered.finishReason).toEqual({ normalized: "content-filter", raw: "content_filter" })
|
||||
expect(future.finishReason).toEqual({ normalized: "unknown", raw: "future_reason" })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -406,7 +406,7 @@ describe("Open Responses-compatible route", () => {
|
||||
})
|
||||
|
||||
routings.forEach((routing) => {
|
||||
it.effect(`preserves reasoning summary boundaries and terminal metadata with ${routing.name}`, () =>
|
||||
it.effect(`preserves reasoning summary boundaries without terminal reconciliation with ${routing.name}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const address = { item_id: routing.item_id, output_index: routing.output_index }
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
@@ -444,21 +444,18 @@ describe("Open Responses-compatible route", () => {
|
||||
type: "reasoning",
|
||||
text: "Second.",
|
||||
providerMetadata: {
|
||||
"openai-compatible": { itemId: routing.id, reasoningEncryptedContent: "final-state" },
|
||||
"openai-compatible": { itemId: routing.id, reasoningEncryptedContent: null },
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toEqual([
|
||||
expect.objectContaining({
|
||||
{
|
||||
type: "reasoning-end",
|
||||
id: `${routing.id}:0`,
|
||||
text: undefined,
|
||||
providerMetadata: { "openai-compatible": { itemId: routing.id } },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: `${routing.id}:1`,
|
||||
providerMetadata: {
|
||||
"openai-compatible": { itemId: routing.id, reasoningEncryptedContent: "final-state" },
|
||||
},
|
||||
}),
|
||||
},
|
||||
{ type: "reasoning-end", id: `${routing.id}:1` },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -671,7 +668,7 @@ describe("Open Responses-compatible route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves terminal reasoning metadata when item completion is missing", () =>
|
||||
it.effect("ignores terminal reasoning output when item completion is missing", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
apiKey: "test-key",
|
||||
@@ -697,8 +694,9 @@ describe("Open Responses-compatible route", () => {
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
|
||||
providerMetadata: { "openai-compatible": { itemId: "rs_raw", reasoningEncryptedContent: "raw-state" } },
|
||||
expect(response.events.find((event) => event.type === "reasoning-end")).toEqual({
|
||||
type: "reasoning-end",
|
||||
id: "rs_raw:0",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2554,7 +2554,7 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves terminal reasoning metadata when output item completion is missing", () =>
|
||||
it.effect("ignores terminal reasoning output when item completion is missing", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, { providerOptions: { store: false } }),
|
||||
@@ -2595,29 +2595,13 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
expect(response.reasoning).toBe("Checked the diff.")
|
||||
expect(response.events.filter((event) => event.type === "reasoning-end")).toEqual([
|
||||
{
|
||||
type: "reasoning-end",
|
||||
id: "rs_1:0",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "terminal-state" } },
|
||||
},
|
||||
{ type: "reasoning-end", id: "rs_1:0" },
|
||||
])
|
||||
expect(response.message.content).toContainEqual({
|
||||
type: "reasoning",
|
||||
text: "Checked the diff.",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "terminal-state" } },
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
|
||||
})
|
||||
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({ model, messages: [response.message], providerOptions: { store: false } }),
|
||||
)
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
summary: [{ type: "summary_text", text: "Checked the diff." }],
|
||||
encrypted_content: "terminal-state",
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2644,7 +2628,7 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reconciles pending reasoning and function calls in completed output order", () =>
|
||||
it.effect("recovers pending function calls without reconciling terminal reasoning", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, { providerOptions: { store: false } }),
|
||||
@@ -2682,14 +2666,15 @@ describe("OpenAI Responses route", () => {
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "terminal-state" } },
|
||||
expect(response.events.find((event) => event.type === "reasoning-end")).toEqual({
|
||||
type: "reasoning-end",
|
||||
id: "rs_1:0",
|
||||
})
|
||||
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
|
||||
expect.objectContaining({ id: "call_1", input: { query: "weather" } }),
|
||||
])
|
||||
expect(response.events.findIndex((event) => event.type === "reasoning-end")).toBeLessThan(
|
||||
response.events.findIndex(LLMEvent.is.toolCall),
|
||||
expect(response.events.findIndex(LLMEvent.is.toolCall)).toBeLessThan(
|
||||
response.events.findIndex((event) => event.type === "reasoning-end"),
|
||||
)
|
||||
expect(response.finishReason.normalized).toBe("tool-calls")
|
||||
}),
|
||||
@@ -3019,6 +3004,7 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "reasoning-end",
|
||||
id: "rs_1:0",
|
||||
text: "Checked the diff.",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
])
|
||||
|
||||
@@ -20,95 +20,134 @@ const inventory: WorktreeDirectory[] = [
|
||||
|
||||
test.use({ serviceWorkers: "block" })
|
||||
|
||||
for (const scenario of [
|
||||
{ name: "managed Git worktree", directory: workspace, accent: true },
|
||||
{ name: "linked Git worktree outside main", directory: "C:/OpenCode/LinkedWorkspace", accent: true },
|
||||
{
|
||||
name: "linked Git worktree on a narrow screen",
|
||||
directory: "C:/OpenCode/LinkedWorkspace",
|
||||
accent: true,
|
||||
viewport: { width: 390, height: 844 },
|
||||
},
|
||||
{ name: "main root with Windows case and separators", directory: "c:\\OPENCODE\\workspaceaccent\\", accent: false },
|
||||
{ name: "nested main directory", directory: `${root}/packages/app`, accent: false },
|
||||
{ name: "nested workspace inside main", directory: `${workspace}/packages/app`, accent: true },
|
||||
{
|
||||
name: "workspace with Windows case and separators",
|
||||
directory: "c:\\opencode\\WORKSPACEACCENT\\.worktrees\\FEATURE\\src\\",
|
||||
accent: true,
|
||||
},
|
||||
{ name: "unregistered sibling with the same prefix", directory: `${workspace}-unregistered`, accent: false },
|
||||
{ name: "workspace using another strategy", directory: "C:/OpenCode/WorkspaceCopy", accent: true },
|
||||
{ name: "registered directory without a strategy", directory: "C:/OpenCode/RegisteredDirectory", accent: true },
|
||||
]) {
|
||||
test(`existing session send button: ${scenario.name}`, async ({ page }, testInfo) => {
|
||||
if (scenario.viewport) await page.setViewportSize(scenario.viewport)
|
||||
const view = await openSession(page, scenario.directory)
|
||||
await view.input.fill("Inspect this fixture workspace.")
|
||||
await expect(view.send).toBeEnabled()
|
||||
for (const theme of ["light", "dark"] as const) {
|
||||
test.describe(theme, () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript((theme) => {
|
||||
localStorage.setItem("opencode-theme-id", "oc-2")
|
||||
localStorage.setItem("opencode-color-scheme", theme)
|
||||
}, theme)
|
||||
})
|
||||
|
||||
if (scenario.name === "managed Git worktree") {
|
||||
// Capture before the color assertion so both red and green runs have evidence.
|
||||
const path = testInfo.outputPath("workspace-accent.png")
|
||||
await view.composer.screenshot({ path })
|
||||
await testInfo.attach("workspace-accent", { path, contentType: "image/png" })
|
||||
for (const scenario of [
|
||||
{ name: "managed Git worktree", directory: workspace, accent: true },
|
||||
{ name: "linked Git worktree outside main", directory: "C:/OpenCode/LinkedWorkspace", accent: true },
|
||||
{
|
||||
name: "linked Git worktree on a narrow screen",
|
||||
directory: "C:/OpenCode/LinkedWorkspace",
|
||||
accent: true,
|
||||
viewport: { width: 390, height: 844 },
|
||||
},
|
||||
{
|
||||
name: "main root with Windows case and separators",
|
||||
directory: "c:\\OPENCODE\\workspaceaccent\\",
|
||||
accent: false,
|
||||
},
|
||||
{ name: "nested main directory", directory: `${root}/packages/app`, accent: false },
|
||||
{ name: "nested workspace inside main", directory: `${workspace}/packages/app`, accent: true },
|
||||
{
|
||||
name: "workspace with Windows case and separators",
|
||||
directory: "c:\\opencode\\WORKSPACEACCENT\\.worktrees\\FEATURE\\src\\",
|
||||
accent: true,
|
||||
},
|
||||
{ name: "unregistered sibling with the same prefix", directory: `${workspace}-unregistered`, accent: false },
|
||||
{ name: "workspace using another strategy", directory: "C:/OpenCode/WorkspaceCopy", accent: true },
|
||||
{ name: "registered directory without a strategy", directory: "C:/OpenCode/RegisteredDirectory", accent: true },
|
||||
]) {
|
||||
test(`existing session send button: ${scenario.name}`, async ({ page }, testInfo) => {
|
||||
if (scenario.viewport) await page.setViewportSize(scenario.viewport)
|
||||
const view = await openSession(page, scenario.directory)
|
||||
await view.input.fill("Inspect this fixture workspace.")
|
||||
await expect(view.send).toBeEnabled()
|
||||
|
||||
if (scenario.name === "managed Git worktree") {
|
||||
// Capture before the color assertion so both red and green runs have evidence.
|
||||
const path = testInfo.outputPath("workspace-accent.png")
|
||||
await view.composer.screenshot({ path })
|
||||
await testInfo.attach("workspace-accent", { path, contentType: "image/png" })
|
||||
}
|
||||
|
||||
await expectBackground(view.send, "contrast")
|
||||
await view.send.hover()
|
||||
await expectBackground(view.send, "contrast")
|
||||
await view.composer.locator('[data-action="composer-model"]').press("Tab")
|
||||
await expect(view.send).toBeFocused()
|
||||
await expectBackground(view.send, "contrast")
|
||||
const message = page.locator('[data-slot="user-message-text"]')
|
||||
await expect(message).toHaveText("Check this fixture workspace.")
|
||||
await expectBackground(
|
||||
message,
|
||||
scenario.accent ? "accent" : theme === "light" ? "layer-02" : "layer-01",
|
||||
"background-color",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
await expectBackground(view.send, scenario.accent ? "accent" : "contrast")
|
||||
const message = page.locator('[data-slot="user-message-text"]')
|
||||
await expect(message).toHaveText("Check this fixture workspace.")
|
||||
await expectBackground(message, scenario.accent ? "accent" : "layer-02", "background-color")
|
||||
test("inventory updates leave send neutral; disabled and stop stay neutral", async ({ page }) => {
|
||||
const view = await openSession(page, workspace, [{ directory: root }])
|
||||
await view.input.fill("Keep this draft while the inventory changes.")
|
||||
await expect(view.send).toBeEnabled()
|
||||
await expectBackground(view.send, "contrast")
|
||||
const url = page.url()
|
||||
|
||||
const refreshed = page.waitForResponse(
|
||||
(response) =>
|
||||
new URL(response.url()).pathname === `/api/worktree/${projectID}` && response.request().method() === "GET",
|
||||
)
|
||||
view.worktrees.push({ directory: workspace, strategy: "git" })
|
||||
view.events.push({
|
||||
id: "evt_workspace_accent_inventory",
|
||||
created: 1700000001000,
|
||||
type: "worktree.updated",
|
||||
data: { projectID },
|
||||
})
|
||||
expect((await refreshed).ok()).toBe(true)
|
||||
await expectBackground(view.send, "contrast")
|
||||
await expect(page).toHaveURL(url)
|
||||
await expect(view.input).toHaveText("Keep this draft while the inventory changes.")
|
||||
await expect(view.send).toBeEnabled()
|
||||
|
||||
await view.input.fill("")
|
||||
await expect(view.send).toBeDisabled()
|
||||
await expectBackground(view.send, "contrast")
|
||||
|
||||
view.events.push({
|
||||
id: "evt_workspace_accent_running",
|
||||
created: 1700000002000,
|
||||
type: "session.execution.started",
|
||||
durable: { aggregateID: sessionID, seq: 1, version: 1 },
|
||||
data: { sessionID },
|
||||
})
|
||||
const stop = view.composer.getByRole("button", { name: "Stop", exact: true })
|
||||
await expect(stop).toBeEnabled()
|
||||
await expectBackground(stop, "contrast")
|
||||
|
||||
await view.input.fill("Send a follow-up instead of stopping.")
|
||||
await expect(view.send).toBeEnabled()
|
||||
await expectBackground(view.send, "contrast")
|
||||
await expect(page).toHaveURL(url)
|
||||
})
|
||||
|
||||
test("new workspace send button stays neutral", async ({ page }) => {
|
||||
const view = await openSession(page, root, [...inventory], true)
|
||||
await expect(view.send).toBeDisabled()
|
||||
await expectBackground(view.send, "contrast")
|
||||
await page.getByRole("button", { name: "Local", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "New workspace", exact: true }).click()
|
||||
await expect(page.getByRole("button", { name: "New workspace", exact: true })).toBeVisible()
|
||||
await view.input.fill("Inspect this fixture workspace.")
|
||||
await expect(view.send).toBeEnabled()
|
||||
await expectBackground(view.send, "contrast")
|
||||
await view.send.hover()
|
||||
await expectBackground(view.send, "contrast")
|
||||
await view.composer.locator('[data-action="composer-model"]').press("Tab")
|
||||
await expect(view.send).toBeFocused()
|
||||
await expectBackground(view.send, "contrast")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
test("inventory updates recolor the send button without navigation; disabled and stop stay neutral", async ({
|
||||
page,
|
||||
}) => {
|
||||
const view = await openSession(page, workspace, [{ directory: root }])
|
||||
await view.input.fill("Keep this draft while the inventory changes.")
|
||||
await expect(view.send).toBeEnabled()
|
||||
await expectBackground(view.send, "contrast")
|
||||
const url = page.url()
|
||||
|
||||
const refreshed = page.waitForResponse(
|
||||
(response) =>
|
||||
new URL(response.url()).pathname === `/api/worktree/${projectID}` && response.request().method() === "GET",
|
||||
)
|
||||
view.worktrees.push({ directory: workspace, strategy: "git" })
|
||||
view.events.push({
|
||||
id: "evt_workspace_accent_inventory",
|
||||
created: 1700000001000,
|
||||
type: "worktree.updated",
|
||||
data: { projectID },
|
||||
})
|
||||
expect((await refreshed).ok()).toBe(true)
|
||||
await expectBackground(view.send, "accent")
|
||||
await expect(page).toHaveURL(url)
|
||||
await expect(view.input).toHaveText("Keep this draft while the inventory changes.")
|
||||
await expect(view.send).toBeEnabled()
|
||||
|
||||
await view.input.fill("")
|
||||
await expect(view.send).toBeDisabled()
|
||||
await expectBackground(view.send, "contrast")
|
||||
|
||||
view.events.push({
|
||||
id: "evt_workspace_accent_running",
|
||||
created: 1700000002000,
|
||||
type: "session.execution.started",
|
||||
durable: { aggregateID: sessionID, seq: 1, version: 1 },
|
||||
data: { sessionID },
|
||||
})
|
||||
const stop = view.composer.getByRole("button", { name: "Stop", exact: true })
|
||||
await expect(stop).toBeEnabled()
|
||||
await expectBackground(stop, "contrast")
|
||||
|
||||
await view.input.fill("Send a follow-up instead of stopping.")
|
||||
await expect(view.send).toBeEnabled()
|
||||
await expectBackground(view.send, "accent")
|
||||
await expect(page).toHaveURL(url)
|
||||
})
|
||||
|
||||
async function openSession(page: Page, directory: string, worktrees = [...inventory]) {
|
||||
async function openSession(page: Page, directory: string, worktrees = [...inventory], draft = false) {
|
||||
const events: OpenCodeEvent[] = []
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
@@ -159,18 +198,32 @@ async function openSession(page: Page, directory: string, worktrees = [...invent
|
||||
if (route.request().method() !== "GET") return route.fallback()
|
||||
return route.fulfill({ json: worktrees, headers: { "access-control-allow-origin": "*" } })
|
||||
})
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("opencode-theme-id", "oc-2")
|
||||
localStorage.setItem("opencode-color-scheme", "light")
|
||||
})
|
||||
if (draft)
|
||||
await page.addInitScript(
|
||||
({ root, server }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: root, expanded: true }] },
|
||||
lastProject: { local: root },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "draft", draftID: "draft_workspace_accent", server, directory: root }]),
|
||||
)
|
||||
},
|
||||
{ root, server },
|
||||
)
|
||||
const loaded = page.waitForResponse(
|
||||
(response) =>
|
||||
new URL(response.url()).pathname === `/api/worktree/${projectID}` && response.request().method() === "GET",
|
||||
)
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await page.goto(
|
||||
draft ? "/new-session?draftId=draft_workspace_accent" : `/server/${base64Encode(server)}/session/${sessionID}`,
|
||||
)
|
||||
expect((await loaded).ok()).toBe(true)
|
||||
await expectSessionReady(page, { server, sessionID, title })
|
||||
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "light")
|
||||
if (!draft) await expectSessionReady(page, { server, sessionID, title })
|
||||
const composer = page.locator('[data-component="composer"]')
|
||||
await expectAppVisible(composer)
|
||||
const input = composer.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
|
||||
@@ -12,12 +12,7 @@ import { formatKeybind, useCommand } from "@/shell/commands/command"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { ComposerModel } from "./model"
|
||||
|
||||
export function Composer(props: {
|
||||
class?: string
|
||||
model: ComposerModel
|
||||
borderUnderlay?: boolean
|
||||
accentSubmit?: boolean
|
||||
}) {
|
||||
export function Composer(props: { class?: string; model: ComposerModel; borderUnderlay?: boolean }) {
|
||||
const dialog = useDialog()
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
@@ -26,7 +21,6 @@ export function Composer(props: {
|
||||
<div class="flex flex-col gap-3">
|
||||
<ComposerEditor
|
||||
controller={props.model}
|
||||
accentSubmit={props.accentSubmit}
|
||||
borderUnderlay={props.borderUnderlay}
|
||||
class={props.class}
|
||||
modelControlsVisible={!props.model.model.loading}
|
||||
|
||||
@@ -37,7 +37,6 @@ export type ComposerMode = "normal" | "shell"
|
||||
|
||||
export type ComposerEditorProps = {
|
||||
controller: ComposerEditorModel
|
||||
accentSubmit?: boolean
|
||||
disabled?: boolean
|
||||
readOnly?: boolean
|
||||
borderUnderlay?: boolean
|
||||
@@ -265,7 +264,6 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
mode={state.mode}
|
||||
stopping={view.submit.stopping()}
|
||||
disabled={!props.controller.canSubmit()}
|
||||
accent={props.accentSubmit}
|
||||
sendLabel={i18n.t("ui.promptInput.send")}
|
||||
stopLabel={i18n.t("ui.promptInput.stop")}
|
||||
onSubmit={() => props.controller.submit()}
|
||||
@@ -751,7 +749,6 @@ export function ComposerEditorSubmitButton(props: {
|
||||
mode: ComposerMode
|
||||
stopping: boolean
|
||||
disabled: boolean
|
||||
accent?: boolean
|
||||
sendLabel: string
|
||||
stopLabel: string
|
||||
onSubmit: () => void
|
||||
@@ -770,16 +767,10 @@ export function ComposerEditorSubmitButton(props: {
|
||||
tabIndex={props.mode === "normal" ? undefined : -1}
|
||||
icon={<Icon name={props.stopping ? "stop" : props.mode === "shell" ? "arrow-undo-down" : "arrow-up"} />}
|
||||
variant="contrast"
|
||||
class="size-7 rounded-md p-[6px] shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50"
|
||||
classList={{
|
||||
"text-v2-text-text-contrast": !!props.accent && !props.stopping && !props.disabled,
|
||||
"text-v2-icon-icon-muted": !props.accent || props.stopping || props.disabled,
|
||||
}}
|
||||
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50"
|
||||
style={{
|
||||
"background-image":
|
||||
props.accent && !props.stopping && !props.disabled
|
||||
? "linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-accent) 0%,var(--v2-background-bg-accent) 100%)"
|
||||
: "linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-contrast) 0%,var(--v2-background-bg-contrast) 100%)",
|
||||
"linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-contrast) 0%,var(--v2-background-bg-contrast) 100%)",
|
||||
}}
|
||||
aria-label={props.stopping ? props.stopLabel : props.sendLabel}
|
||||
onClick={(event) => {
|
||||
|
||||
@@ -48,7 +48,7 @@ export function NewSessionView(props: {
|
||||
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
||||
<Wordmark class="h-auto w-full text-v2-background-bg-inverse" />
|
||||
<div class="mt-8 flex flex-col gap-8">
|
||||
<Composer model={props.composer} accentSubmit={props.workspace.selection.workspace()} />
|
||||
<Composer model={props.composer} />
|
||||
<Show when={props.project.empty()}>
|
||||
<PromptProjectAddButton controller={props.project} />
|
||||
</Show>
|
||||
|
||||
@@ -216,7 +216,6 @@ export type ActiveSessionRegionModel = ReturnType<typeof createActiveSessionRegi
|
||||
export function ActiveSessionComposerRegion(props: {
|
||||
model: ActiveSessionRegionModel
|
||||
session: SessionModel
|
||||
accentSubmit: boolean
|
||||
onResponseSubmit: () => void
|
||||
}) {
|
||||
const settings = useSettings()
|
||||
@@ -251,7 +250,7 @@ export function ActiveSessionComposerRegion(props: {
|
||||
<div class="relative">
|
||||
<SessionQueuePanel queue={queue} />
|
||||
<div class="relative z-10">
|
||||
<Composer model={composer} borderUnderlay accentSubmit={props.accentSubmit} />
|
||||
<Composer model={composer} borderUnderlay />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -145,12 +145,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
|
||||
<Show when={!review.mobile.changes() ? session.identity.params.id : undefined} keyed>
|
||||
{(_id) => (
|
||||
<ActiveSessionComposerRegion
|
||||
model={composer}
|
||||
session={session}
|
||||
accentSubmit={session.workspace.current()}
|
||||
onResponseSubmit={timeline.actions.resume}
|
||||
/>
|
||||
<ActiveSessionComposerRegion model={composer} session={session} onResponseSubmit={timeline.actions.resume} />
|
||||
)}
|
||||
</Show>
|
||||
<Show when={!!session.identity.params.id && mobileTabsBottom()}>
|
||||
|
||||
@@ -375,6 +375,11 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
params: {
|
||||
hostname: Flag.string("hostname").pipe(Flag.optional),
|
||||
port: Flag.integer("port").pipe(Flag.optional),
|
||||
cors: Flag.string("cors").pipe(
|
||||
Flag.withSchema(Schema.NonEmptyString),
|
||||
Flag.withDescription("Additional allowed CORS origin (repeat for multiple origins)"),
|
||||
Flag.atLeast(0),
|
||||
),
|
||||
service: Flag.boolean("service").pipe(Flag.withDefault(false)),
|
||||
stdio: Flag.boolean("stdio").pipe(Flag.withDefault(false)),
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ export default Runtime.handler(
|
||||
mode: input.service ? "service" : input.stdio ? "stdio" : "default",
|
||||
hostname: Option.getOrUndefined(input.hostname),
|
||||
port: Option.getOrUndefined(input.port),
|
||||
cors: input.cors.length > 0 ? input.cors : undefined,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -22,6 +22,7 @@ export type Options = {
|
||||
readonly mode: Mode
|
||||
readonly hostname?: string
|
||||
readonly port?: number
|
||||
readonly cors?: readonly string[]
|
||||
}
|
||||
|
||||
// The process effect lives until server shutdown; tracing it would parent every request to one process-lifetime trace.
|
||||
@@ -88,6 +89,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
},
|
||||
hostname,
|
||||
port,
|
||||
cors: options.cors ?? config.cors,
|
||||
password,
|
||||
pty: { handoff },
|
||||
simulation: truthy(process.env.OPENCODE_SIMULATE),
|
||||
|
||||
@@ -15,11 +15,12 @@ export const Info = Schema.Struct({
|
||||
hostname: Schema.optional(Schema.String),
|
||||
port: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(65_535))),
|
||||
password: Schema.optional(Schema.String),
|
||||
cors: Schema.optional(Schema.Array(Schema.String)),
|
||||
env: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
})
|
||||
export type Info = typeof Info.Type
|
||||
|
||||
const keys = ["hostname", "port", "password", "env"] as const
|
||||
const keys = ["hostname", "port", "password", "cors", "env"] as const
|
||||
type Key = (typeof keys)[number]
|
||||
|
||||
const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
|
||||
@@ -77,7 +78,7 @@ export const migrateConfig = Effect.fnUntraced(function* (legacy: string, file:
|
||||
})
|
||||
|
||||
function configKey(key: string): Key {
|
||||
if (key === "hostname" || key === "port" || key === "password" || key === "env") return key
|
||||
if (key === "hostname" || key === "port" || key === "password" || key === "cors" || key === "env") return key
|
||||
throw new Error(`Unknown service config key: ${key}`)
|
||||
}
|
||||
|
||||
@@ -160,6 +161,9 @@ export const get = Effect.fn("cli.service-config.get")(function* (key?: string,
|
||||
case "password": {
|
||||
return yield* password()
|
||||
}
|
||||
case "cors": {
|
||||
return JSON.stringify((yield* read()).cors ?? [], null, 2)
|
||||
}
|
||||
case "env": {
|
||||
const env = (yield* read()).env ?? {}
|
||||
return name === undefined ? JSON.stringify(env, null, 2) : (env[name] ?? "")
|
||||
@@ -197,6 +201,19 @@ export const set = Effect.fn("cli.service-config.set")(function* (key: string, v
|
||||
yield* write({ ...existing, env: { ...existing.env, [value]: nestedValue } })
|
||||
return
|
||||
}
|
||||
case "cors": {
|
||||
const cors = value.split(",").map((origin) => origin.trim())
|
||||
if (
|
||||
cors.some((origin) => {
|
||||
const url = URL.parse(origin)
|
||||
return !url || (url.protocol !== "http:" && url.protocol !== "https:") || url.origin !== origin
|
||||
})
|
||||
)
|
||||
throw new Error("CORS must be a comma-separated list of HTTP(S) origins without paths or trailing slashes")
|
||||
yield* Service.stop(yield* options())
|
||||
yield* write({ ...(yield* read()), cors })
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -231,6 +248,12 @@ export const unset = Effect.fn("cli.service-config.unset")(function* (key: strin
|
||||
yield* write(Object.keys(env).length === 0 ? rest : { ...rest, env })
|
||||
return
|
||||
}
|
||||
case "cors": {
|
||||
yield* Service.stop(yield* options())
|
||||
const { cors: _cors, ...next } = yield* read()
|
||||
yield* write(next)
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { NodeServices } from "@effect/platform-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { expect, test } from "bun:test"
|
||||
import { Effect, Exit, FileSystem } from "effect"
|
||||
import { Command } from "effect/unstable/cli"
|
||||
import path from "node:path"
|
||||
import { Commands } from "../src/commands/commands"
|
||||
import { ServiceConfig } from "../src/services/service-config"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
|
||||
it.live("service CORS config persists multiple origins and preserves other settings on set and unset", () =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const root = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-cors-" })
|
||||
const config = path.join(root, "config")
|
||||
const state = path.join(root, "state")
|
||||
const file = path.join(config, ServiceConfig.filename())
|
||||
const existing = { hostname: "127.0.0.1", port: 4321, password: "test-secret", env: { TEST: "value" } }
|
||||
yield* fs.makeDirectory(config)
|
||||
yield* fs.makeDirectory(state)
|
||||
yield* fs.writeFileString(file, JSON.stringify(existing))
|
||||
yield* Effect.gen(function* () {
|
||||
expect(yield* ServiceConfig.get("cors")).toBe("[]")
|
||||
yield* ServiceConfig.set("cors", " http://192.0.2.10:3001, https://app.example.com ")
|
||||
const cors = ["http://192.0.2.10:3001", "https://app.example.com"]
|
||||
expect(yield* ServiceConfig.read()).toEqual({ ...existing, cors })
|
||||
expect(yield* ServiceConfig.get("cors")).toBe(JSON.stringify(cors, null, 2))
|
||||
expect(JSON.parse(yield* ServiceConfig.get())).toEqual({
|
||||
hostname: existing.hostname,
|
||||
port: existing.port,
|
||||
env: existing.env,
|
||||
cors,
|
||||
})
|
||||
expect(JSON.parse(yield* fs.readFileString(file))).toEqual({ ...existing, cors })
|
||||
yield* ServiceConfig.set("cors", "https://replacement.example.com")
|
||||
expect((yield* ServiceConfig.read()).cors).toEqual(["https://replacement.example.com"])
|
||||
yield* ServiceConfig.unset("cors")
|
||||
expect(yield* ServiceConfig.get("cors")).toBe("[]")
|
||||
expect(JSON.parse(yield* fs.readFileString(file))).toEqual(existing)
|
||||
}).pipe(Effect.provideService(Global.Service, Global.make({ config, state })))
|
||||
}).pipe(Effect.provide(NodeServices.layer)),
|
||||
)
|
||||
|
||||
it.live("service CORS config rejects empty lists, invalid origins, and extra arguments without changing config", () =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const root = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-cors-invalid-" })
|
||||
const config = path.join(root, "config")
|
||||
const state = path.join(root, "state")
|
||||
const file = path.join(config, ServiceConfig.filename())
|
||||
const existing = { port: 4321, cors: ["https://app.example.com"] }
|
||||
yield* fs.makeDirectory(config)
|
||||
yield* fs.makeDirectory(state)
|
||||
yield* fs.writeFileString(file, JSON.stringify(existing))
|
||||
yield* Effect.gen(function* () {
|
||||
yield* Effect.forEach(
|
||||
[
|
||||
"",
|
||||
" ",
|
||||
",",
|
||||
"https://app.example.com,",
|
||||
",https://app.example.com",
|
||||
"https://app.example.com,,https://other.example.com",
|
||||
"not-a-url",
|
||||
"*",
|
||||
"null",
|
||||
"ftp://app.example.com",
|
||||
"https://app.example.com/",
|
||||
"https://app.example.com/path",
|
||||
"https://app.example.com?query=1",
|
||||
"https://app.example.com#fragment",
|
||||
"https://user:password@app.example.com",
|
||||
],
|
||||
(value) =>
|
||||
Effect.gen(function* () {
|
||||
expect(Exit.isFailure(yield* ServiceConfig.set("cors", value).pipe(Effect.exit))).toBe(true)
|
||||
expect(yield* ServiceConfig.read()).toEqual(existing)
|
||||
}),
|
||||
)
|
||||
yield* Effect.forEach(
|
||||
[
|
||||
ServiceConfig.get("cors", "extra"),
|
||||
ServiceConfig.set("cors", "https://app.example.com", "extra"),
|
||||
ServiceConfig.unset("cors", "extra"),
|
||||
],
|
||||
(operation) =>
|
||||
Effect.gen(function* () {
|
||||
expect(Exit.isFailure(yield* operation.pipe(Effect.exit))).toBe(true)
|
||||
}),
|
||||
)
|
||||
expect(JSON.parse(yield* fs.readFileString(file))).toEqual(existing)
|
||||
}).pipe(Effect.provideService(Global.Service, Global.make({ config, state })))
|
||||
}).pipe(Effect.provide(NodeServices.layer)),
|
||||
)
|
||||
|
||||
test.each([
|
||||
{ args: [], cors: [] },
|
||||
{ args: ["--cors", "https://app.example.com"], cors: ["https://app.example.com"] },
|
||||
{
|
||||
args: ["--service", "--cors", "http://192.0.2.10:3001", "--cors", "https://app.example.com"],
|
||||
cors: ["http://192.0.2.10:3001", "https://app.example.com"],
|
||||
},
|
||||
])("serve parses CORS flags: $args", async ({ args, cors }) => {
|
||||
const received: (readonly string[])[] = []
|
||||
const command = Commands.commands.serve.spec.pipe(
|
||||
Command.withHandler((input) => Effect.sync(() => void received.push(input.cors))),
|
||||
)
|
||||
await Effect.runPromise(Command.runWith(command, { version: "test" })(args).pipe(Effect.provide(NodeServices.layer)))
|
||||
expect(received).toEqual([cors])
|
||||
})
|
||||
|
||||
test.each([{ args: ["--cors"] }, { args: ["--cors", ""] }])(
|
||||
"serve rejects a missing or empty CORS flag value: $args",
|
||||
async ({ args }) => {
|
||||
const command = Commands.commands.serve.spec.pipe(Command.withHandler(() => Effect.void))
|
||||
const result = await Effect.runPromise(
|
||||
Command.runWith(command, { version: "test", renderErrors: false })(args).pipe(
|
||||
Effect.exit,
|
||||
Effect.provide(NodeServices.layer),
|
||||
),
|
||||
)
|
||||
expect(Exit.isFailure(result)).toBe(true)
|
||||
},
|
||||
)
|
||||
@@ -9,6 +9,7 @@ import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { ServiceConfig } from "../src/services/service-config"
|
||||
import { ServiceRegistration } from "../src/services/service-registration"
|
||||
import { isolatedEnv } from "./fixture/environment"
|
||||
|
||||
test("managed service ports are stable per installation channel", () => {
|
||||
expect(ServiceConfig.defaultPort("latest")).toBe(0xc0de)
|
||||
@@ -313,6 +314,48 @@ test("configured managed service port overrides the channel default", async () =
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
test.each([
|
||||
{ args: [], origins: ["http://192.0.2.10:3001", "https://configured.example.com"] },
|
||||
{
|
||||
args: ["--cors", "http://192.0.2.20:3001", "--cors", "https://override.example.com"],
|
||||
origins: ["http://192.0.2.20:3001", "https://override.example.com"],
|
||||
},
|
||||
])(
|
||||
"managed service applies CORS configuration with flag overrides: $args",
|
||||
async ({ args, origins }) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-cors-"))
|
||||
const config = path.join(root, "config", ServiceConfig.filename())
|
||||
const registration = path.join(root, "state", "opencode", ServiceConfig.filename())
|
||||
const cors = ["http://192.0.2.10:3001", "https://configured.example.com"]
|
||||
await fs.mkdir(path.dirname(config), { recursive: true })
|
||||
await fs.writeFile(config, JSON.stringify({ cors }))
|
||||
const owner = Bun.spawn(
|
||||
[process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service", "--port", "0", ...args],
|
||||
{ env: isolatedEnv(root), stderr: "pipe", stdout: "ignore" },
|
||||
)
|
||||
try {
|
||||
const info = await waitForInfo(registration)
|
||||
await Promise.all(
|
||||
[...new Set([...cors, ...origins, "https://unlisted.example.com"])].map(async (origin) => {
|
||||
const response = await fetch(new URL("/api/health", info.url), {
|
||||
method: "OPTIONS",
|
||||
headers: { Origin: origin, "Access-Control-Request-Method": "GET" },
|
||||
})
|
||||
expect(response.headers.get("access-control-allow-origin")).toBe(
|
||||
origins.some((value) => value === origin) ? origin : null,
|
||||
)
|
||||
}),
|
||||
)
|
||||
expect((await Bun.file(config).json()).cors).toEqual(cors)
|
||||
} finally {
|
||||
owner.kill("SIGTERM")
|
||||
await owner.exited
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
},
|
||||
30_000,
|
||||
)
|
||||
|
||||
test("unrelated managed port occupancy reports an actionable conflict", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-conflict-"))
|
||||
const listener = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response("unrelated") })
|
||||
|
||||
@@ -1950,6 +1950,12 @@ export type VcsGetInput = {
|
||||
export type VcsGetOutput = { readonly location: Location.Info; readonly data: Vcs.Info }
|
||||
export type VcsGetOperation<E = never> = (input?: VcsGetInput) => Effect.Effect<VcsGetOutput, E>
|
||||
|
||||
export type VcsBaseInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type VcsBaseOutput = { readonly location: Location.Info; readonly data: Vcs.Base | null }
|
||||
export type VcsBaseOperation<E = never> = (input?: VcsBaseInput) => Effect.Effect<VcsBaseOutput, E>
|
||||
|
||||
export type VcsStatusInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
@@ -1967,6 +1973,7 @@ export type VcsBranchesOperation<E = never> = (input?: VcsBranchesInput) => Effe
|
||||
export type VcsDiffInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly mode: Vcs.Mode
|
||||
readonly base?: string | undefined
|
||||
readonly context?: number | undefined
|
||||
}
|
||||
export type VcsDiffOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<FileDiff.Info> }
|
||||
@@ -1974,6 +1981,7 @@ export type VcsDiffOperation<E = never> = (input: VcsDiffInput) => Effect.Effect
|
||||
|
||||
export interface VcsApi<E = never> {
|
||||
readonly get: VcsGetOperation<E>
|
||||
readonly base: VcsBaseOperation<E>
|
||||
readonly status: VcsStatusOperation<E>
|
||||
readonly branches: VcsBranchesOperation<E>
|
||||
readonly diff: VcsDiffOperation<E>
|
||||
|
||||
@@ -244,6 +244,8 @@ import type {
|
||||
WorkspaceDestroyOutput,
|
||||
VcsGetInput,
|
||||
VcsGetOutput,
|
||||
VcsBaseInput,
|
||||
VcsBaseOutput,
|
||||
VcsStatusInput,
|
||||
VcsStatusOutput,
|
||||
VcsBranchesInput,
|
||||
@@ -1468,6 +1470,11 @@ const EndpointVcsGet = (raw: RawClient["server.vcs"]) => (input?: VcsGetInput) =
|
||||
raw["vcs.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointVcsBase = (raw: RawClient["server.vcs"]) => (input?: VcsBaseInput) =>
|
||||
preserveEffect<VcsBaseOutput>()(
|
||||
raw["vcs.base"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointVcsStatus = (raw: RawClient["server.vcs"]) => (input?: VcsStatusInput) =>
|
||||
preserveEffect<VcsStatusOutput>()(
|
||||
raw["vcs.status"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
@@ -1482,13 +1489,14 @@ const EndpointVcsBranches = (raw: RawClient["server.vcs"]) => (input?: VcsBranch
|
||||
|
||||
const EndpointVcsDiff = (raw: RawClient["server.vcs"]) => (input: VcsDiffInput) =>
|
||||
preserveEffect<VcsDiffOutput>()(
|
||||
raw["vcs.diff"]({ query: { location: input["location"], mode: input["mode"], context: input["context"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
raw["vcs.diff"]({
|
||||
query: { location: input["location"], mode: input["mode"], base: input["base"], context: input["context"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroupVcs = (raw: RawClient["server.vcs"]) => ({
|
||||
get: EndpointVcsGet(raw),
|
||||
base: EndpointVcsBase(raw),
|
||||
status: EndpointVcsStatus(raw),
|
||||
branches: EndpointVcsBranches(raw),
|
||||
diff: EndpointVcsDiff(raw),
|
||||
|
||||
@@ -34,6 +34,7 @@ export { Permission } from "@opencode-ai/schema/permission"
|
||||
export { PermissionSaved } from "@opencode-ai/schema/permission-saved"
|
||||
export { Project } from "@opencode-ai/schema/project"
|
||||
export { Worktree } from "@opencode-ai/schema/worktree"
|
||||
export { Vcs } from "@opencode-ai/schema/vcs"
|
||||
export { Provider } from "@opencode-ai/schema/provider"
|
||||
export { Pty } from "@opencode-ai/schema/pty"
|
||||
export { Question } from "@opencode-ai/schema/question"
|
||||
|
||||
@@ -240,6 +240,8 @@ import type {
|
||||
WorkspaceDestroyOutput,
|
||||
VcsGetInput,
|
||||
VcsGetOutput,
|
||||
VcsBaseInput,
|
||||
VcsBaseOutput,
|
||||
VcsStatusInput,
|
||||
VcsStatusOutput,
|
||||
VcsBranchesInput,
|
||||
@@ -1996,6 +1998,18 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
base: (input?: VcsBaseInput, requestOptions?: RequestOptions) =>
|
||||
request<VcsBaseOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/vcs/base`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [503, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
status: (input?: VcsStatusInput, requestOptions?: RequestOptions) =>
|
||||
request<VcsStatusOutput>(
|
||||
{
|
||||
@@ -2025,9 +2039,9 @@ export function make(options: ClientOptions) {
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/vcs/diff`,
|
||||
query: { location: input["location"], mode: input["mode"], context: input["context"] },
|
||||
query: { location: input["location"], mode: input["mode"], base: input["base"], context: input["context"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
declaredStatuses: [503, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
|
||||
@@ -419,6 +419,8 @@ export type WorkspaceDestroyResult = { destroyed: boolean }
|
||||
|
||||
export type VcsBranch = { current?: string; default?: string }
|
||||
|
||||
export type VcsBase = { name: string; ref: string; source: "reflog" | "default" }
|
||||
|
||||
export type VcsFileStatus = {
|
||||
file: string
|
||||
additions: number
|
||||
@@ -6073,6 +6075,17 @@ export type VcsGetOutput = {
|
||||
data: VcsInfo
|
||||
}
|
||||
|
||||
export type VcsBaseInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type VcsBaseOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: VcsBase | null
|
||||
}
|
||||
|
||||
export type VcsStatusInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
@@ -6110,17 +6123,26 @@ export type VcsBranchesOutput = {
|
||||
export type VcsDiffInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly mode: "working" | "branch"
|
||||
readonly mode: "working" | "branch" | "committed"
|
||||
readonly base?: string | undefined
|
||||
readonly context?: number | undefined
|
||||
}["location"]
|
||||
readonly mode: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly mode: "working" | "branch"
|
||||
readonly mode: "working" | "branch" | "committed"
|
||||
readonly base?: string | undefined
|
||||
readonly context?: number | undefined
|
||||
}["mode"]
|
||||
readonly base?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly mode: "working" | "branch" | "committed"
|
||||
readonly base?: string | undefined
|
||||
readonly context?: number | undefined
|
||||
}["base"]
|
||||
readonly context?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly mode: "working" | "branch"
|
||||
readonly mode: "working" | "branch" | "committed"
|
||||
readonly base?: string | undefined
|
||||
readonly context?: number | undefined
|
||||
}["context"]
|
||||
}
|
||||
|
||||
@@ -750,7 +750,8 @@ export function createData(config: CreateDataInput) {
|
||||
command: event.data.shell.command,
|
||||
status: event.data.shell.status,
|
||||
exit: event.data.shell.exit,
|
||||
metadata: event.metadata,
|
||||
metadata:
|
||||
event.data.shell.metadata.background === true ? { ...event.metadata, background: true } : event.metadata,
|
||||
time: { created: event.created },
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Model } from "@opencode-ai/schema/model"
|
||||
import { Prompt } from "@opencode-ai/schema/prompt"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { Vcs } from "@opencode-ai/schema/vcs"
|
||||
|
||||
const Client = await import("../src/effect")
|
||||
|
||||
@@ -14,6 +15,7 @@ test("effect entrypoint exposes canonical Schema contracts", () => {
|
||||
expect(Client.Config).toBe(Config)
|
||||
expect(Client.Model).toBe(Model)
|
||||
expect(Client.Session).toBe(Session)
|
||||
expect(Client.Vcs.Base).toBe(Vcs.Base)
|
||||
})
|
||||
|
||||
test("generated Effect API names canonical and composed outputs", async () => {
|
||||
|
||||
@@ -27,6 +27,26 @@ test("health.get decodes the readiness response", async () => {
|
||||
expect(result).toEqual({ healthy: true, version: "old", pid: 123 })
|
||||
})
|
||||
|
||||
test("vcs.base decodes nullable review-base metadata", async () => {
|
||||
const location = { directory: "/repo", project: { id: "global", directory: "/repo", canonical: "/repo" } }
|
||||
const base = {
|
||||
name: "release",
|
||||
ref: "refs/remotes/origin/release",
|
||||
source: "reflog",
|
||||
}
|
||||
for (const data of [base, null]) {
|
||||
const httpClient = HttpClient.make((request) =>
|
||||
Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ location, data }))),
|
||||
)
|
||||
const result = await Effect.gen(function* () {
|
||||
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||
return yield* client.vcs.base({ location: { directory: AbsolutePath.make("/repo") } })
|
||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||
expect(result.data).toEqual(data)
|
||||
expect(result.location.directory).toBe("/repo")
|
||||
}
|
||||
})
|
||||
|
||||
test("session.get returns the decoded Effect projection", async () => {
|
||||
const httpClient = HttpClient.make((request) =>
|
||||
Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session))),
|
||||
|
||||
@@ -47,7 +47,7 @@ test("exposes every standard HTTP API group", () => {
|
||||
expect(Object.keys(client.integration.command)).toEqual(["connect", "status", "cancel"])
|
||||
expect(Object.keys(client.websearch)).toEqual(["providers", "query"])
|
||||
expect(Object.keys(client.file)).toEqual(["read", "list", "find"])
|
||||
expect(Object.keys(client.vcs)).toEqual(["get", "status", "branches", "diff"])
|
||||
expect(Object.keys(client.vcs)).toEqual(["get", "base", "status", "branches", "diff"])
|
||||
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove", "connect"])
|
||||
expect(Object.keys(client.pty.connect)).toEqual(["token"])
|
||||
expect(Object.keys(client.experimental)).toEqual(["persistentPty"])
|
||||
@@ -84,6 +84,46 @@ test("config.get returns ordered config entries for a location", async () => {
|
||||
expect(request?.url).toBe("http://localhost:3000/api/config?location%5Bdirectory%5D=%2Ftmp%2Fproject")
|
||||
})
|
||||
|
||||
test("vcs.base and committed diffs preserve location and explicit base on the wire", async () => {
|
||||
const requests: Request[] = []
|
||||
const location = { directory: "/repo", project: { id: "global", directory: "/repo", canonical: "/repo" } }
|
||||
const base = { name: "release", ref: "refs/remotes/origin/release", source: "reflog" }
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input, init) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
requests.push(request)
|
||||
return Response.json({ location, data: new URL(request.url).pathname.endsWith("/base") ? base : [] })
|
||||
},
|
||||
})
|
||||
expect(await client.vcs.base({ location: { directory: "/repo" } })).toEqual({ location, data: base })
|
||||
expect(
|
||||
await client.vcs.diff({ location: { directory: "/repo" }, mode: "committed", base: base.ref, context: 1 }),
|
||||
).toEqual({ location, data: [] })
|
||||
expect(new URL(requests[0].url).pathname).toBe("/api/vcs/base")
|
||||
const query = new URL(requests[1].url).searchParams
|
||||
expect(query.get("location[directory]")).toBe("/repo")
|
||||
expect(query.get("mode")).toBe("committed")
|
||||
expect(query.get("base")).toBe(base.ref)
|
||||
expect(query.get("context")).toBe("1")
|
||||
})
|
||||
|
||||
test("vcs.diff exposes unavailable comparisons as errors, not empty diffs", async () => {
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async () =>
|
||||
Response.json(
|
||||
{ _tag: "ServiceUnavailableError", service: "vcs", message: "No review base available" },
|
||||
{ status: 503 },
|
||||
),
|
||||
})
|
||||
await expect(client.vcs.diff({ mode: "committed" })).rejects.toMatchObject({
|
||||
_tag: "ServiceUnavailableError",
|
||||
service: "vcs",
|
||||
message: "No review base available",
|
||||
})
|
||||
})
|
||||
|
||||
test("project.update uses the global project contract", async () => {
|
||||
let request: Request | undefined
|
||||
const project = {
|
||||
|
||||
@@ -666,6 +666,36 @@ test("ignores activity snapshots from an older connection", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("projects background user shell metadata from durable shell data", () => {
|
||||
const setup = activityFixture(() => Response.json({ data: {} }))
|
||||
try {
|
||||
setup.emit({
|
||||
id: "evt_user_shell",
|
||||
created: 1,
|
||||
type: "session.shell.started",
|
||||
durable: { aggregateID: "ses_refresh", seq: 1, version: 1 },
|
||||
data: {
|
||||
sessionID: "ses_refresh",
|
||||
shell: {
|
||||
id: "sh_user",
|
||||
status: "running",
|
||||
command: "pwd",
|
||||
cwd: "/project",
|
||||
shell: "/bin/sh",
|
||||
file: "/project/shell.out",
|
||||
metadata: { sessionID: "ses_refresh", background: true },
|
||||
time: { started: 1 },
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(setup.data.session.message.list("ses_refresh")).toMatchObject([
|
||||
{ type: "shell", shellID: "sh_user", status: "running", metadata: { background: true } },
|
||||
])
|
||||
} finally {
|
||||
setup.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
function activityFixture(read: () => Response | Promise<Response>) {
|
||||
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
|
||||
const api = OpenCode.make({
|
||||
|
||||
@@ -24,8 +24,8 @@ export const invoke = (plan: Plan, input: unknown): Effect.Effect<unknown, unkno
|
||||
const response = yield* client
|
||||
.execute(request)
|
||||
.pipe(
|
||||
Effect.catch((cause) =>
|
||||
Effect.fail(toolError(`${plan.operation.method} ${plan.operation.path} failed: transport error`, cause)),
|
||||
Effect.mapError((cause) =>
|
||||
toolError(`${plan.operation.method} ${plan.operation.path} failed: transport error`, cause),
|
||||
),
|
||||
)
|
||||
const text = yield* readResponseBody(response, plan)
|
||||
|
||||
@@ -87,7 +87,7 @@ const layer = Layer.effect(
|
||||
draft.agents.delete(id)
|
||||
},
|
||||
}),
|
||||
notify: () => bus.publish(Agent.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
finalize: () => bus.publish(Agent.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
const selectable = (agent: Info | undefined) =>
|
||||
agent && agent.mode !== "subagent" && !agent.hidden ? agent : undefined
|
||||
|
||||
@@ -134,7 +134,7 @@ const layer = Layer.effect(
|
||||
}
|
||||
return result
|
||||
},
|
||||
notify: Effect.fn("Catalog.notify")(function* () {
|
||||
finalize: Effect.fn("Catalog.finalize")(function* () {
|
||||
yield* bus.publish(Catalog.Event.Updated, {})
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -60,7 +60,7 @@ export const layer = Layer.effect(
|
||||
draft: (draft) => ({
|
||||
add: (definition) => draft.set(definition.name, definition),
|
||||
}),
|
||||
notify: () => bus.publish(Command.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
finalize: () => bus.publish(Command.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
const info = (definition: Definition) =>
|
||||
Info.make({
|
||||
|
||||
@@ -227,7 +227,7 @@ export class SQLiteEffectInsertBase<
|
||||
config: SQLiteInsertConfig<TTable>
|
||||
|
||||
constructor(
|
||||
private table: TTable,
|
||||
table: TTable,
|
||||
values: SQLiteInsertConfig["values"],
|
||||
private effectSession: SQLiteEffectSession<TEffectHKT, TRunResult, any>,
|
||||
private effectDialect: SQLiteDialect,
|
||||
|
||||
@@ -277,11 +277,7 @@ export class SQLiteEffectPreparedQuery<
|
||||
}
|
||||
|
||||
assertUnreachable(cacheStrat)
|
||||
}).pipe(
|
||||
Effect.catch((e) => {
|
||||
return Effect.fail(new EffectDrizzleQueryError({ query: queryString, params, cause: Cause.fail(e) }))
|
||||
}),
|
||||
)
|
||||
}).pipe(Effect.mapError((e) => new EffectDrizzleQueryError({ query: queryString, params, cause: Cause.fail(e) })))
|
||||
}
|
||||
|
||||
getQuery(): Query {
|
||||
|
||||
@@ -25,15 +25,19 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Lo
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
let current: readonly string[] = []
|
||||
const listeners = new Set<(ignore: readonly string[]) => Effect.Effect<void>>()
|
||||
const state: State.Interface<Data, Draft> = State.create<Data, Draft>({
|
||||
const state = State.create<Data, Draft>({
|
||||
name: "location-watcher-policy",
|
||||
initial: () => ({ ignore: [] }),
|
||||
draft: (draft) => ({
|
||||
add: (ignore) => draft.ignore.push(...ignore),
|
||||
list: () => draft.ignore,
|
||||
}),
|
||||
notify: () => Effect.forEach(listeners, (listener) => listener(state.get().ignore), { discard: true }),
|
||||
finalize: (draft) =>
|
||||
Effect.sync(() => {
|
||||
current = [...draft.list()]
|
||||
}).pipe(Effect.andThen(Effect.forEach(listeners, (listener) => listener(current), { discard: true }))),
|
||||
})
|
||||
const observe = Effect.fn("LocationWatcherPolicy.observe")(function* (
|
||||
listener: (ignore: readonly string[]) => Effect.Effect<void>,
|
||||
@@ -52,7 +56,7 @@ const layer = Layer.effect(
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
current: () => state.get().ignore,
|
||||
current: () => current,
|
||||
observe,
|
||||
})
|
||||
}),
|
||||
|
||||
+28
-33
@@ -177,7 +177,7 @@ const layer = Layer.effect(
|
||||
if (!dotgit) return undefined
|
||||
|
||||
const cwd = path.dirname(dotgit)
|
||||
const result = yield* run(cwd, proc)(["rev-parse", "--git-dir", "--git-common-dir", "--show-toplevel"])
|
||||
const result = yield* run(cwd, proc, ["rev-parse", "--git-dir", "--git-common-dir", "--show-toplevel"])
|
||||
const [gitDir, commonDir, topLevel] = result.text.split(/\r?\n/)
|
||||
if (!gitDir || !commonDir) return undefined
|
||||
|
||||
@@ -189,13 +189,13 @@ const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const remote = Effect.fn("Git.remote.get")(function* (repository: Repository, name = "origin") {
|
||||
const result = yield* run(repository.worktree, proc)(["remote", "get-url", name])
|
||||
const result = yield* run(repository.worktree, proc, ["remote", "get-url", name])
|
||||
if (result.exitCode !== 0) return undefined
|
||||
return result.text.trim() || undefined
|
||||
})
|
||||
|
||||
const roots = Effect.fn("Git.history.rootCommits")(function* (repository: Repository) {
|
||||
const result = yield* run(repository.worktree, proc)(["rev-list", "--max-parents=0", "HEAD"])
|
||||
const result = yield* run(repository.worktree, proc, ["rev-list", "--max-parents=0", "HEAD"])
|
||||
if (result.exitCode !== 0) return []
|
||||
return result.text
|
||||
.split("\n")
|
||||
@@ -205,13 +205,13 @@ const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const head = Effect.fn("Git.history.head")(function* (repository: Repository) {
|
||||
const result = yield* run(repository.worktree, proc)(["rev-parse", "HEAD"])
|
||||
const result = yield* run(repository.worktree, proc, ["rev-parse", "HEAD"])
|
||||
if (result.exitCode !== 0) return undefined
|
||||
return result.text.trim() || undefined
|
||||
})
|
||||
|
||||
const branch = Effect.fn("Git.history.branch")(function* (repository: Repository) {
|
||||
const result = yield* run(repository.worktree, proc)(["symbolic-ref", "--quiet", "--short", "HEAD"])
|
||||
const result = yield* run(repository.worktree, proc, ["symbolic-ref", "--quiet", "--short", "HEAD"])
|
||||
if (result.exitCode !== 0) return undefined
|
||||
return result.text.trim() || undefined
|
||||
})
|
||||
@@ -220,7 +220,7 @@ const layer = Layer.effect(
|
||||
repository: Repository,
|
||||
remoteName = "origin",
|
||||
) {
|
||||
const result = yield* run(repository.worktree, proc)(["symbolic-ref", `refs/remotes/${remoteName}/HEAD`])
|
||||
const result = yield* run(repository.worktree, proc, ["symbolic-ref", `refs/remotes/${remoteName}/HEAD`])
|
||||
if (result.exitCode !== 0) return undefined
|
||||
return result.text.trim().replace(new RegExp(`^refs/remotes/${remoteName}/`), "") || undefined
|
||||
})
|
||||
@@ -230,10 +230,7 @@ const layer = Layer.effect(
|
||||
directory: AbsolutePath,
|
||||
args: string[],
|
||||
) {
|
||||
const result = yield* execute(
|
||||
directory,
|
||||
proc,
|
||||
)(args).pipe(
|
||||
const result = yield* execute(directory, proc, args).pipe(
|
||||
Effect.mapError((cause) => new OperationError({ operation, directory, message: cause.message, cause })),
|
||||
)
|
||||
if (result.exitCode === 0) return
|
||||
@@ -711,31 +708,29 @@ interface Result {
|
||||
readonly stderr: string
|
||||
}
|
||||
|
||||
function run(cwd: string, proc: AppProcess.Interface) {
|
||||
return (args: string[]) =>
|
||||
execute(cwd, proc)(args).pipe(Effect.orElseSucceed(() => ({ exitCode: 1, text: "", stderr: "" })))
|
||||
function run(cwd: string, proc: AppProcess.Interface, args: string[]) {
|
||||
return execute(cwd, proc, args).pipe(Effect.orElseSucceed(() => ({ exitCode: 1, text: "", stderr: "" })))
|
||||
}
|
||||
|
||||
function execute(cwd: string, proc: AppProcess.Interface) {
|
||||
return (args: string[]) =>
|
||||
proc
|
||||
.run(
|
||||
ChildProcess.make("git", args, {
|
||||
cwd,
|
||||
extendEnv: true,
|
||||
stdin: "ignore",
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
Effect.map(
|
||||
(result) =>
|
||||
({
|
||||
exitCode: result.exitCode,
|
||||
text: result.stdout.toString("utf8"),
|
||||
stderr: result.stderr.toString("utf8"),
|
||||
}) satisfies Result,
|
||||
),
|
||||
)
|
||||
function execute(cwd: string, proc: AppProcess.Interface, args: string[]) {
|
||||
return proc
|
||||
.run(
|
||||
ChildProcess.make("git", args, {
|
||||
cwd,
|
||||
extendEnv: true,
|
||||
stdin: "ignore",
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
Effect.map(
|
||||
(result) =>
|
||||
({
|
||||
exitCode: result.exitCode,
|
||||
text: result.stdout.toString("utf8"),
|
||||
stderr: result.stderr.toString("utf8"),
|
||||
}) satisfies Result,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function resolvePath(cwd: string, value: string) {
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
type LanguageModelV3,
|
||||
type LanguageModelV3CallOptions,
|
||||
type LanguageModelV3Content,
|
||||
type LanguageModelV3ProviderTool,
|
||||
type LanguageModelV3StreamPart,
|
||||
type SharedV3ProviderMetadata,
|
||||
type SharedV3Warning,
|
||||
@@ -27,7 +26,7 @@ import { imageGenerationOutputSchema } from "./tool/image-generation.js"
|
||||
import { convertToOpenAIResponsesInput } from "./convert-to-openai-responses-input.js"
|
||||
import { mapOpenAIResponseFinishReason } from "./map-openai-responses-finish-reason.js"
|
||||
import type { OpenAIResponsesIncludeOptions, OpenAIResponsesIncludeValue } from "./openai-responses-api-types.js"
|
||||
import { prepareResponsesTools } from "./openai-responses-prepare-tools.js"
|
||||
import { prepareResponsesTools, type ResponsesHostedTool } from "./openai-responses-prepare-tools.js"
|
||||
import type { OpenAIResponsesModelId } from "./openai-responses-settings.js"
|
||||
|
||||
const webSearchCallItem = z.object({
|
||||
@@ -221,15 +220,23 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
addInclude("message.output_text.logprobs")
|
||||
}
|
||||
|
||||
// when a web search tool is present, automatically include the sources:
|
||||
const webSearchToolName = (
|
||||
tools?.find(
|
||||
(tool) =>
|
||||
tool.type === "provider" && (tool.id === "openai.web_search" || tool.id === "openai.web_search_preview"),
|
||||
) as LanguageModelV3ProviderTool | undefined
|
||||
)?.name
|
||||
const {
|
||||
tools: openaiTools,
|
||||
toolChoice: openaiToolChoice,
|
||||
hostedTools,
|
||||
selectedHostedTool,
|
||||
toolWarnings,
|
||||
} = prepareResponsesTools({
|
||||
tools,
|
||||
toolChoice,
|
||||
strictJsonSchema,
|
||||
})
|
||||
const getHostedToolName = (responseType: ResponsesHostedTool["responseType"]) => {
|
||||
if (selectedHostedTool?.responseType === responseType) return selectedHostedTool.name
|
||||
return hostedTools.find((tool) => tool.responseType === responseType)?.name ?? responseType
|
||||
}
|
||||
|
||||
if (webSearchToolName) {
|
||||
if (hostedTools.some((tool) => tool.responseType === "web_search")) {
|
||||
addInclude("web_search_call.action.sources")
|
||||
}
|
||||
|
||||
@@ -357,18 +364,8 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
baseArgs.service_tier = undefined
|
||||
}
|
||||
|
||||
const {
|
||||
tools: openaiTools,
|
||||
toolChoice: openaiToolChoice,
|
||||
toolWarnings,
|
||||
} = prepareResponsesTools({
|
||||
tools,
|
||||
toolChoice,
|
||||
strictJsonSchema,
|
||||
})
|
||||
|
||||
return {
|
||||
webSearchToolName,
|
||||
getHostedToolName,
|
||||
args: {
|
||||
...baseArgs,
|
||||
tools: openaiTools,
|
||||
@@ -379,7 +376,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
}
|
||||
|
||||
async doGenerate(options: LanguageModelV3CallOptions) {
|
||||
const { args: body, warnings, webSearchToolName } = await this.getArgs(options)
|
||||
const { args: body, warnings, getHostedToolName } = await this.getArgs(options)
|
||||
const url = this.config.url({
|
||||
path: "/responses",
|
||||
modelId: this.modelId,
|
||||
@@ -526,7 +523,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
content.push({
|
||||
type: "tool-call",
|
||||
toolCallId: part.id,
|
||||
toolName: "image_generation",
|
||||
toolName: getHostedToolName("image_generation"),
|
||||
input: "{}",
|
||||
providerExecuted: true,
|
||||
})
|
||||
@@ -534,7 +531,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
content.push({
|
||||
type: "tool-result",
|
||||
toolCallId: part.id,
|
||||
toolName: "image_generation",
|
||||
toolName: getHostedToolName("image_generation"),
|
||||
result: {
|
||||
result: part.result,
|
||||
} satisfies z.infer<typeof imageGenerationOutputSchema>,
|
||||
@@ -605,7 +602,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
content.push({
|
||||
type: "tool-call",
|
||||
toolCallId: part.id,
|
||||
toolName: webSearchToolName ?? "web_search",
|
||||
toolName: getHostedToolName("web_search"),
|
||||
input: JSON.stringify({ action: part.action }),
|
||||
providerExecuted: true,
|
||||
})
|
||||
@@ -613,7 +610,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
content.push({
|
||||
type: "tool-result",
|
||||
toolCallId: part.id,
|
||||
toolName: webSearchToolName ?? "web_search",
|
||||
toolName: getHostedToolName("web_search"),
|
||||
result: { status: part.status },
|
||||
})
|
||||
|
||||
@@ -645,7 +642,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
content.push({
|
||||
type: "tool-call",
|
||||
toolCallId: part.id,
|
||||
toolName: "file_search",
|
||||
toolName: getHostedToolName("file_search"),
|
||||
input: "{}",
|
||||
providerExecuted: true,
|
||||
})
|
||||
@@ -653,7 +650,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
content.push({
|
||||
type: "tool-result",
|
||||
toolCallId: part.id,
|
||||
toolName: "file_search",
|
||||
toolName: getHostedToolName("file_search"),
|
||||
result: {
|
||||
queries: part.queries,
|
||||
results:
|
||||
@@ -673,7 +670,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
content.push({
|
||||
type: "tool-call",
|
||||
toolCallId: part.id,
|
||||
toolName: "code_interpreter",
|
||||
toolName: getHostedToolName("code_interpreter"),
|
||||
input: JSON.stringify({
|
||||
code: part.code,
|
||||
containerId: part.container_id,
|
||||
@@ -684,7 +681,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
content.push({
|
||||
type: "tool-result",
|
||||
toolCallId: part.id,
|
||||
toolName: "code_interpreter",
|
||||
toolName: getHostedToolName("code_interpreter"),
|
||||
result: {
|
||||
outputs: part.outputs,
|
||||
} satisfies z.infer<typeof codeInterpreterOutputSchema>,
|
||||
@@ -746,7 +743,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
}
|
||||
|
||||
async doStream(options: LanguageModelV3CallOptions) {
|
||||
const { args: body, warnings, webSearchToolName } = await this.getArgs(options)
|
||||
const { args: body, warnings, getHostedToolName } = await this.getArgs(options)
|
||||
|
||||
const { responseHeaders, value: response } = await postJsonToApi({
|
||||
url: this.config.url({
|
||||
@@ -866,7 +863,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
controller.enqueue({
|
||||
type: "tool-input-start",
|
||||
id: value.item.id,
|
||||
toolName: webSearchToolName ?? "web_search",
|
||||
toolName: getHostedToolName("web_search"),
|
||||
})
|
||||
} else if (value.item.type === "computer_call") {
|
||||
ongoingToolCalls[value.output_index] = {
|
||||
@@ -889,7 +886,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
controller.enqueue({
|
||||
type: "tool-input-start",
|
||||
id: value.item.id,
|
||||
toolName: "code_interpreter",
|
||||
toolName: getHostedToolName("code_interpreter"),
|
||||
})
|
||||
|
||||
controller.enqueue({
|
||||
@@ -901,7 +898,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
controller.enqueue({
|
||||
type: "tool-call",
|
||||
toolCallId: value.item.id,
|
||||
toolName: "file_search",
|
||||
toolName: getHostedToolName("file_search"),
|
||||
input: "{}",
|
||||
providerExecuted: true,
|
||||
})
|
||||
@@ -909,7 +906,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
controller.enqueue({
|
||||
type: "tool-call",
|
||||
toolCallId: value.item.id,
|
||||
toolName: "image_generation",
|
||||
toolName: getHostedToolName("image_generation"),
|
||||
input: "{}",
|
||||
providerExecuted: true,
|
||||
})
|
||||
@@ -980,7 +977,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
controller.enqueue({
|
||||
type: "tool-call",
|
||||
toolCallId: value.item.id,
|
||||
toolName: "web_search",
|
||||
toolName: getHostedToolName("web_search"),
|
||||
input: JSON.stringify({ action: value.item.action }),
|
||||
providerExecuted: true,
|
||||
})
|
||||
@@ -988,7 +985,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
controller.enqueue({
|
||||
type: "tool-result",
|
||||
toolCallId: value.item.id,
|
||||
toolName: "web_search",
|
||||
toolName: getHostedToolName("web_search"),
|
||||
result: { status: value.item.status },
|
||||
})
|
||||
} else if (value.item.type === "computer_call") {
|
||||
@@ -1022,7 +1019,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
controller.enqueue({
|
||||
type: "tool-result",
|
||||
toolCallId: value.item.id,
|
||||
toolName: "file_search",
|
||||
toolName: getHostedToolName("file_search"),
|
||||
result: {
|
||||
queries: value.item.queries,
|
||||
results:
|
||||
@@ -1041,7 +1038,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
controller.enqueue({
|
||||
type: "tool-result",
|
||||
toolCallId: value.item.id,
|
||||
toolName: "code_interpreter",
|
||||
toolName: getHostedToolName("code_interpreter"),
|
||||
result: {
|
||||
outputs: value.item.outputs,
|
||||
} satisfies z.infer<typeof codeInterpreterOutputSchema>,
|
||||
@@ -1050,7 +1047,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
controller.enqueue({
|
||||
type: "tool-result",
|
||||
toolCallId: value.item.id,
|
||||
toolName: "image_generation",
|
||||
toolName: getHostedToolName("image_generation"),
|
||||
result: {
|
||||
result: value.item.result,
|
||||
} satisfies z.infer<typeof imageGenerationOutputSchema>,
|
||||
@@ -1099,7 +1096,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
controller.enqueue({
|
||||
type: "tool-result",
|
||||
toolCallId: value.item_id,
|
||||
toolName: "image_generation",
|
||||
toolName: getHostedToolName("image_generation"),
|
||||
result: {
|
||||
result: value.partial_image_b64,
|
||||
} satisfies z.infer<typeof imageGenerationOutputSchema>,
|
||||
@@ -1135,7 +1132,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
controller.enqueue({
|
||||
type: "tool-call",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
toolName: "code_interpreter",
|
||||
toolName: getHostedToolName("code_interpreter"),
|
||||
input: JSON.stringify({
|
||||
code: value.code,
|
||||
containerId: toolCall.codeInterpreter!.containerId,
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { type LanguageModelV3CallOptions, type SharedV3Warning, UnsupportedFunctionalityError } from "@ai-sdk/provider"
|
||||
import {
|
||||
type LanguageModelV3CallOptions,
|
||||
type LanguageModelV3ProviderTool,
|
||||
type SharedV3Warning,
|
||||
UnsupportedFunctionalityError,
|
||||
} from "@ai-sdk/provider"
|
||||
import { codeInterpreterArgsSchema } from "./tool/code-interpreter.js"
|
||||
import { fileSearchArgsSchema } from "./tool/file-search.js"
|
||||
import { webSearchArgsSchema } from "./tool/web-search.js"
|
||||
@@ -6,6 +11,28 @@ import { webSearchPreviewArgsSchema } from "./tool/web-search-preview.js"
|
||||
import { imageGenerationArgsSchema } from "./tool/image-generation.js"
|
||||
import type { OpenAIResponsesTool } from "./openai-responses-api-types.js"
|
||||
|
||||
export type ResponsesHostedTool = {
|
||||
name: string
|
||||
type: "file_search" | "web_search_preview" | "web_search" | "code_interpreter" | "image_generation"
|
||||
responseType: "file_search" | "web_search" | "code_interpreter" | "image_generation"
|
||||
}
|
||||
|
||||
export function getResponsesHostedTool(tool: LanguageModelV3ProviderTool): ResponsesHostedTool | undefined {
|
||||
switch (tool.id) {
|
||||
case "openai.file_search":
|
||||
return { name: tool.name, type: "file_search", responseType: "file_search" }
|
||||
case "openai.web_search_preview":
|
||||
return { name: tool.name, type: "web_search_preview", responseType: "web_search" }
|
||||
case "openai.web_search":
|
||||
return { name: tool.name, type: "web_search", responseType: "web_search" }
|
||||
case "openai.code_interpreter":
|
||||
return { name: tool.name, type: "code_interpreter", responseType: "code_interpreter" }
|
||||
case "openai.image_generation":
|
||||
return { name: tool.name, type: "image_generation", responseType: "image_generation" }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function prepareResponsesTools({
|
||||
tools,
|
||||
toolChoice,
|
||||
@@ -26,6 +53,8 @@ export function prepareResponsesTools({
|
||||
| { type: "function"; name: string }
|
||||
| { type: "code_interpreter" }
|
||||
| { type: "image_generation" }
|
||||
hostedTools: ResponsesHostedTool[]
|
||||
selectedHostedTool?: ResponsesHostedTool
|
||||
toolWarnings: SharedV3Warning[]
|
||||
} {
|
||||
// when the tools array is empty, change it to undefined to prevent errors:
|
||||
@@ -34,7 +63,49 @@ export function prepareResponsesTools({
|
||||
const toolWarnings: SharedV3Warning[] = []
|
||||
|
||||
if (tools == null) {
|
||||
return { tools: undefined, toolChoice: undefined, toolWarnings }
|
||||
return { tools: undefined, toolChoice: undefined, hostedTools: [], toolWarnings }
|
||||
}
|
||||
|
||||
const hostedTools = tools.flatMap((tool) => {
|
||||
if (tool.type !== "provider") return []
|
||||
const hostedTool = getResponsesHostedTool(tool)
|
||||
return hostedTool ? [hostedTool] : []
|
||||
})
|
||||
const selectedToolName = toolChoice?.type === "tool" ? toolChoice.toolName : undefined
|
||||
const selectedTools = selectedToolName === undefined ? [] : tools.filter((tool) => tool.name === selectedToolName)
|
||||
if (selectedTools.length > 1) {
|
||||
throw new UnsupportedFunctionalityError({
|
||||
functionality: `ambiguous tool choice '${selectedToolName}': multiple tool definitions share this name`,
|
||||
})
|
||||
}
|
||||
const selectedHostedTool =
|
||||
selectedTools[0]?.type === "provider" ? getResponsesHostedTool(selectedTools[0]) : undefined
|
||||
|
||||
const ambiguousHostedResponse =
|
||||
toolChoice?.type === "none" || toolChoice?.type === "tool"
|
||||
? undefined
|
||||
: hostedTools.find(
|
||||
(tool) =>
|
||||
new Set(
|
||||
hostedTools.filter((candidate) => candidate.responseType === tool.responseType).map((item) => item.name),
|
||||
).size > 1,
|
||||
)
|
||||
if (ambiguousHostedResponse) {
|
||||
const names = new Set(
|
||||
hostedTools.filter((tool) => tool.responseType === ambiguousHostedResponse.responseType).map((tool) => tool.name),
|
||||
)
|
||||
throw new UnsupportedFunctionalityError({
|
||||
functionality: `ambiguous ${ambiguousHostedResponse.responseType} response for hosted tools: ${[...names].join(", ")}`,
|
||||
})
|
||||
}
|
||||
|
||||
if (selectedHostedTool) {
|
||||
const names = new Set(hostedTools.filter((tool) => tool.type === selectedHostedTool.type).map((tool) => tool.name))
|
||||
if (names.size > 1) {
|
||||
throw new UnsupportedFunctionalityError({
|
||||
functionality: `ambiguous ${selectedHostedTool.type} tool choice for hosted tools: ${[...names].join(", ")}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const openaiTools: Array<OpenAIResponsesTool> = []
|
||||
@@ -134,7 +205,7 @@ export function prepareResponsesTools({
|
||||
}
|
||||
|
||||
if (toolChoice == null) {
|
||||
return { tools: openaiTools, toolChoice: undefined, toolWarnings }
|
||||
return { tools: openaiTools, toolChoice: undefined, hostedTools, selectedHostedTool, toolWarnings }
|
||||
}
|
||||
|
||||
const type = toolChoice.type
|
||||
@@ -143,20 +214,18 @@ export function prepareResponsesTools({
|
||||
case "auto":
|
||||
case "none":
|
||||
case "required":
|
||||
return { tools: openaiTools, toolChoice: type, toolWarnings }
|
||||
case "tool":
|
||||
return { tools: openaiTools, toolChoice: type, hostedTools, selectedHostedTool, toolWarnings }
|
||||
case "tool": {
|
||||
return {
|
||||
tools: openaiTools,
|
||||
toolChoice:
|
||||
toolChoice.toolName === "code_interpreter" ||
|
||||
toolChoice.toolName === "file_search" ||
|
||||
toolChoice.toolName === "image_generation" ||
|
||||
toolChoice.toolName === "web_search_preview" ||
|
||||
toolChoice.toolName === "web_search"
|
||||
? { type: toolChoice.toolName }
|
||||
: { type: "function", name: toolChoice.toolName },
|
||||
toolChoice: selectedHostedTool
|
||||
? { type: selectedHostedTool.type }
|
||||
: { type: "function", name: toolChoice.toolName },
|
||||
hostedTools,
|
||||
selectedHostedTool,
|
||||
toolWarnings,
|
||||
}
|
||||
}
|
||||
default: {
|
||||
const _exhaustiveCheck: never = type
|
||||
throw new UnsupportedFunctionalityError({
|
||||
|
||||
@@ -74,7 +74,7 @@ export const layer = (options?: Options) =>
|
||||
draft.available = false
|
||||
},
|
||||
}),
|
||||
notify: () => bus.publish(Event.Updated, {}).pipe(Effect.asVoid),
|
||||
finalize: () => bus.publish(Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
const source = (value: ReadonlyArray<File> | Instructions.Unavailable | Instructions.Removed) =>
|
||||
|
||||
@@ -2,13 +2,13 @@ export * as InstructionBuiltIns from "./builtins.js"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, DateTime, Effect, Layer, Schema } from "effect"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Location } from "../location.js"
|
||||
import { SessionSchema } from "../session/schema.js"
|
||||
import { Instructions } from "./index.js"
|
||||
|
||||
export interface Interface {
|
||||
readonly load: (sessionID: SessionSchema.ID) => Effect.Effect<Instructions.List>
|
||||
readonly load: (sessionID: Session.ID) => Effect.Effect<Instructions.List>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/InstructionBuiltIns") {}
|
||||
|
||||
@@ -328,7 +328,7 @@ const layer = Layer.effect(
|
||||
},
|
||||
},
|
||||
}),
|
||||
notify: () => bus.publish(Integration.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
finalize: () => bus.publish(Integration.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
const createCredential = Effect.fnUntraced(function* (input: Parameters<Credential.Interface["create"]>[0]) {
|
||||
@@ -400,13 +400,11 @@ const layer = Layer.effect(
|
||||
}
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const persistence = yield* Effect.sync(() => {
|
||||
const implementation = state
|
||||
.get()
|
||||
.integrations.get(attempt.integrationID)
|
||||
?.implementations.get(attempt.methodID)
|
||||
return attempt.label ?? implementation?.label?.(exit.value)
|
||||
}).pipe(
|
||||
const implementation = state
|
||||
.get()
|
||||
.integrations.get(attempt.integrationID)
|
||||
?.implementations.get(attempt.methodID)
|
||||
const persistence = yield* Effect.sync(() => attempt.label ?? implementation?.label?.(exit.value)).pipe(
|
||||
Effect.flatMap((label) =>
|
||||
createCredential({
|
||||
integrationID: attempt.integrationID,
|
||||
|
||||
@@ -6,21 +6,7 @@ import { ephemeral } from "@opencode-ai/schema/event"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import { createHash } from "node:crypto"
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import {
|
||||
Cause,
|
||||
Context,
|
||||
Effect,
|
||||
Exit,
|
||||
Fiber,
|
||||
FiberSet,
|
||||
Latch,
|
||||
Layer,
|
||||
Schema,
|
||||
Scope,
|
||||
Semaphore,
|
||||
Stream,
|
||||
Types,
|
||||
} from "effect"
|
||||
import { Cause, Context, Effect, Exit, FiberSet, Latch, Layer, Schema, Scope, Stream, Types } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Credential } from "../credential.js"
|
||||
import { Bus } from "../bus.js"
|
||||
@@ -629,9 +615,8 @@ export const layer = (options?: Options) =>
|
||||
|
||||
let applied: Map<ServerName, Mcp.ServerConfig> | undefined
|
||||
const overrides = new Map<ServerName, Mcp.ServerConfig | false>()
|
||||
const reconcileLock = Semaphore.makeUnsafe(1)
|
||||
const reconcile = Effect.fnUntraced(function* () {
|
||||
const servers = state.get().servers
|
||||
const reconcile = Effect.fnUntraced(function* (next: Draft) {
|
||||
const servers = new Map(next.list())
|
||||
if (!applied && entries.size === 0) {
|
||||
for (const [name, server] of servers) {
|
||||
entries.set(name, {
|
||||
@@ -692,7 +677,7 @@ export const layer = (options?: Options) =>
|
||||
Stream.runForEach((event) => Effect.sync(() => fork(reconnect(event.data.integrationID)))),
|
||||
),
|
||||
)
|
||||
const state: State.Interface<Data, Draft> = State.create<Data, Draft>({
|
||||
const state = State.create<Data, Draft>({
|
||||
name: "mcp",
|
||||
initial: () => ({
|
||||
servers: new Map(
|
||||
@@ -717,12 +702,7 @@ export const layer = (options?: Options) =>
|
||||
},
|
||||
remove: (server) => draft.servers.delete(ServerName.make(server)),
|
||||
}),
|
||||
notify: () =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* Fiber.await(fork(reconcileLock.withPermit(reconcile())))
|
||||
if (Exit.isFailure(exit) && root.state._tag === "Closed" && Cause.hasInterruptsOnly(exit.cause)) return
|
||||
yield* exit
|
||||
}),
|
||||
finalize: reconcile,
|
||||
})
|
||||
|
||||
// Suspend so each await sees current entries; a bare Map iterator is exhausted after one run.
|
||||
|
||||
@@ -292,20 +292,11 @@ const layer = Layer.effect(
|
||||
pending.delete(input.requestID)
|
||||
if (input.reply !== "always" || !existing.request.save?.length) return
|
||||
|
||||
const rememberedRules = yield* savedRules()
|
||||
for (const [id, item] of pending) {
|
||||
const rules = yield* configured(item.request.sessionID, item.agent).pipe(
|
||||
const result = yield* evaluateInput({ ...item.request, agent: item.agent }).pipe(
|
||||
Effect.catchTag("Session.NotFoundError", () => Effect.undefined),
|
||||
)
|
||||
if (!rules) continue
|
||||
if (denied(item.request, rules)) continue
|
||||
const effective = [...rules, ...rememberedRules]
|
||||
if (
|
||||
!item.request.resources.every(
|
||||
(resource) => evaluate(item.request.action, resource, effective).effect === "allow",
|
||||
)
|
||||
)
|
||||
continue
|
||||
if (result?.effect !== "allow") continue
|
||||
yield* bus.publish(Permission.Event.Replied, {
|
||||
sessionID: item.request.sessionID,
|
||||
requestID: item.request.id,
|
||||
|
||||
@@ -2,9 +2,9 @@ export * as PermissionSaved from "./saved.js"
|
||||
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { Database } from "../database/database.js"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Project } from "../project.js"
|
||||
import { PermissionTable } from "./sql.js"
|
||||
import { PermissionSaved } from "@opencode-ai/schema/permission-saved"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"
|
||||
import type { Project } from "@opencode-ai/schema/project"
|
||||
import { Timestamps } from "../database/schema.sql.js"
|
||||
import { Project } from "../project.js"
|
||||
import { ProjectTable } from "../project/sql.js"
|
||||
import type { PermissionSaved } from "./saved.js"
|
||||
|
||||
|
||||
@@ -369,9 +369,10 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
|
||||
},
|
||||
vcs: {
|
||||
get: () => response(vcs.info()),
|
||||
base: () => response(vcs.base()),
|
||||
branches: (input) => response(vcs.branches({ search: input?.search, limit: input?.limit })),
|
||||
status: () => response(vcs.status()),
|
||||
diff: (input) => response(vcs.diff(input.mode, { context: input.context })),
|
||||
diff: (input) => response(vcs.diff(input.mode, { context: input.context, base: input.base })),
|
||||
transform: vcs.transform,
|
||||
reload: vcs.reload,
|
||||
},
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Bus } from "../bus.js"
|
||||
import { ModelsDev } from "../models-dev.js"
|
||||
import { Provider } from "../provider.js"
|
||||
|
||||
export const ModelsDevPlugin = define({
|
||||
id: "opencode.models.dev",
|
||||
|
||||
@@ -2,12 +2,12 @@ export * as PlanPlugin from "./plan.js"
|
||||
|
||||
import { Message, ToolFailure } from "@opencode-ai/ai"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { SessionEvent } from "@opencode-ai/schema/session-event"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Effect, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { Agent } from "../agent.js"
|
||||
import { Permission } from "../permission.js"
|
||||
import { SessionEvent } from "../session/event.js"
|
||||
|
||||
const plan = Agent.ID.make("plan")
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ export * as VariantPlugin from "./variant.js"
|
||||
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Model } from "../model.js"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Provider } from "../provider.js"
|
||||
|
||||
export const Plugin = define({
|
||||
|
||||
@@ -4,10 +4,11 @@ import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { BranchList, FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
|
||||
import { Base, BranchList, FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { Location } from "../../location.js"
|
||||
import type { Adapter, BranchOptions, DiffOptions } from "../../vcs.js"
|
||||
import { DiffError } from "../../vcs.js"
|
||||
import {
|
||||
chunksByFile,
|
||||
emptyPatch,
|
||||
@@ -35,9 +36,10 @@ export const Plugin = define({
|
||||
id: "git",
|
||||
name: "Git",
|
||||
info: () => adapter.info(),
|
||||
base: () => adapter.base(),
|
||||
branches: (input) => adapter.branches({ search: input.search, limit: input.limit }),
|
||||
status: () => adapter.status(),
|
||||
diff: (input) => adapter.diff(input.mode, { context: input.context }),
|
||||
diff: (input) => adapter.diff(input.mode, { context: input.context, base: input.base }),
|
||||
})
|
||||
})
|
||||
}),
|
||||
@@ -48,7 +50,7 @@ export const Plugin = define({
|
||||
* batched through one `git diff` invocation where possible and capped by
|
||||
* per-file and total byte budgets, falling back to empty patches when capped.
|
||||
*/
|
||||
function make(proc: AppProcess.Interface, input: { directory: string; worktree: string }): Adapter {
|
||||
function make(proc: AppProcess.Interface, input: { directory: string; worktree: string }) {
|
||||
// Listing commands scope pathspecs to the requested directory; per-file
|
||||
// commands run from the worktree root because git lists root-relative paths.
|
||||
const ctx: Ctx = { git: makeGit(proc), directory: input.directory, worktree: input.worktree }
|
||||
@@ -60,6 +62,7 @@ function make(proc: AppProcess.Interface, input: { directory: string; worktree:
|
||||
})
|
||||
return { branch: { current, default: root?.name } } satisfies Info
|
||||
}),
|
||||
base: () => ctx.git.base(ctx.directory),
|
||||
branches: Effect.fn("VcsGit.branches")(function* (options?: BranchOptions) {
|
||||
return yield* ctx.git.branches(ctx.directory, options)
|
||||
}),
|
||||
@@ -93,25 +96,23 @@ function make(proc: AppProcess.Interface, input: { directory: string; worktree:
|
||||
return yield* track(ctx, (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined, options)
|
||||
}
|
||||
|
||||
const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], {
|
||||
concurrency: 2,
|
||||
})
|
||||
if (!root) return []
|
||||
if (current && current === root.name) return []
|
||||
const ref = yield* git.mergeBase(ctx.directory, root.ref)
|
||||
if (!ref) return []
|
||||
return yield* diffAgainstRef(ctx, ref, options)
|
||||
if (!(yield* git.hasHead(ctx.directory))) {
|
||||
return mode === "committed" ? [] : yield* track(ctx, undefined, options)
|
||||
}
|
||||
const base = options?.base ?? (yield* git.defaultBranch(ctx.directory))?.ref
|
||||
const ref = base ? yield* git.mergeBase(ctx.directory, base) : undefined
|
||||
if (!ref) {
|
||||
return yield* new DiffError({
|
||||
message: base ? `No merge base available for ${base}` : "No review base available",
|
||||
})
|
||||
}
|
||||
return yield* diffAgainstRef(ctx, ref, { ...options, target: mode === "committed" ? "HEAD" : undefined })
|
||||
}),
|
||||
}
|
||||
} satisfies Adapter
|
||||
}
|
||||
|
||||
type Kind = FileStatus["status"]
|
||||
|
||||
interface Base {
|
||||
readonly name: string
|
||||
readonly ref: string
|
||||
}
|
||||
|
||||
interface Item {
|
||||
readonly file: string
|
||||
readonly code: string
|
||||
@@ -124,8 +125,11 @@ interface Stat {
|
||||
readonly deletions: number
|
||||
}
|
||||
|
||||
interface PatchOptions {
|
||||
readonly context?: number
|
||||
interface GitDiffOptions extends DiffOptions {
|
||||
readonly target?: "HEAD"
|
||||
}
|
||||
|
||||
interface PatchOptions extends GitDiffOptions {
|
||||
readonly maxOutputBytes?: number
|
||||
}
|
||||
|
||||
@@ -196,7 +200,7 @@ function makeGit(proc: AppProcess.Interface) {
|
||||
const result = yield* run(["config", "init.defaultBranch"], { cwd })
|
||||
const name = result.text().trim()
|
||||
if (!name || !list.includes(name)) return
|
||||
return { name, ref: name } satisfies Base
|
||||
return { name, ref: name }
|
||||
})
|
||||
|
||||
const primary = Effect.fnUntraced(function* (cwd: string) {
|
||||
@@ -239,15 +243,70 @@ function makeGit(proc: AppProcess.Interface) {
|
||||
.trim()
|
||||
.replace(/^refs\/remotes\//, "")
|
||||
const name = ref.startsWith(`${remote}/`) ? ref.slice(`${remote}/`.length) : ""
|
||||
if (name) return { name, ref } satisfies Base
|
||||
if (name) return { name, ref }
|
||||
}
|
||||
}
|
||||
|
||||
const list = yield* lines(["for-each-ref", "--format=%(refname:short)", "refs/heads"], { cwd })
|
||||
const next = yield* configured(cwd, list)
|
||||
if (next) return next
|
||||
if (list.includes("main")) return { name: "main", ref: "main" } satisfies Base
|
||||
if (list.includes("master")) return { name: "master", ref: "master" } satisfies Base
|
||||
if (list.includes("main")) return { name: "main", ref: "main" }
|
||||
if (list.includes("master")) return { name: "master", ref: "master" }
|
||||
})
|
||||
|
||||
const resolve = Effect.fnUntraced(function* (cwd: string, ref: string) {
|
||||
const result = yield* run(["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`], { cwd })
|
||||
if (result.exitCode !== 0) return
|
||||
return result.text().trim() || undefined
|
||||
})
|
||||
|
||||
const ancestor = Effect.fnUntraced(function* (cwd: string, commit: string, ref: string) {
|
||||
if (!/^[a-f0-9]{40,64}$/.test(commit)) return false
|
||||
return (yield* run(["merge-base", "--is-ancestor", commit, ref], { cwd })).exitCode === 0
|
||||
})
|
||||
|
||||
const namedRef = Effect.fnUntraced(function* (cwd: string, input: string) {
|
||||
// Creation hints must identify a branch, not HEAD, an object ID, or a revision expression.
|
||||
if (input === "HEAD" || input.endsWith("/HEAD") || /[~^:@{}\s]/.test(input)) return
|
||||
const ref = (yield* text(["rev-parse", "--symbolic-full-name", "--verify", "--end-of-options", input], {
|
||||
cwd,
|
||||
})).trim()
|
||||
if (!/^refs\/(heads|remotes)\/.+/.test(ref) || ref.endsWith("/HEAD") || !(yield* resolve(cwd, ref))) return
|
||||
return { name: ref.replace(/^refs\/heads\//, "").replace(/^refs\/remotes\/[^/]+\//, ""), ref }
|
||||
})
|
||||
|
||||
const base = Effect.fn("VcsGit.base")(function* (cwd: string) {
|
||||
if (!(yield* hasHead(cwd))) return null
|
||||
const current = yield* branch(cwd)
|
||||
if (!current) return yield* new DiffError({ message: "Choose a review base" })
|
||||
const history = (yield* lines(
|
||||
["reflog", "show", "--max-count=256", "--format=%H%x00%gs", `refs/heads/${current}`],
|
||||
{
|
||||
cwd,
|
||||
},
|
||||
)).flatMap((line) => {
|
||||
const match = /^([a-f0-9]+)\0(.+)$/.exec(line)
|
||||
return match ? [{ commit: match[1], message: match[2] }] : []
|
||||
})
|
||||
const renamed = history.some((entry) => entry.message.startsWith("Branch: renamed "))
|
||||
const creation = renamed ? undefined : history.find((entry) => entry.message.startsWith("branch: Created from "))
|
||||
const origin = creation?.message.slice("branch: Created from ".length)
|
||||
if (creation && origin) {
|
||||
const candidate = yield* namedRef(cwd, origin)
|
||||
if (
|
||||
candidate &&
|
||||
candidate.name !== current &&
|
||||
(yield* ancestor(cwd, creation.commit, "HEAD")) &&
|
||||
(yield* ancestor(cwd, creation.commit, candidate.ref))
|
||||
) {
|
||||
return { ...candidate, source: "reflog" } satisfies Base
|
||||
}
|
||||
}
|
||||
const root = yield* defaultBranch(cwd)
|
||||
if (!root || current !== root.name) return yield* new DiffError({ message: "Choose a review base" })
|
||||
const candidate = yield* namedRef(cwd, root.ref)
|
||||
if (!candidate) return yield* new DiffError({ message: "The default review base is unavailable" })
|
||||
return { name: root.name, ref: candidate.ref, source: "default" } satisfies Base
|
||||
})
|
||||
|
||||
const hasHead = Effect.fn("VcsGit.hasHead")(function* (cwd: string) {
|
||||
@@ -256,7 +315,9 @@ function makeGit(proc: AppProcess.Interface) {
|
||||
})
|
||||
|
||||
const mergeBase = Effect.fn("VcsGit.mergeBase")(function* (cwd: string, base: string) {
|
||||
const result = yield* run(["merge-base", base, "HEAD"], { cwd })
|
||||
const ref = yield* resolve(cwd, base)
|
||||
if (!ref) return
|
||||
const result = yield* run(["merge-base", ref, "HEAD"], { cwd })
|
||||
if (result.exitCode !== 0) return
|
||||
return result.text().trim() || undefined
|
||||
})
|
||||
@@ -272,10 +333,13 @@ function makeGit(proc: AppProcess.Interface) {
|
||||
})
|
||||
})
|
||||
|
||||
const diff = Effect.fn("VcsGit.diffNames")(function* (cwd: string, ref: string) {
|
||||
const list = nuls(
|
||||
yield* text(["diff", "--no-ext-diff", "--no-renames", "--name-status", "-z", ref, "--", "."], { cwd }),
|
||||
const diff = Effect.fn("VcsGit.diffNames")(function* (cwd: string, ref: string, target?: string) {
|
||||
const result = yield* run(
|
||||
["diff", "--no-ext-diff", "--no-renames", "--name-status", "-z", ref, ...(target ? [target] : []), "--", "."],
|
||||
{ cwd },
|
||||
)
|
||||
if (result.exitCode !== 0) return yield* new DiffError({ message: "Unable to list Git changes" })
|
||||
const list = nuls(result.text())
|
||||
return list.flatMap((code, idx) => {
|
||||
if (idx % 2 !== 0) return []
|
||||
const file = list[idx + 1]
|
||||
@@ -284,9 +348,12 @@ function makeGit(proc: AppProcess.Interface) {
|
||||
})
|
||||
})
|
||||
|
||||
const stats = Effect.fn("VcsGit.stats")(function* (cwd: string, ref: string) {
|
||||
const stats = Effect.fn("VcsGit.stats")(function* (cwd: string, ref: string, target?: string) {
|
||||
return nuls(
|
||||
yield* text(["diff", "--no-ext-diff", "--no-renames", "--numstat", "-z", ref, "--", "."], { cwd }),
|
||||
yield* text(
|
||||
["diff", "--no-ext-diff", "--no-renames", "--numstat", "-z", ref, ...(target ? [target] : []), "--", "."],
|
||||
{ cwd },
|
||||
),
|
||||
).flatMap((item) => {
|
||||
const a = item.indexOf("\t")
|
||||
const b = item.indexOf("\t", a + 1)
|
||||
@@ -309,7 +376,17 @@ function makeGit(proc: AppProcess.Interface) {
|
||||
|
||||
const patch = Effect.fn("VcsGit.patch")(function* (cwd: string, ref: string, file: string, options?: PatchOptions) {
|
||||
const result = yield* run(
|
||||
["diff", "--patch", "--no-ext-diff", "--no-renames", `--unified=${options?.context ?? 3}`, ref, "--", file],
|
||||
[
|
||||
"diff",
|
||||
"--patch",
|
||||
"--no-ext-diff",
|
||||
"--no-renames",
|
||||
`--unified=${options?.context ?? 3}`,
|
||||
ref,
|
||||
...(options?.target ? [options.target] : []),
|
||||
"--",
|
||||
file,
|
||||
],
|
||||
{ cwd, maxOutputBytes: options?.maxOutputBytes },
|
||||
)
|
||||
return { text: result.truncated ? "" : result.text(), truncated: result.truncated } satisfies Patch
|
||||
@@ -317,7 +394,17 @@ function makeGit(proc: AppProcess.Interface) {
|
||||
|
||||
const patchAll = Effect.fn("VcsGit.patchAll")(function* (cwd: string, ref: string, options?: PatchOptions) {
|
||||
const result = yield* run(
|
||||
["diff", "--patch", "--no-ext-diff", "--no-renames", `--unified=${options?.context ?? 3}`, ref, "--", "."],
|
||||
[
|
||||
"diff",
|
||||
"--patch",
|
||||
"--no-ext-diff",
|
||||
"--no-renames",
|
||||
`--unified=${options?.context ?? 3}`,
|
||||
ref,
|
||||
...(options?.target ? [options.target] : []),
|
||||
"--",
|
||||
".",
|
||||
],
|
||||
{ cwd, maxOutputBytes: options?.maxOutputBytes },
|
||||
)
|
||||
return { text: result.text(), truncated: result.truncated } satisfies Patch
|
||||
@@ -367,6 +454,7 @@ function makeGit(proc: AppProcess.Interface) {
|
||||
return {
|
||||
branch,
|
||||
branches,
|
||||
base,
|
||||
defaultBranch,
|
||||
hasHead,
|
||||
mergeBase,
|
||||
@@ -393,10 +481,11 @@ const merge = (...lists: Item[][]) => {
|
||||
|
||||
const emptyBatch = () => ({ patches: new Map<string, string>(), capped: false })
|
||||
|
||||
const batchPatches = Effect.fnUntraced(function* (ctx: Ctx, ref: string, list: Item[], options?: DiffOptions) {
|
||||
const batchPatches = Effect.fnUntraced(function* (ctx: Ctx, ref: string, list: Item[], options?: GitDiffOptions) {
|
||||
if (list.length === 0) return emptyBatch()
|
||||
|
||||
const result = yield* ctx.git.patchAll(ctx.directory, ref, {
|
||||
target: options?.target,
|
||||
context: options?.context ?? PATCH_CONTEXT_LINES,
|
||||
maxOutputBytes: MAX_TOTAL_PATCH_BYTES,
|
||||
})
|
||||
@@ -407,7 +496,12 @@ const batchPatches = Effect.fnUntraced(function* (ctx: Ctx, ref: string, list: I
|
||||
}
|
||||
})
|
||||
|
||||
const nativePatch = Effect.fnUntraced(function* (ctx: Ctx, ref: string | undefined, item: Item, options?: DiffOptions) {
|
||||
const nativePatch = Effect.fnUntraced(function* (
|
||||
ctx: Ctx,
|
||||
ref: string | undefined,
|
||||
item: Item,
|
||||
options?: GitDiffOptions,
|
||||
) {
|
||||
const result =
|
||||
item.code === "??" || !ref
|
||||
? yield* ctx.git.patchUntracked(ctx.worktree, item.file, {
|
||||
@@ -415,6 +509,7 @@ const nativePatch = Effect.fnUntraced(function* (ctx: Ctx, ref: string | undefin
|
||||
maxOutputBytes: MAX_PATCH_BYTES,
|
||||
})
|
||||
: yield* ctx.git.patch(ctx.worktree, ref, item.file, {
|
||||
target: options?.target,
|
||||
context: options?.context ?? PATCH_CONTEXT_LINES,
|
||||
maxOutputBytes: MAX_PATCH_BYTES,
|
||||
})
|
||||
@@ -434,7 +529,7 @@ const patchForItem = Effect.fnUntraced(function* (
|
||||
item: Item,
|
||||
batch: { patches: Map<string, string>; capped: boolean },
|
||||
capped: boolean,
|
||||
options?: DiffOptions,
|
||||
options?: GitDiffOptions,
|
||||
) {
|
||||
if (capped) return emptyPatch(item.file)
|
||||
|
||||
@@ -450,7 +545,7 @@ const files = Effect.fnUntraced(function* (
|
||||
list: Item[],
|
||||
map: Map<string, { additions: number; deletions: number }>,
|
||||
batch: { patches: Map<string, string>; capped: boolean },
|
||||
options?: DiffOptions,
|
||||
options?: GitDiffOptions,
|
||||
) {
|
||||
const next: FileDiff.Info[] = []
|
||||
let total = 0
|
||||
@@ -459,7 +554,7 @@ const files = Effect.fnUntraced(function* (
|
||||
for (const item of list.toSorted((a, b) => a.file.localeCompare(b.file))) {
|
||||
const stat =
|
||||
map.get(item.file) ??
|
||||
(item.status === "added" ? yield* ctx.git.statUntracked(ctx.worktree, item.file) : undefined)
|
||||
(!options?.target && item.status === "added" ? yield* ctx.git.statUntracked(ctx.worktree, item.file) : undefined)
|
||||
const patch = yield* patchForItem(ctx, ref, item, batch, capped, options)
|
||||
const result: { patch: string; capped: boolean } = capped
|
||||
? { patch, capped: true }
|
||||
@@ -481,9 +576,13 @@ const files = Effect.fnUntraced(function* (
|
||||
return next
|
||||
})
|
||||
|
||||
const diffAgainstRef = Effect.fnUntraced(function* (ctx: Ctx, ref: string, options?: DiffOptions) {
|
||||
const diffAgainstRef = Effect.fnUntraced(function* (ctx: Ctx, ref: string, options?: GitDiffOptions) {
|
||||
const [list, stats, extra] = yield* Effect.all(
|
||||
[ctx.git.diff(ctx.directory, ref), ctx.git.stats(ctx.directory, ref), ctx.git.status(ctx.directory)],
|
||||
[
|
||||
ctx.git.diff(ctx.directory, ref, options?.target),
|
||||
ctx.git.stats(ctx.directory, ref, options?.target),
|
||||
options?.target ? Effect.succeed([]) : ctx.git.status(ctx.directory),
|
||||
],
|
||||
{ concurrency: 3 },
|
||||
)
|
||||
return yield* files(
|
||||
|
||||
@@ -10,6 +10,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { Location } from "../../location.js"
|
||||
import type { Adapter, DiffOptions } from "../../vcs.js"
|
||||
import { DiffError } from "../../vcs.js"
|
||||
import {
|
||||
addPatch,
|
||||
chunksByFile,
|
||||
@@ -42,7 +43,7 @@ export const Plugin = define({
|
||||
info: () => adapter.info(),
|
||||
branches: (input) => adapter.branches({ search: input.search, limit: input.limit }),
|
||||
status: () => adapter.status(),
|
||||
diff: (input) => adapter.diff(input.mode, { context: input.context }),
|
||||
diff: (input) => adapter.diff(input.mode, { context: input.context, base: input.base }),
|
||||
})
|
||||
})
|
||||
}),
|
||||
@@ -121,6 +122,11 @@ function make(
|
||||
}),
|
||||
diff: Effect.fn("VcsHg.diff")(function* (mode: Mode, options?: DiffOptions) {
|
||||
if (mode === "working") return yield* diffAgainst(undefined, options)
|
||||
if (mode === "committed" || options?.base !== undefined) {
|
||||
return yield* new DiffError({
|
||||
message: "The Mercurial provider does not support committed reviews or explicit bases",
|
||||
})
|
||||
}
|
||||
|
||||
const branch = yield* hg.branch()
|
||||
if (!branch || branch === "default") return []
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
export * as WarmingPlugin from "./warming.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import { Clock, Duration, Effect, Scope } from "effect"
|
||||
import { Config } from "../config.js"
|
||||
import { SessionSchema } from "../session/schema.js"
|
||||
|
||||
const defaults = {
|
||||
prompt: "This is a keep-alive request. Do not perform any work or use tools. Reply with exactly: OK",
|
||||
@@ -26,8 +26,8 @@ export const Plugin = define({
|
||||
})
|
||||
|
||||
const scope = yield* Scope.Scope
|
||||
const sessions = new Map<SessionSchema.ID, { last: number; expires: number; settings: typeof defaults }>()
|
||||
const loop: (sessionID: SessionSchema.ID) => Effect.Effect<void> = Effect.fn("WarmingPlugin.loop")(
|
||||
const sessions = new Map<Session.ID, { last: number; expires: number; settings: typeof defaults }>()
|
||||
const loop: (sessionID: Session.ID) => Effect.Effect<void> = Effect.fn("WarmingPlugin.loop")(
|
||||
function* (sessionID) {
|
||||
const current = sessions.get(sessionID)
|
||||
if (!current) return
|
||||
|
||||
+206
-207
@@ -88,233 +88,232 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Pty") {}
|
||||
|
||||
const layer = () =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const location = yield* Location.Service
|
||||
const shell = yield* ShellSelect.Service
|
||||
const context = yield* Effect.context()
|
||||
const runFork = Effect.runForkWith(context)
|
||||
const sessions = new Map<PtyID, Active>()
|
||||
const exitOrder: PtyID[] = []
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const location = yield* Location.Service
|
||||
const shell = yield* ShellSelect.Service
|
||||
const context = yield* Effect.context()
|
||||
const runFork = Effect.runForkWith(context)
|
||||
const sessions = new Map<PtyID, Active>()
|
||||
const exitOrder: PtyID[] = []
|
||||
|
||||
function notifyEnd(session: Active, event: { exitCode?: number }) {
|
||||
for (const subscriber of session.subscribers.values()) {
|
||||
if (!subscriber.active) {
|
||||
subscriber.end = event
|
||||
continue
|
||||
}
|
||||
try {
|
||||
subscriber.onEnd(event)
|
||||
} catch {}
|
||||
function notifyEnd(session: Active, event: { exitCode?: number }) {
|
||||
for (const subscriber of session.subscribers.values()) {
|
||||
if (!subscriber.active) {
|
||||
subscriber.end = event
|
||||
continue
|
||||
}
|
||||
session.subscribers.clear()
|
||||
try {
|
||||
subscriber.onEnd(event)
|
||||
} catch {}
|
||||
}
|
||||
session.subscribers.clear()
|
||||
}
|
||||
|
||||
function teardown(session: Active) {
|
||||
for (const listener of session.listeners) listener.dispose()
|
||||
session.listeners.length = 0
|
||||
if (session.info.status === "running") {
|
||||
try {
|
||||
session.process.kill()
|
||||
} catch {}
|
||||
}
|
||||
notifyEnd(session, {})
|
||||
function teardown(session: Active) {
|
||||
for (const listener of session.listeners) listener.dispose()
|
||||
session.listeners.length = 0
|
||||
if (session.info.status === "running") {
|
||||
try {
|
||||
session.process.kill()
|
||||
} catch {}
|
||||
}
|
||||
notifyEnd(session, {})
|
||||
}
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
for (const session of sessions.values()) teardown(session)
|
||||
sessions.clear()
|
||||
exitOrder.length = 0
|
||||
}),
|
||||
)
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
for (const session of sessions.values()) teardown(session)
|
||||
sessions.clear()
|
||||
exitOrder.length = 0
|
||||
}),
|
||||
)
|
||||
|
||||
const requireSession = Effect.fn("Pty.requireSession")(function* (id: PtyID) {
|
||||
const session = sessions.get(id)
|
||||
if (!session) return yield* new NotFoundError({ ptyID: id })
|
||||
return session
|
||||
})
|
||||
const requireSession = Effect.fn("Pty.requireSession")(function* (id: PtyID) {
|
||||
const session = sessions.get(id)
|
||||
if (!session) return yield* new NotFoundError({ ptyID: id })
|
||||
return session
|
||||
})
|
||||
|
||||
const removeSession = Effect.fnUntraced(function* (id: PtyID) {
|
||||
const session = sessions.get(id)
|
||||
if (!session) return
|
||||
sessions.delete(id)
|
||||
const index = exitOrder.indexOf(id)
|
||||
if (index !== -1) exitOrder.splice(index, 1)
|
||||
yield* Effect.logInfo("removing session", { id })
|
||||
teardown(session)
|
||||
yield* bus.publish(Pty.Event.Deleted, { id: session.info.id })
|
||||
})
|
||||
const removeSession = Effect.fnUntraced(function* (id: PtyID) {
|
||||
const session = sessions.get(id)
|
||||
if (!session) return
|
||||
sessions.delete(id)
|
||||
const index = exitOrder.indexOf(id)
|
||||
if (index !== -1) exitOrder.splice(index, 1)
|
||||
yield* Effect.logInfo("removing session", { id })
|
||||
teardown(session)
|
||||
yield* bus.publish(Pty.Event.Deleted, { id: session.info.id })
|
||||
})
|
||||
|
||||
const remove = Effect.fn("Pty.remove")(function* (id: PtyID) {
|
||||
yield* requireSession(id)
|
||||
yield* removeSession(id)
|
||||
})
|
||||
const remove = Effect.fn("Pty.remove")(function* (id: PtyID) {
|
||||
yield* requireSession(id)
|
||||
yield* removeSession(id)
|
||||
})
|
||||
|
||||
const list = Effect.fn("Pty.list")(function* () {
|
||||
return Array.from(sessions.values()).map((session) => session.info)
|
||||
})
|
||||
const list = Effect.fn("Pty.list")(function* () {
|
||||
return Array.from(sessions.values()).map((session) => session.info)
|
||||
})
|
||||
|
||||
const get = Effect.fn("Pty.get")(function* (id: PtyID) {
|
||||
return (yield* requireSession(id)).info
|
||||
})
|
||||
const get = Effect.fn("Pty.get")(function* (id: PtyID) {
|
||||
return (yield* requireSession(id)).info
|
||||
})
|
||||
|
||||
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
|
||||
const id = PtyID.ascending()
|
||||
const command = input.command || (yield* shell.resolve({ priority: "config" }))
|
||||
const args = ShellSelect.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])]
|
||||
const cwd = input.cwd || location.directory
|
||||
const env = {
|
||||
...process.env,
|
||||
...input.env,
|
||||
TERM: "xterm-256color",
|
||||
OPENCODE_TERMINAL: "1",
|
||||
} as Record<string, string>
|
||||
if (process.platform === "win32") {
|
||||
env.LC_ALL = "C.UTF-8"
|
||||
env.LC_CTYPE = "C.UTF-8"
|
||||
env.LANG = "C.UTF-8"
|
||||
}
|
||||
yield* Effect.logInfo("creating session", { id, cmd: command, args, cwd })
|
||||
const { spawn } = yield* Effect.promise(() => pty())
|
||||
const proc = yield* Effect.sync(() => spawn(command, args, { name: "xterm-256color", cwd, env }))
|
||||
const info: Info = {
|
||||
id,
|
||||
title: input.title || `Terminal ${id.slice(-4)}`,
|
||||
command,
|
||||
args,
|
||||
cwd,
|
||||
status: "running",
|
||||
pid: proc.pid,
|
||||
}
|
||||
const session: Active = {
|
||||
info,
|
||||
process: proc,
|
||||
buffer: "",
|
||||
bufferCursor: 0,
|
||||
cursor: 0,
|
||||
subscribers: new Map(),
|
||||
listeners: [],
|
||||
}
|
||||
sessions.set(id, session)
|
||||
session.listeners.push(
|
||||
proc.onData((chunk) => {
|
||||
session.cursor += chunk.length
|
||||
for (const [token, subscriber] of session.subscribers.entries()) {
|
||||
if (!subscriber.active) {
|
||||
subscriber.pending.push(chunk)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
subscriber.onData(chunk)
|
||||
} catch {
|
||||
session.subscribers.delete(token)
|
||||
}
|
||||
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
|
||||
const id = PtyID.ascending()
|
||||
const command = input.command || (yield* shell.resolve({ priority: "config" }))
|
||||
const args = ShellSelect.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])]
|
||||
const cwd = input.cwd || location.directory
|
||||
const env = {
|
||||
...process.env,
|
||||
...input.env,
|
||||
TERM: "xterm-256color",
|
||||
OPENCODE_TERMINAL: "1",
|
||||
} as Record<string, string>
|
||||
if (process.platform === "win32") {
|
||||
env.LC_ALL = "C.UTF-8"
|
||||
env.LC_CTYPE = "C.UTF-8"
|
||||
env.LANG = "C.UTF-8"
|
||||
}
|
||||
yield* Effect.logInfo("creating session", { id, cmd: command, args, cwd })
|
||||
const { spawn } = yield* Effect.promise(() => pty())
|
||||
const proc = yield* Effect.sync(() => spawn(command, args, { name: "xterm-256color", cwd, env }))
|
||||
const info: Info = {
|
||||
id,
|
||||
title: input.title || `Terminal ${id.slice(-4)}`,
|
||||
command,
|
||||
args,
|
||||
cwd,
|
||||
status: "running",
|
||||
pid: proc.pid,
|
||||
}
|
||||
const session: Active = {
|
||||
info,
|
||||
process: proc,
|
||||
buffer: "",
|
||||
bufferCursor: 0,
|
||||
cursor: 0,
|
||||
subscribers: new Map(),
|
||||
listeners: [],
|
||||
}
|
||||
sessions.set(id, session)
|
||||
session.listeners.push(
|
||||
proc.onData((chunk) => {
|
||||
session.cursor += chunk.length
|
||||
for (const [token, subscriber] of session.subscribers.entries()) {
|
||||
if (!subscriber.active) {
|
||||
subscriber.pending.push(chunk)
|
||||
continue
|
||||
}
|
||||
session.buffer += chunk
|
||||
if (session.buffer.length <= BUFFER_LIMIT) return
|
||||
const excess = session.buffer.length - BUFFER_LIMIT
|
||||
session.buffer = session.buffer.slice(excess)
|
||||
session.bufferCursor += excess
|
||||
}),
|
||||
proc.onExit(({ exitCode }) => {
|
||||
if (session.info.status === "exited") return
|
||||
session.info.status = "exited"
|
||||
session.info.exitCode = exitCode
|
||||
notifyEnd(session, { exitCode })
|
||||
exitOrder.push(id)
|
||||
runFork(
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.logInfo("session exited", { id, exitCode })
|
||||
yield* bus.publish(Pty.Event.Exited, { id, exitCode })
|
||||
while (exitOrder.length > EXITED_LIMIT) {
|
||||
const oldest = exitOrder[0]
|
||||
if (!oldest) break
|
||||
yield* removeSession(oldest)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
yield* bus.publish(Pty.Event.Created, { info })
|
||||
return info
|
||||
})
|
||||
|
||||
const update = Effect.fn("Pty.update")(function* (id: PtyID, input: UpdateInput) {
|
||||
const session = yield* requireSession(id)
|
||||
if (input.title) session.info.title = input.title
|
||||
if (input.size && session.info.status === "running") session.process.resize(input.size.cols, input.size.rows)
|
||||
yield* bus.publish(Pty.Event.Updated, { info: session.info })
|
||||
return session.info
|
||||
})
|
||||
|
||||
const write = Effect.fn("Pty.write")(function* (id: PtyID, data: string) {
|
||||
const session = yield* requireSession(id)
|
||||
if (session.info.status === "running") session.process.write(data)
|
||||
})
|
||||
|
||||
const attach = Effect.fn("Pty.attach")(function* (id: PtyID, input: AttachInput) {
|
||||
const session = yield* requireSession(id)
|
||||
if (session.info.status !== "running") return yield* new ExitedError({ ptyID: id })
|
||||
yield* Effect.logInfo("client attached to session", { id, directory: location.directory })
|
||||
const token = {}
|
||||
const subscriber: Subscriber = {
|
||||
onData: input.onData,
|
||||
onEnd: input.onEnd,
|
||||
active: false,
|
||||
detached: false,
|
||||
pending: [],
|
||||
}
|
||||
session.subscribers.set(token, subscriber)
|
||||
const start = session.bufferCursor
|
||||
const end = session.cursor
|
||||
const from =
|
||||
input.cursor === -1
|
||||
? end
|
||||
: typeof input.cursor === "number" && Number.isSafeInteger(input.cursor)
|
||||
? Math.max(0, input.cursor)
|
||||
: 0
|
||||
const replay = (() => {
|
||||
if (!session.buffer || from >= end) return ""
|
||||
const offset = Math.max(0, from - start)
|
||||
if (offset >= session.buffer.length) return ""
|
||||
return session.buffer.slice(offset)
|
||||
})()
|
||||
return {
|
||||
replay,
|
||||
cursor: end,
|
||||
write: (data: string) => {
|
||||
if (session.info.status === "running") session.process.write(data)
|
||||
},
|
||||
activate: () => {
|
||||
if (subscriber.active || subscriber.detached) return
|
||||
subscriber.active = true
|
||||
try {
|
||||
for (const chunk of subscriber.pending) subscriber.onData(chunk)
|
||||
subscriber.pending.length = 0
|
||||
if (subscriber.end) subscriber.onEnd(subscriber.end)
|
||||
subscriber.onData(chunk)
|
||||
} catch {
|
||||
session.subscribers.delete(token)
|
||||
}
|
||||
},
|
||||
detach: () => {
|
||||
subscriber.detached = true
|
||||
subscriber.pending.length = 0
|
||||
subscriber.end = undefined
|
||||
session.subscribers.delete(token)
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
session.buffer += chunk
|
||||
if (session.buffer.length <= BUFFER_LIMIT) return
|
||||
const excess = session.buffer.length - BUFFER_LIMIT
|
||||
session.buffer = session.buffer.slice(excess)
|
||||
session.bufferCursor += excess
|
||||
}),
|
||||
proc.onExit(({ exitCode }) => {
|
||||
if (session.info.status === "exited") return
|
||||
session.info.status = "exited"
|
||||
session.info.exitCode = exitCode
|
||||
notifyEnd(session, { exitCode })
|
||||
exitOrder.push(id)
|
||||
runFork(
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.logInfo("session exited", { id, exitCode })
|
||||
yield* bus.publish(Pty.Event.Exited, { id, exitCode })
|
||||
while (exitOrder.length > EXITED_LIMIT) {
|
||||
const oldest = exitOrder[0]
|
||||
if (!oldest) break
|
||||
yield* removeSession(oldest)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
yield* bus.publish(Pty.Event.Created, { info })
|
||||
return info
|
||||
})
|
||||
|
||||
return Service.of({ list, get, create, update, remove, write, attach })
|
||||
}),
|
||||
)
|
||||
const update = Effect.fn("Pty.update")(function* (id: PtyID, input: UpdateInput) {
|
||||
const session = yield* requireSession(id)
|
||||
if (input.title) session.info.title = input.title
|
||||
if (input.size && session.info.status === "running") session.process.resize(input.size.cols, input.size.rows)
|
||||
yield* bus.publish(Pty.Event.Updated, { info: session.info })
|
||||
return session.info
|
||||
})
|
||||
|
||||
const write = Effect.fn("Pty.write")(function* (id: PtyID, data: string) {
|
||||
const session = yield* requireSession(id)
|
||||
if (session.info.status === "running") session.process.write(data)
|
||||
})
|
||||
|
||||
const attach = Effect.fn("Pty.attach")(function* (id: PtyID, input: AttachInput) {
|
||||
const session = yield* requireSession(id)
|
||||
if (session.info.status !== "running") return yield* new ExitedError({ ptyID: id })
|
||||
yield* Effect.logInfo("client attached to session", { id, directory: location.directory })
|
||||
const token = {}
|
||||
const subscriber: Subscriber = {
|
||||
onData: input.onData,
|
||||
onEnd: input.onEnd,
|
||||
active: false,
|
||||
detached: false,
|
||||
pending: [],
|
||||
}
|
||||
session.subscribers.set(token, subscriber)
|
||||
const start = session.bufferCursor
|
||||
const end = session.cursor
|
||||
const from =
|
||||
input.cursor === -1
|
||||
? end
|
||||
: typeof input.cursor === "number" && Number.isSafeInteger(input.cursor)
|
||||
? Math.max(0, input.cursor)
|
||||
: 0
|
||||
const replay = (() => {
|
||||
if (!session.buffer || from >= end) return ""
|
||||
const offset = Math.max(0, from - start)
|
||||
if (offset >= session.buffer.length) return ""
|
||||
return session.buffer.slice(offset)
|
||||
})()
|
||||
return {
|
||||
replay,
|
||||
cursor: end,
|
||||
write: (data: string) => {
|
||||
if (session.info.status === "running") session.process.write(data)
|
||||
},
|
||||
activate: () => {
|
||||
if (subscriber.active || subscriber.detached) return
|
||||
subscriber.active = true
|
||||
try {
|
||||
for (const chunk of subscriber.pending) subscriber.onData(chunk)
|
||||
subscriber.pending.length = 0
|
||||
if (subscriber.end) subscriber.onEnd(subscriber.end)
|
||||
} catch {
|
||||
session.subscribers.delete(token)
|
||||
}
|
||||
},
|
||||
detach: () => {
|
||||
subscriber.detached = true
|
||||
subscriber.pending.length = 0
|
||||
subscriber.end = undefined
|
||||
session.subscribers.delete(token)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({ list, get, create, update, remove, write, attach })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer(),
|
||||
layer,
|
||||
deps: [Bus.node, Location.node, ShellSelect.node],
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as PtyTicket from "./ticket.js"
|
||||
|
||||
import { Workspace } from "../workspace.js"
|
||||
import type { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { PtyTicket } from "@opencode-ai/schema/pty-ticket"
|
||||
import { PtyID } from "./schema.js"
|
||||
import { Cache, Context, Duration, Effect, Layer } from "effect"
|
||||
|
||||
@@ -26,7 +26,6 @@ export type Info = Reference.Info
|
||||
|
||||
type Data = {
|
||||
sources: Map<string, Types.DeepMutable<Source>>
|
||||
materialized: Map<string, Info>
|
||||
}
|
||||
|
||||
type Draft = {
|
||||
@@ -48,71 +47,61 @@ const layer = Layer.effect(
|
||||
const bus = yield* Bus.Service
|
||||
const cache = yield* RepositoryCache.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const state: State.Interface<Data, Draft> = State.create<Data, Draft>({
|
||||
const materialized = new Map<string, Info>()
|
||||
const state = State.create<Data, Draft>({
|
||||
name: "reference",
|
||||
initial: () => ({ sources: new Map(), materialized: new Map() }),
|
||||
initial: () => ({ sources: new Map() }),
|
||||
draft: (draft) => ({
|
||||
add: (name, source) => draft.sources.set(name, source as Types.DeepMutable<Source>),
|
||||
remove: (name) => draft.sources.delete(name),
|
||||
list: () => Array.from(draft.sources.entries()) as [string, Source][],
|
||||
}),
|
||||
prepare: (data) => {
|
||||
for (const [name, source] of data.sources) {
|
||||
if (source.type === "local") {
|
||||
data.materialized.set(
|
||||
finalize: (draft) =>
|
||||
Effect.gen(function* () {
|
||||
materialized.clear()
|
||||
for (const [name, source] of draft.list()) {
|
||||
if (source.type === "local") {
|
||||
materialized.set(
|
||||
name,
|
||||
Info.make({
|
||||
name,
|
||||
path: source.path,
|
||||
...(source.description === undefined ? {} : { description: source.description }),
|
||||
...(source.hidden === undefined ? {} : { hidden: source.hidden }),
|
||||
source,
|
||||
}),
|
||||
)
|
||||
continue
|
||||
}
|
||||
const repository = Repository.parse(source.repository)
|
||||
if (!repository || !Repository.isRemote(repository)) continue
|
||||
if (source.branch) {
|
||||
try {
|
||||
Repository.validateBranch(source.branch)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
materialized.set(
|
||||
name,
|
||||
Info.make({
|
||||
name,
|
||||
path: source.path,
|
||||
path: AbsolutePath.make(Repository.cachePath(global.repos, repository, source.branch)),
|
||||
...(source.description === undefined ? {} : { description: source.description }),
|
||||
...(source.hidden === undefined ? {} : { hidden: source.hidden }),
|
||||
source,
|
||||
}),
|
||||
)
|
||||
continue
|
||||
}
|
||||
const repository = Repository.parse(source.repository)
|
||||
if (!repository || !Repository.isRemote(repository)) continue
|
||||
if (source.branch) {
|
||||
try {
|
||||
Repository.validateBranch(source.branch)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
data.materialized.set(
|
||||
name,
|
||||
Info.make({
|
||||
name,
|
||||
path: AbsolutePath.make(Repository.cachePath(global.repos, repository, source.branch)),
|
||||
...(source.description === undefined ? {} : { description: source.description }),
|
||||
...(source.hidden === undefined ? {} : { hidden: source.hidden }),
|
||||
source,
|
||||
}),
|
||||
)
|
||||
}
|
||||
},
|
||||
notify: () =>
|
||||
Effect.gen(function* () {
|
||||
for (const info of state.get().materialized.values()) {
|
||||
const source = info.source
|
||||
if (source.type !== "git") continue
|
||||
yield* cache
|
||||
.ensure({
|
||||
reference: Repository.parseRemote(source.repository),
|
||||
branch: source.branch,
|
||||
refresh: true,
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to materialize reference", {
|
||||
name: info.name,
|
||||
repository: source.repository,
|
||||
cause,
|
||||
}),
|
||||
),
|
||||
Effect.forkIn(scope),
|
||||
)
|
||||
yield* cache.ensure({ reference: repository, branch: source.branch, refresh: true }).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to materialize reference", {
|
||||
name,
|
||||
repository: source.repository,
|
||||
cause,
|
||||
}),
|
||||
),
|
||||
Effect.forkIn(scope),
|
||||
)
|
||||
}
|
||||
yield* bus.publish(Reference.Event.Updated, {})
|
||||
}),
|
||||
@@ -122,7 +111,7 @@ const layer = Layer.effect(
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
list: Effect.fn("Reference.list")(function* () {
|
||||
return Array.from(state.get().materialized.values())
|
||||
return Array.from(materialized.values())
|
||||
}),
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as Session from "./session.js"
|
||||
export * from "./session/schema.js"
|
||||
|
||||
import { Cause, Effect, Layer, Schema, Context, RcMap, Stream, Scope } from "effect"
|
||||
import { Cause, Effect, Fiber, Layer, Schema, Context, RcMap, Stream, Scope } from "effect"
|
||||
import { ListAnchor } from "@opencode-ai/schema/session"
|
||||
import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm"
|
||||
import { Project } from "./project.js"
|
||||
@@ -50,8 +50,7 @@ import { Job } from "./job.js"
|
||||
import { Command } from "./command.js"
|
||||
import { Shell } from "./shell.js"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex.js"
|
||||
import { ShellResult } from "./shell/result.js"
|
||||
import { fileURLToPath } from "url"
|
||||
import { SessionEnvironment } from "./session/environment.js"
|
||||
import { SessionHistory } from "./session/history.js"
|
||||
@@ -347,8 +346,6 @@ const layer = Layer.effect(
|
||||
const jobs = yield* Job.Service
|
||||
const environments = yield* SessionEnvironment.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const activeShells = new Set<SessionSchema.ID>()
|
||||
const shellLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
|
||||
const closeTransport = Effect.fn("Session.closeTransport")(function* (session: SessionSchema.Info) {
|
||||
const location = Location.Ref.make({
|
||||
directory: session.location.directory,
|
||||
@@ -680,10 +677,7 @@ const layer = Layer.effect(
|
||||
// retried payload, metadata, and delivery mode.
|
||||
if (admitted.type !== "user" || admitted.sessionID !== input.sessionID)
|
||||
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
|
||||
if (input.resume !== false) {
|
||||
if (activeShells.has(admitted.sessionID)) return admitted
|
||||
yield* execution.wake(admitted.sessionID)
|
||||
}
|
||||
if (input.resume !== false) yield* execution.wake(admitted.sessionID)
|
||||
return admitted
|
||||
}),
|
||||
),
|
||||
@@ -717,60 +711,62 @@ const layer = Layer.effect(
|
||||
}),
|
||||
shell: Effect.fn("Session.shell")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
yield* shellLocks.withLock(input.sessionID)(
|
||||
Effect.gen(function* () {
|
||||
activeShells.add(input.sessionID)
|
||||
yield* execution.awaitIdle(input.sessionID)
|
||||
const started = yield* Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.flush
|
||||
const shell = yield* Shell.Service
|
||||
return yield* shell
|
||||
.create({
|
||||
command: input.command,
|
||||
cwd: session.location.directory,
|
||||
timeout: 0,
|
||||
metadata: { sessionID: input.sessionID },
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
}).pipe(Effect.provide(locations.get(session.location)))
|
||||
yield* bus.publish(
|
||||
SessionEvent.Shell.Started,
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
shell: started,
|
||||
},
|
||||
{ id: input.id },
|
||||
)
|
||||
const completed = yield* Effect.gen(function* () {
|
||||
const shell = yield* Shell.Service
|
||||
const terminal = yield* shell.wait(started.id).pipe(
|
||||
Effect.map((info) => ({ info, retained: true as const })),
|
||||
Effect.catchTag("Shell.NotFoundError", () =>
|
||||
Effect.succeed({ info: synthesizeTerminalShellInfo(started), retained: false as const }),
|
||||
),
|
||||
)
|
||||
const output = terminal.retained
|
||||
? yield* shell
|
||||
.output(started.id, { limit: SHELL_MAX_CAPTURE_BYTES })
|
||||
.pipe(Effect.catchTag("Shell.NotFoundError", () => Effect.succeed(missingShellOutput())))
|
||||
: missingShellOutput()
|
||||
return { shell: terminal.info, output }
|
||||
}).pipe(Effect.provide(locations.get(session.location)))
|
||||
yield* bus.publish(SessionEvent.Shell.Ended, {
|
||||
sessionID: input.sessionID,
|
||||
shell: completed.shell,
|
||||
output: completed.output,
|
||||
// The server owns completion recording even if the submitting client disconnects.
|
||||
const running = yield* Effect.gen(function* () {
|
||||
// Resolve shell services here without pinning Session events to this Location after a move.
|
||||
const shell = yield* Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.flush
|
||||
return yield* Shell.Service
|
||||
}).pipe(Effect.provide(locations.get(session.location)))
|
||||
const started = yield* shell
|
||||
.create({
|
||||
command: input.command,
|
||||
cwd: session.location.directory,
|
||||
timeout: 0,
|
||||
metadata: { sessionID: input.sessionID, background: true },
|
||||
})
|
||||
}).pipe(
|
||||
Effect.ensuring(
|
||||
Effect.gen(function* () {
|
||||
activeShells.delete(input.sessionID)
|
||||
yield* execution.wake(input.sessionID)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.pipe(
|
||||
Effect.tapError((error) =>
|
||||
result.synthetic({
|
||||
sessionID: input.sessionID,
|
||||
text: `User shell command failed to start:\n${input.command}\n\n${error.message}`,
|
||||
description: input.command,
|
||||
metadata: { source: "shell", state: "error" },
|
||||
resume: false,
|
||||
}),
|
||||
),
|
||||
Effect.orDie,
|
||||
)
|
||||
yield* bus.publish(
|
||||
SessionEvent.Shell.Started,
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
shell: started,
|
||||
},
|
||||
{ id: input.id },
|
||||
)
|
||||
const terminal = yield* shell.result(started)
|
||||
const preview = yield* shell
|
||||
.output(started.id, { limit: SHELL_MAX_CAPTURE_BYTES })
|
||||
.pipe(Effect.catchTag("Shell.NotFoundError", () => Effect.succeed(ShellResult.unavailable)))
|
||||
yield* bus.publish(SessionEvent.Shell.Ended, {
|
||||
sessionID: input.sessionID,
|
||||
shell: terminal.info,
|
||||
output: preview,
|
||||
})
|
||||
yield* result
|
||||
.synthetic({
|
||||
...ShellResult.userNotification(terminal),
|
||||
sessionID: input.sessionID,
|
||||
resume: false,
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchTag("Session.NotFoundError", () => Effect.void),
|
||||
Effect.orDie,
|
||||
)
|
||||
}).pipe(Effect.forkIn(scope, { startImmediately: true }))
|
||||
yield* Fiber.join(running)
|
||||
}),
|
||||
skill: Effect.fn("Session.skill")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
@@ -994,26 +990,6 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
function missingShellOutput() {
|
||||
const output = "Shell command output is no longer available."
|
||||
return {
|
||||
output,
|
||||
cursor: Buffer.byteLength(output),
|
||||
size: Buffer.byteLength(output),
|
||||
truncated: false,
|
||||
}
|
||||
}
|
||||
|
||||
function synthesizeTerminalShellInfo(started: ShellSchema.Info): ShellSchema.Info {
|
||||
return {
|
||||
...started,
|
||||
// The Shell record was removed before waiters could observe it; publish a terminal
|
||||
// boundary instead of leaving the Session shell message permanently running.
|
||||
status: "killed",
|
||||
time: { ...started.time, completed: Date.now() },
|
||||
}
|
||||
}
|
||||
|
||||
const preparePrompt = Effect.fn("Session.preparePrompt")(function* (
|
||||
request: Parameters<Interface["prompt"]>[0],
|
||||
messageID: SessionMessage.ID,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * as SessionCompaction from "./compaction.js"
|
||||
|
||||
import { LLMClient, LLMEvent, Message } from "@opencode-ai/ai"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
import { Bus } from "../bus.js"
|
||||
@@ -15,7 +16,6 @@ import { SessionSchema } from "./schema.js"
|
||||
import { toSessionError } from "./to-session-error.js"
|
||||
import { Token } from "../util/token.js"
|
||||
import { SessionUsage } from "./usage.js"
|
||||
import { Agent } from "../agent.js"
|
||||
import { State } from "../state.js"
|
||||
|
||||
const DEFAULT_BUFFER = 20_000
|
||||
@@ -164,7 +164,9 @@ const serialize = (message: SessionMessage.Info) => {
|
||||
if (message.type === "synthetic") return `[Synthetic context]: ${message.text}`
|
||||
if (message.type === "skill") return `[Skill activated: ${message.name}]\n${message.text}`
|
||||
if (message.type === "shell")
|
||||
return `[Shell]: ${message.command}\n${truncateToolOutput(message.output?.output ?? "")}`
|
||||
return message.metadata?.background === true
|
||||
? ""
|
||||
: `[Shell]: ${message.command}\n${truncateToolOutput(message.output?.output ?? "")}`
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * as SessionContext from "./context.js"
|
||||
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Agent } from "../agent.js"
|
||||
import { Catalog } from "../catalog.js"
|
||||
@@ -12,7 +13,6 @@ import { InstructionBuiltIns } from "../instructions/builtins.js"
|
||||
import { Location } from "../location.js"
|
||||
import { McpInstructions } from "../mcp/instructions.js"
|
||||
import { McpTool } from "../tool/mcp.js"
|
||||
import { Model } from "../model.js"
|
||||
import { PluginSupervisor } from "../plugin/supervisor.js"
|
||||
import { ReferenceInstructions } from "../reference/instructions.js"
|
||||
import { SkillInstructions } from "../skill/instructions.js"
|
||||
|
||||
@@ -9,6 +9,8 @@ import { SessionEvent } from "../event.js"
|
||||
import { SessionExecution } from "../execution.js"
|
||||
import { SessionSchema } from "../schema.js"
|
||||
import { SessionStore } from "../store.js"
|
||||
import { ShellResult } from "../../shell/result.js"
|
||||
import { SubagentCompletion } from "../subagent-completion.js"
|
||||
|
||||
const CONTINUE_AFTER_SERVER_RESTART =
|
||||
"The server restarted while you were working. Continue from where you left off without repeating completed work."
|
||||
@@ -115,13 +117,13 @@ export const layer = (options?: Options) =>
|
||||
id: background.notificationID,
|
||||
sessionID: recovery.sessionID,
|
||||
description: recovery.command,
|
||||
text: `<shell id="${background.id}" state="${state}" command="${recovery.command}">\n${text}\n</shell>`,
|
||||
metadata: {
|
||||
source: "shell",
|
||||
...ShellResult.notification({
|
||||
jobID: background.id,
|
||||
shellID: recovery.shellID,
|
||||
command: recovery.command,
|
||||
state,
|
||||
},
|
||||
text,
|
||||
}),
|
||||
...(suspended.has(recovery.sessionID) ? { resume: false } : {}),
|
||||
})
|
||||
.pipe(
|
||||
@@ -143,29 +145,12 @@ export const layer = (options?: Options) =>
|
||||
}
|
||||
|
||||
const notify = Effect.fnUntraced(function* (result: Pick<Job.Background, "status" | "output" | "error">) {
|
||||
if (result.status === "running") return
|
||||
const text =
|
||||
result.status === "completed"
|
||||
? (result.output ?? "Subagent completed without a text response.")
|
||||
: result.status === "error"
|
||||
? (result.error ?? "Subagent failed")
|
||||
: "Subagent cancelled"
|
||||
yield* sessions
|
||||
.synthetic({
|
||||
id: background.notificationID,
|
||||
sessionID: recovery.parentSessionID,
|
||||
...(suspended.has(recovery.parentSessionID) ? { resume: false } : {}),
|
||||
description: recovery.description,
|
||||
text: `<subagent sessionID="${recovery.childSessionID}" state="${result.status}" description="${recovery.description}">\n${text}\n</subagent>`,
|
||||
metadata: {
|
||||
source: "subagent",
|
||||
childID: recovery.childSessionID,
|
||||
agent: recovery.agent,
|
||||
state: result.status,
|
||||
},
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
yield* jobs.completeBackground(background.notificationID)
|
||||
yield* SubagentCompletion.deliver(sessions, jobs, {
|
||||
...result,
|
||||
recovery,
|
||||
notificationID: background.notificationID,
|
||||
resume: suspended.has(recovery.parentSessionID) ? false : undefined,
|
||||
}).pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
if (background.status !== "running") {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as SessionInstructions from "./instructions.js"
|
||||
|
||||
import { relative } from "path"
|
||||
import { Context, DateTime, Effect, Layer, Option, Ref, Schema } from "effect"
|
||||
import { Context, Effect, Layer, Option, Ref, Schema } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Bus } from "../bus.js"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
|
||||
@@ -169,7 +169,8 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
SessionMessage.Shell.make({
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
type: "shell",
|
||||
metadata: event.metadata,
|
||||
metadata:
|
||||
event.data.shell.metadata.background === true ? { ...event.metadata, background: true } : event.metadata,
|
||||
shellID: event.data.shell.id,
|
||||
command: event.data.shell.command,
|
||||
status: event.data.shell.status,
|
||||
|
||||
@@ -2,12 +2,13 @@ export * as SessionModelRequest from "./model-request.js"
|
||||
|
||||
import { HttpOptions, LanguageModel, LLM, LLMRequest, Message, SystemPart } from "@opencode-ai/ai"
|
||||
import type { StreamOptions } from "@opencode-ai/ai/route"
|
||||
import type { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { Model } from "@opencode-ai/schema/model"
|
||||
import type { Content } from "@opencode-ai/schema/tool"
|
||||
import { Cause, Config, Context, Effect, Layer, Result, Stream } from "effect"
|
||||
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { App } from "../app.js"
|
||||
import { Model } from "../model.js"
|
||||
import { Permission } from "../permission.js"
|
||||
import { PluginHooks } from "../plugin/hooks.js"
|
||||
import { QuestionTool } from "../tool/plugin/question.js"
|
||||
@@ -18,7 +19,6 @@ import { SessionSchema } from "./schema.js"
|
||||
import { SessionSystemPrompt } from "./system-prompt.js"
|
||||
import { toLLMMessages } from "./runner/to-llm-message.js"
|
||||
import type { SessionMessage } from "./message.js"
|
||||
import type { Agent } from "../agent.js"
|
||||
|
||||
const IMAGE_BYTES_TRIGGER = 25 * 1024 * 1024 // 25 MiB
|
||||
const IMAGE_BYTES_TARGET = 15 * 1024 * 1024 // 15 MiB
|
||||
|
||||
@@ -2,10 +2,10 @@ export * as SessionRunnerModel from "./model.js"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { LanguageModel } from "@opencode-ai/ai"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { ModelResolver } from "../../model-resolver.js"
|
||||
import { Capabilities, ID, Info, Ref, VariantID } from "../../model.js"
|
||||
import { Provider } from "../../provider.js"
|
||||
import { SessionSchema } from "../schema.js"
|
||||
|
||||
export class ModelNotSelectedError extends Schema.TaggedError<ModelNotSelectedError>()(
|
||||
@@ -19,7 +19,7 @@ export class ModelNotSelectedError extends Schema.TaggedError<ModelNotSelectedEr
|
||||
|
||||
export class ModelUnavailableError extends Schema.TaggedError<ModelUnavailableError>()(
|
||||
"SessionRunnerModel.ModelUnavailableError",
|
||||
{ providerID: Provider.ID, modelID: ID },
|
||||
{ providerID: Provider.ID, modelID: Model.ID },
|
||||
) {
|
||||
override get message() {
|
||||
if (this.providerID === "azure-cognitive-services")
|
||||
@@ -43,7 +43,7 @@ export interface Interface {
|
||||
/** Availability is sampled lazily for each explicitly selected model resolution. */
|
||||
readonly resolve: (
|
||||
session: SessionSchema.Info,
|
||||
available: () => Effect.Effect<ReadonlyArray<Info>>,
|
||||
available: () => Effect.Effect<ReadonlyArray<Model.Info>>,
|
||||
) => Effect.Effect<Resolved, Error>
|
||||
}
|
||||
|
||||
@@ -53,15 +53,15 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Se
|
||||
export const resolved = (
|
||||
model: LanguageModel,
|
||||
options: {
|
||||
readonly capabilities: Capabilities
|
||||
readonly variant?: VariantID
|
||||
readonly cost: Info["cost"]
|
||||
readonly limit: Info["limit"]
|
||||
readonly capabilities: Model.Capabilities
|
||||
readonly variant?: Model.VariantID
|
||||
readonly cost: Model.Info["cost"]
|
||||
readonly limit: Model.Info["limit"]
|
||||
},
|
||||
): Resolved => ({
|
||||
model,
|
||||
ref: Ref.make({
|
||||
id: ID.make(model.id),
|
||||
ref: Model.Ref.make({
|
||||
id: Model.ID.make(model.id),
|
||||
providerID: Provider.ID.make(model.provider),
|
||||
...(options.variant === undefined ? {} : { variant: options.variant }),
|
||||
}),
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { type LLMEvent, type ProviderMetadata, type ToolResultValue } from "@opencode-ai/ai"
|
||||
import type { Agent } from "@opencode-ai/schema/agent"
|
||||
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 { Clock, Effect, Iterable } from "effect"
|
||||
import { isArrayNonEmpty, isReadonlyArrayNonEmpty } from "effect/Array"
|
||||
import { Bus } from "../../bus.js"
|
||||
import { Model } from "../../model.js"
|
||||
import { SessionEvent } from "../event.js"
|
||||
import { SessionMessage } from "../message.js"
|
||||
import { SessionSchema } from "../schema.js"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Agent } from "../../agent.js"
|
||||
import { Snapshot } from "../../snapshot.js"
|
||||
import { RelativePath } from "../../schema.js"
|
||||
import { SessionUsage } from "../usage.js"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
|
||||
@@ -67,7 +67,9 @@ const hostedContent = (result: ToolResultValue): NonEmptyContent => {
|
||||
* concurrently without a lock. Two rules keep that safe, and every method must preserve
|
||||
* them. (1) Commit state marks synchronously before the first await: never a yield
|
||||
* between a check (`tool.settled`, `stepStarted`, ...) and its mark, so check-and-mark
|
||||
* stays atomic under cooperative scheduling. (2) Never require a cross-source event
|
||||
* stays atomic under cooperative scheduling. Provider-event publication remains
|
||||
* uninterruptible through its writes so cancellation cannot strand a mark without
|
||||
* its durable event. (2) Never require a cross-source event
|
||||
* order: each publishing fiber is sequential, so per-source order holds by construction,
|
||||
* and consumers fold by id/ordinal rather than global position.
|
||||
*/
|
||||
@@ -528,7 +530,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
yield* failAssistant({ type: "provider.unknown", message: event.message })
|
||||
return
|
||||
}
|
||||
})
|
||||
}, Effect.uninterruptible)
|
||||
|
||||
const publishTraced = Effect.fn("SessionRunner.publishLLMEvent")(publish)
|
||||
|
||||
|
||||
@@ -9,9 +9,9 @@ import {
|
||||
type ProviderErrorEvent,
|
||||
type ToolCall,
|
||||
} from "@opencode-ai/ai"
|
||||
import type { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Cause, Data, Effect, Exit, Fiber, Option, Stream } from "effect"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Agent } from "../../agent.js"
|
||||
import { Bus } from "../../bus.js"
|
||||
import { Permission } from "../../permission.js"
|
||||
import { Snapshot } from "../../snapshot.js"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Message, ToolCallPart, ToolResultPart, type ContentPart, type ProviderMetadata } from "@opencode-ai/ai"
|
||||
import type { Model } from "@opencode-ai/schema/model"
|
||||
import { Option, Schema } from "effect"
|
||||
import { fileURLToPath } from "url"
|
||||
import type { Model } from "../../model.js"
|
||||
import { SessionMessage } from "../message.js"
|
||||
import type { FileAttachment } from "@opencode-ai/schema/prompt"
|
||||
|
||||
@@ -260,6 +260,8 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
|
||||
case "system":
|
||||
return [Message.system(message.text)]
|
||||
case "shell":
|
||||
// Background shell results enter context once, through their completion inbox item.
|
||||
if (message.metadata?.background === true) return []
|
||||
return [
|
||||
Message.make({
|
||||
id: message.id,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
export * as SubagentCompletion from "./subagent-completion.js"
|
||||
|
||||
import { Effect } from "effect"
|
||||
import type { Job } from "../job.js"
|
||||
import type { Session } from "../session.js"
|
||||
|
||||
export const deliver = Effect.fnUntraced(function* (
|
||||
sessions: Pick<Session.Interface, "synthetic">,
|
||||
jobs: Pick<Job.Interface, "completeBackground">,
|
||||
input: Pick<Job.Info, "status" | "output" | "error" | "notificationID"> & {
|
||||
recovery: Extract<Job.Recovery, { kind: "subagent" }>
|
||||
resume?: boolean
|
||||
},
|
||||
) {
|
||||
if (input.status === "running") return
|
||||
const recovery = input.recovery
|
||||
const text =
|
||||
input.status === "completed"
|
||||
? (input.output ?? "Subagent completed without a text response.")
|
||||
: input.status === "error"
|
||||
? (input.error ?? "Subagent failed")
|
||||
: "Subagent cancelled"
|
||||
yield* sessions.synthetic({
|
||||
...(input.notificationID ? { id: input.notificationID } : {}),
|
||||
sessionID: recovery.parentSessionID,
|
||||
...(input.resume === false ? { resume: false } : {}),
|
||||
description: recovery.description,
|
||||
text: `<subagent sessionID="${recovery.childSessionID}" state="${input.status}" description="${recovery.description}">\n${text}\n</subagent>`,
|
||||
metadata: { source: "subagent", childID: recovery.childSessionID, agent: recovery.agent, state: input.status },
|
||||
})
|
||||
if (input.notificationID) yield* jobs.completeBackground(input.notificationID)
|
||||
})
|
||||
@@ -2,8 +2,8 @@ export * as SessionTitle from "./title.js"
|
||||
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import { LLMClient, LLMEvent, Message, SystemPart } from "@opencode-ai/ai"
|
||||
import type { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Context, DateTime, Effect, Layer, Stream } from "effect"
|
||||
import type { Agent } from "../agent.js"
|
||||
import { Database } from "../database/database.js"
|
||||
import { Bus } from "../bus.js"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
|
||||
@@ -18,6 +18,9 @@ import type { ShellCreateBefore } from "@opencode-ai/plugin/effect/shell"
|
||||
import { PluginHooks } from "./plugin/hooks.js"
|
||||
import { SessionEnvironment } from "./session/environment.js"
|
||||
import { SessionSchema } from "./session/schema.js"
|
||||
import { Config } from "./config.js"
|
||||
import { ToolOutput } from "./tool-output.js"
|
||||
import { ShellResult } from "./shell/result.js"
|
||||
|
||||
export class NotFoundError extends Schema.TaggedError<NotFoundError>()("Shell.NotFoundError", {
|
||||
id: Shell.ID,
|
||||
@@ -65,6 +68,8 @@ export interface Interface {
|
||||
// Resolves once the command reaches a terminal status, returning its final Info. Fails with
|
||||
// NotFoundError if the command is unknown or is removed before it terminates.
|
||||
readonly wait: (id: Shell.ID) => Effect.Effect<Shell.Info, NotFoundError>
|
||||
// A known shell's terminal state and bounded tail. Missing capture remains distinct from its exit status.
|
||||
readonly result: (started: Shell.Info) => Effect.Effect<ShellResult.Result>
|
||||
// Replaces the running command's timeout from now; zero clears it.
|
||||
readonly timeout: (id: Shell.ID, duration: number) => Effect.Effect<Shell.Info, NotFoundError>
|
||||
readonly output: (id: Shell.ID, input?: Shell.OutputInput) => Effect.Effect<Shell.Output, NotFoundError>
|
||||
@@ -120,6 +125,7 @@ const layer = () =>
|
||||
const environment = yield* Environment.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const environments = yield* SessionEnvironment.Service
|
||||
const config = yield* Config.Service
|
||||
const context = yield* Effect.context()
|
||||
const runFork = Effect.runForkWith(context)
|
||||
const commands = new Map<Shell.ID, Active>()
|
||||
@@ -217,6 +223,28 @@ const layer = () =>
|
||||
}
|
||||
})
|
||||
|
||||
const result = Effect.fn("Shell.result")(function* (started: Shell.Info) {
|
||||
const info = yield* wait(started.id).pipe(
|
||||
Effect.catchTag("Shell.NotFoundError", () =>
|
||||
Effect.succeed({ ...started, status: "killed" as const, time: { ...started.time, completed: Date.now() } }),
|
||||
),
|
||||
)
|
||||
const capture = yield* Effect.gen(function* () {
|
||||
const limits = Config.latest(yield* config.entries(), "tool_output")
|
||||
const maxLines = limits?.max_lines ?? ToolOutput.MAX_LINES
|
||||
const maxBytes = limits?.max_bytes ?? ToolOutput.MAX_BYTES
|
||||
const latest = yield* output(info.id, { cursor: Number.MAX_SAFE_INTEGER })
|
||||
const page = yield* output(info.id, { cursor: Math.max(0, latest.size - maxBytes), limit: maxBytes })
|
||||
const lines = page.output.split("\n")
|
||||
if (page.output.endsWith("\n")) lines.pop()
|
||||
const truncated = latest.size > maxBytes || lines.length > maxLines
|
||||
const text = lines.length > maxLines ? lines.slice(-maxLines).join("\n") : page.output
|
||||
const notice = truncated ? `\n\n[output truncated; full output saved to: ${info.file}]` : ""
|
||||
return { output: `${text || "(no output)"}${notice}`, truncated }
|
||||
}).pipe(Effect.catchTag("Shell.NotFoundError", () => Effect.succeed(undefined)))
|
||||
return { info, capture }
|
||||
})
|
||||
|
||||
const create = Effect.fn("Shell.create")(function* <E = never, R = never>(
|
||||
input: CreateInput,
|
||||
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
|
||||
@@ -382,7 +410,7 @@ const layer = () =>
|
||||
return command.info
|
||||
})
|
||||
|
||||
return Service.of({ create, list, get, wait, timeout, output, remove })
|
||||
return Service.of({ create, list, get, wait, result, timeout, output, remove })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -397,6 +425,7 @@ export const node = makeLocationNode({
|
||||
Environment.node,
|
||||
PluginHooks.node,
|
||||
SessionEnvironment.node,
|
||||
Config.node,
|
||||
cleanupNode,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
export * as ShellResult from "./result.js"
|
||||
|
||||
import type { Shell } from "@opencode-ai/schema/shell"
|
||||
|
||||
export type Result = {
|
||||
info: Shell.Info
|
||||
capture: { output: string; truncated: boolean } | undefined
|
||||
}
|
||||
|
||||
type Output = { output: string; truncated: boolean; exit?: number; timeout?: boolean }
|
||||
|
||||
const missing = "Shell command output is no longer available."
|
||||
export const unavailable: Shell.Output = {
|
||||
output: missing,
|
||||
cursor: Buffer.byteLength(missing),
|
||||
size: Buffer.byteLength(missing),
|
||||
truncated: false,
|
||||
}
|
||||
|
||||
export function output(result: Result): Output {
|
||||
return {
|
||||
output: result.capture?.output ?? unavailable.output,
|
||||
truncated: result.capture?.truncated ?? false,
|
||||
...(result.info.exit !== undefined ? { exit: result.info.exit } : {}),
|
||||
...(result.info.status === "timeout" ? { timeout: true } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function notice(output: Pick<Output, "exit" | "timeout">) {
|
||||
if (output.timeout) return "Command timed out before completion."
|
||||
if (output.exit !== undefined) return `Command exited with code ${output.exit}.`
|
||||
}
|
||||
|
||||
export function metadata(output: Output) {
|
||||
return {
|
||||
truncated: output.truncated,
|
||||
...(output.exit !== undefined ? { exit: output.exit } : {}),
|
||||
...(output.timeout !== undefined ? { timeout: output.timeout } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function notification(input: {
|
||||
shellID: string
|
||||
jobID?: string
|
||||
command: string
|
||||
state: "completed" | "cancelled" | "error"
|
||||
text: string
|
||||
output?: Output
|
||||
}) {
|
||||
return {
|
||||
text: `<shell id="${input.jobID ?? input.shellID}" state="${input.state}" command="${input.command}">\n${input.text}\n</shell>`,
|
||||
metadata: {
|
||||
source: "shell",
|
||||
shellID: input.shellID,
|
||||
...(input.jobID !== undefined ? { jobID: input.jobID } : {}),
|
||||
state: input.state,
|
||||
...(input.output ? metadata(input.output) : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function userNotification(result: Result) {
|
||||
const captured = output(result)
|
||||
const status =
|
||||
result.info.status === "killed" ? "Command cancelled." : (notice(captured) ?? "Command exited with code unknown.")
|
||||
const message = notification({
|
||||
shellID: result.info.id,
|
||||
command: result.info.command,
|
||||
state: result.info.status === "killed" ? "cancelled" : "completed",
|
||||
text: `${captured.output}\n\n${status}`,
|
||||
output: captured,
|
||||
})
|
||||
return { ...message, text: `The following shell command was executed by the user:\n${message.text}` }
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import type { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer, Types } from "effect"
|
||||
import type { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import { Agent } from "./agent.js"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Permission } from "./permission.js"
|
||||
import { State } from "./state.js"
|
||||
@@ -109,7 +109,7 @@ const layer = Layer.effect(
|
||||
draft.skills.delete(ID.make(id))
|
||||
},
|
||||
}),
|
||||
notify: () => bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
finalize: () => bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
|
||||
+95
-89
@@ -1,9 +1,9 @@
|
||||
export * as State from "./state.js"
|
||||
|
||||
import { Clock, Context, Deferred, Effect, Exit, Scope } from "effect"
|
||||
import { Clock, Context, Deferred, Effect, Scope, Semaphore } from "effect"
|
||||
|
||||
/**
|
||||
* A replayable transform applied to a draft while deriving state.
|
||||
* A replayable transform applied to a draft during reload.
|
||||
*
|
||||
* Domain drafts expose readable and writable state while preserving concise
|
||||
* plugin/config code. Transforms synchronously rebuild derived state.
|
||||
@@ -16,14 +16,13 @@ export interface Registration {
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a scoped transform and invalidates the derived state. Closing the
|
||||
* owning Scope removes the transform. Reads synchronously replay pending changes.
|
||||
* Registers and applies a scoped transform. Closing the owning Scope removes
|
||||
* the transform and reloads the materialized state.
|
||||
*/
|
||||
export type Transform<DraftApi> = (
|
||||
transform: TransformCallback<DraftApi>,
|
||||
) => Effect.Effect<Registration, never, Scope.Scope>
|
||||
|
||||
/** Invalidates the snapshot after captured inputs change and coalesces notifications. */
|
||||
export type Reload = () => Effect.Effect<void>
|
||||
|
||||
export interface Transformable<DraftApi> {
|
||||
@@ -34,7 +33,7 @@ export interface Transformable<DraftApi> {
|
||||
type Batch = {
|
||||
active: boolean
|
||||
readonly flush: boolean
|
||||
readonly notifications: Set<Reload>
|
||||
readonly reloads: Set<Reload>
|
||||
}
|
||||
|
||||
const CurrentBatch = Context.Reference<Batch | undefined>("@opencode/State/CurrentBatch", {
|
||||
@@ -42,24 +41,17 @@ const CurrentBatch = Context.Reference<Batch | undefined>("@opencode/State/Curre
|
||||
})
|
||||
const reloadDebounce = 500
|
||||
|
||||
/** Batches notifications, not read visibility. flush: false is terminal teardown. */
|
||||
/** flush: false is terminal teardown: states whose transforms are removed stop rebuilding, including pending reloads. */
|
||||
export function batch<A, E, R>(effect: Effect.Effect<A, E, R>, options: { readonly flush?: boolean } = {}) {
|
||||
return Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
const current = yield* CurrentBatch
|
||||
if (current?.active && options.flush !== false) return yield* restore(effect)
|
||||
const batch: Batch = { active: true, flush: options.flush !== false, notifications: new Set() }
|
||||
const exit = yield* restore(effect.pipe(Effect.provideService(CurrentBatch, batch))).pipe(Effect.exit)
|
||||
batch.active = false
|
||||
const notifications = batch.flush
|
||||
? yield* Effect.forEach(batch.notifications, (notify) => restore(notify()).pipe(Effect.exit))
|
||||
: []
|
||||
// Accepted writes are not rolled back: one failed observer must not hide
|
||||
// the other states' changes, or replace the batch body's failure.
|
||||
yield* Exit.asVoidAll([exit, ...notifications])
|
||||
return yield* exit
|
||||
}),
|
||||
)
|
||||
return Effect.gen(function* () {
|
||||
const current = yield* CurrentBatch
|
||||
if (current?.active && options.flush !== false) return yield* effect
|
||||
const batch: Batch = { active: true, flush: options.flush !== false, reloads: new Set() }
|
||||
const exit = yield* effect.pipe(Effect.provideService(CurrentBatch, batch), Effect.exit)
|
||||
batch.active = false
|
||||
if (batch.flush) yield* Effect.forEach(batch.reloads, (reload) => reload(), { discard: true })
|
||||
return yield* exit
|
||||
})
|
||||
}
|
||||
|
||||
export const inherit = Effect.fnUntraced(function* () {
|
||||
@@ -73,110 +65,124 @@ export interface Options<State, DraftApi> {
|
||||
readonly initial: () => State
|
||||
/** Wraps mutable state in a domain-specific draft API. */
|
||||
readonly draft: MakeDraft<State, DraftApi>
|
||||
/** Synchronously completes derived data after ordered transform replay. */
|
||||
readonly prepare?: (state: State) => void
|
||||
/**
|
||||
* Observes accepted changes outside the read path. Batched writes notify at
|
||||
* batch completion; reloads debounce notifications. Reads never run this hook.
|
||||
* Resource reconciliation owns its execution scope and coordination.
|
||||
* Runs after the rebuilt state becomes visible. Update events published here
|
||||
* act as read barriers: subscribers refetching on the event observe the
|
||||
* committed state.
|
||||
*/
|
||||
readonly notify?: () => Effect.Effect<void>
|
||||
readonly finalize?: (draft: DraftApi) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export interface Interface<State, DraftApi> extends Transformable<DraftApi> {
|
||||
/** Returns the latest accepted state, replaying stale inputs synchronously. */
|
||||
readonly get: () => State
|
||||
}
|
||||
|
||||
export function create<State, DraftApi>(options: Options<State, DraftApi>): Interface<State, DraftApi> {
|
||||
let state = options.initial()
|
||||
const transforms = new Set<{ run: TransformCallback<DraftApi> }>()
|
||||
let dirty = false
|
||||
let transforms: { run: TransformCallback<DraftApi> }[] = []
|
||||
let generation = 0
|
||||
let requestedAt = 0
|
||||
let running = false
|
||||
let closed = false
|
||||
let pending: Deferred.Deferred<void> | undefined
|
||||
let waiters: { generation: number; done: Deferred.Deferred<void> }[] = []
|
||||
const semaphore = Semaphore.makeUnsafe(1)
|
||||
|
||||
const get = () => {
|
||||
if (!dirty || closed) return state
|
||||
const next = options.initial()
|
||||
const api = options.draft(next)
|
||||
transforms.forEach((transform) => transform.run(api))
|
||||
options.prepare?.(next)
|
||||
const commit = Effect.fn("State.commit")(function* (next: State) {
|
||||
state = next
|
||||
dirty = false
|
||||
return state
|
||||
}
|
||||
|
||||
const notify = Effect.fn("State.notify")(function* () {
|
||||
if (closed) return
|
||||
get()
|
||||
if (options.notify) yield* options.notify()
|
||||
if (options.finalize) yield* options.finalize(options.draft(next))
|
||||
})
|
||||
|
||||
const publish = (done: Deferred.Deferred<void>): Effect.Effect<void> =>
|
||||
const materialize = Effect.fnUntraced(function* () {
|
||||
if (closed) return
|
||||
const next = options.initial()
|
||||
const api = options.draft(next)
|
||||
for (const transform of transforms) {
|
||||
yield* Effect.sync(() => {
|
||||
transform.run(api)
|
||||
})
|
||||
}
|
||||
yield* commit(next)
|
||||
})
|
||||
|
||||
const materializeReload = () => semaphore.withPermit(materialize())
|
||||
|
||||
const rebuild = (): Effect.Effect<void> =>
|
||||
Effect.gen(function* () {
|
||||
const clock = yield* Clock.Clock
|
||||
const remaining = requestedAt + reloadDebounce - clock.currentTimeMillisUnsafe()
|
||||
if (remaining > 0) yield* Effect.sleep(remaining)
|
||||
if (clock.currentTimeMillisUnsafe() < requestedAt + reloadDebounce) return yield* publish(done)
|
||||
if (clock.currentTimeMillisUnsafe() < requestedAt + reloadDebounce) return yield* rebuild()
|
||||
|
||||
// Release scheduling ownership before observers run: an observer may
|
||||
// request and await another reload without joining this notification.
|
||||
pending = undefined
|
||||
yield* notify().pipe(Deferred.into(done))
|
||||
const target = generation
|
||||
const exit = yield* materializeReload().pipe(Effect.exit)
|
||||
const completed = waiters.filter((waiter) => waiter.generation <= target)
|
||||
waiters = waiters.filter((waiter) => waiter.generation > target)
|
||||
yield* Effect.forEach(completed, (waiter) => Deferred.done(waiter.done, exit), {
|
||||
concurrency: "unbounded",
|
||||
discard: true,
|
||||
})
|
||||
if (generation > target) return yield* rebuild()
|
||||
running = false
|
||||
})
|
||||
|
||||
const changed = (debounce: boolean) =>
|
||||
Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
if (closed) return
|
||||
if (debounce) dirty = true
|
||||
const batch = yield* CurrentBatch
|
||||
if (batch?.active) {
|
||||
if (!batch.flush) {
|
||||
closed = true
|
||||
return
|
||||
}
|
||||
batch.notifications.add(notify)
|
||||
return
|
||||
}
|
||||
if (!debounce) return yield* restore(notify())
|
||||
|
||||
const clock = yield* Clock.Clock
|
||||
requestedAt = clock.currentTimeMillisUnsafe()
|
||||
// No yields between choosing the burst's completion and claiming it.
|
||||
const done = pending ?? Deferred.makeUnsafe<void>()
|
||||
if (!pending) {
|
||||
pending = done
|
||||
yield* publish(done).pipe(Effect.forkDetach)
|
||||
}
|
||||
yield* restore(Deferred.await(done))
|
||||
}),
|
||||
)
|
||||
const reload = Effect.fnUntraced(function* () {
|
||||
if (closed) return
|
||||
const done = Deferred.makeUnsafe<void>()
|
||||
const clock = yield* Clock.Clock
|
||||
generation++
|
||||
requestedAt = clock.currentTimeMillisUnsafe()
|
||||
waiters.push({ generation, done })
|
||||
if (!running) {
|
||||
running = true
|
||||
yield* rebuild().pipe(Effect.forkDetach)
|
||||
}
|
||||
yield* Deferred.await(done)
|
||||
})
|
||||
|
||||
return {
|
||||
get,
|
||||
get: () => state,
|
||||
transform: Effect.fn("State.transform")(function* (update) {
|
||||
yield* Effect.annotateCurrentSpan("state", options.name ?? "anonymous")
|
||||
const scope = yield* Scope.Scope
|
||||
return yield* Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const transform = { run: update }
|
||||
let active = true
|
||||
const dispose = Effect.uninterruptible(
|
||||
Effect.suspend(() => {
|
||||
if (!transforms.delete(transform)) return Effect.void
|
||||
dirty = true
|
||||
return changed(false)
|
||||
semaphore.withPermit(
|
||||
Effect.suspend(() => {
|
||||
if (!active) return Effect.void
|
||||
active = false
|
||||
transforms = transforms.filter((item) => item !== transform)
|
||||
return Effect.gen(function* () {
|
||||
const batch = yield* CurrentBatch
|
||||
if (batch?.active) {
|
||||
// Detached debounced reloads must also stay quiet after teardown.
|
||||
if (!batch.flush) {
|
||||
closed = true
|
||||
return
|
||||
}
|
||||
batch.reloads.add(materializeReload)
|
||||
return
|
||||
}
|
||||
yield* materialize()
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* semaphore.withPermit(
|
||||
Effect.sync(() => {
|
||||
transforms = [...transforms, transform]
|
||||
}),
|
||||
)
|
||||
transforms.add(transform)
|
||||
dirty = true
|
||||
yield* Scope.addFinalizer(scope, dispose)
|
||||
yield* changed(false)
|
||||
const batch = yield* CurrentBatch
|
||||
if (batch?.active) batch.reloads.add(materializeReload)
|
||||
else yield* materializeReload()
|
||||
return { dispose }
|
||||
}),
|
||||
)
|
||||
}),
|
||||
reload: () => changed(true),
|
||||
reload,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +185,7 @@ const layer = Layer.effect(
|
||||
draft.tools.delete(id)
|
||||
},
|
||||
}),
|
||||
notify: () =>
|
||||
finalize: () =>
|
||||
Effect.forEach(
|
||||
state.get().errors,
|
||||
({ tool, error }) =>
|
||||
|
||||
@@ -13,7 +13,7 @@ import { SessionSchema } from "../../session/schema.js"
|
||||
import { Shell } from "../../shell.js"
|
||||
import { ShellParse } from "../../shell/parse.js"
|
||||
import { ShellSelect } from "../../shell/select.js"
|
||||
import { ToolOutput } from "../../tool-output.js"
|
||||
import { ShellResult } from "../../shell/result.js"
|
||||
|
||||
export const name = "shell"
|
||||
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
|
||||
@@ -71,11 +71,7 @@ const Output = Schema.Struct({
|
||||
type Output = typeof Output.Type
|
||||
|
||||
const resultMessages = (output: Output) => {
|
||||
const notice = (() => {
|
||||
if (output.status === "running") return BACKGROUND_INSTRUCTION
|
||||
if (output.timeout) return "Command timed out before completion."
|
||||
if (output.exit !== undefined) return `Command exited with code ${output.exit}.`
|
||||
})()
|
||||
const notice = output.status === "running" ? BACKGROUND_INSTRUCTION : ShellResult.notice(output)
|
||||
return [output.output, ...(notice ? [notice] : [])]
|
||||
}
|
||||
|
||||
@@ -85,10 +81,8 @@ const toolResult = (output: Output) => {
|
||||
content: resultMessages(output).map((text) => ({ type: "text" as const, text })),
|
||||
metadata: {
|
||||
status: output.status,
|
||||
truncated: output.truncated,
|
||||
...(output.exit !== undefined ? { exit: output.exit } : {}),
|
||||
...ShellResult.metadata(output),
|
||||
...(output.shellID !== undefined ? { shellID: output.shellID } : {}),
|
||||
...(output.timeout !== undefined ? { timeout: output.timeout } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -132,21 +126,15 @@ export const Plugin = {
|
||||
yield* runtime.session.synthetic({
|
||||
...(info.notificationID ? { id: info.notificationID } : {}),
|
||||
sessionID,
|
||||
text: `<shell id="${id}" state="${info.status}" command="${command}">\n${text}\n</shell>`,
|
||||
description: command,
|
||||
metadata: {
|
||||
source: "shell",
|
||||
...ShellResult.notification({
|
||||
jobID: id,
|
||||
shellID,
|
||||
command,
|
||||
state: info.status,
|
||||
...(output
|
||||
? {
|
||||
truncated: output.truncated,
|
||||
...(output.exit !== undefined ? { exit: output.exit } : {}),
|
||||
...(output.timeout !== undefined ? { timeout: output.timeout } : {}),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
text,
|
||||
output,
|
||||
}),
|
||||
})
|
||||
if (info.notificationID) yield* runtime.job.completeBackground(info.notificationID)
|
||||
},
|
||||
@@ -229,52 +217,19 @@ export const Plugin = {
|
||||
)
|
||||
yield* context.progress({ shellID: info.id })
|
||||
|
||||
const captureShell = Effect.fnUntraced(function* () {
|
||||
const configured = Config.latest(yield* config.entries(), "tool_output")
|
||||
const maxLines = configured?.max_lines ?? ToolOutput.MAX_LINES
|
||||
const maxBytes = configured?.max_bytes ?? ToolOutput.MAX_BYTES
|
||||
const latest = yield* shell.output(info.id, { cursor: Number.MAX_SAFE_INTEGER })
|
||||
const page = yield* shell.output(info.id, {
|
||||
cursor: Math.max(0, latest.size - maxBytes),
|
||||
limit: maxBytes,
|
||||
})
|
||||
const lines = page.output.split("\n")
|
||||
if (page.output.endsWith("\n")) lines.pop()
|
||||
const truncated = latest.size > maxBytes || lines.length > maxLines
|
||||
const output = lines.length > maxLines ? lines.slice(-maxLines).join("\n") : page.output
|
||||
const notice = truncated ? `\n\n[output truncated; full output saved to: ${info.file}]` : ""
|
||||
const settled = yield* Deferred.make<Output>()
|
||||
const run = Effect.gen(function* () {
|
||||
const result = yield* shell.result(info)
|
||||
if (!result.capture) return yield* new Shell.NotFoundError({ id: info.id })
|
||||
const output = ShellResult.output(result)
|
||||
return {
|
||||
output: `${output || "(no output)"}${notice}`,
|
||||
truncated,
|
||||
}
|
||||
})
|
||||
|
||||
const settleShell = Effect.fnUntraced(function* () {
|
||||
const final = yield* shell.wait(info.id)
|
||||
const capture = yield* captureShell()
|
||||
|
||||
// `exit` is optionalKey in the Output schema; a present-but-undefined key
|
||||
// fails output encoding, so omit it when the process has no exit code.
|
||||
if (final.status === "timeout") {
|
||||
return {
|
||||
...(final.exit !== undefined ? { exit: final.exit } : {}),
|
||||
output: `${capture.output}\n\nCommand exceeded timeout of ${finalTimeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
|
||||
truncated: capture.truncated,
|
||||
timeout: true,
|
||||
status: "completed" as const,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...(final.exit !== undefined ? { exit: final.exit } : {}),
|
||||
output: capture.output,
|
||||
truncated: capture.truncated,
|
||||
...output,
|
||||
output: output.timeout
|
||||
? `${output.output}\n\nCommand exceeded timeout of ${finalTimeout} ms. Retry with a larger timeout if the command is expected to take longer.`
|
||||
: output.output,
|
||||
status: "completed" as const,
|
||||
}
|
||||
})
|
||||
|
||||
const settled = yield* Deferred.make<Output>()
|
||||
const run = settleShell().pipe(
|
||||
}).pipe(
|
||||
Effect.tap((output) => Deferred.succeed(settled, output)),
|
||||
Effect.map((output) => resultMessages(output).join("\n\n")),
|
||||
Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)),
|
||||
|
||||
@@ -5,9 +5,11 @@ import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect, Schema, Scope } from "effect"
|
||||
import { Agent } from "../../agent.js"
|
||||
import { Config } from "../../config.js"
|
||||
import type { Job } from "../../job.js"
|
||||
import { PluginRuntime } from "../../plugin/runtime.js"
|
||||
import { Permission } from "../../permission.js"
|
||||
import { SessionSchema } from "../../session/schema.js"
|
||||
import { SubagentCompletion } from "../../session/subagent-completion.js"
|
||||
|
||||
export const name = "subagent"
|
||||
|
||||
@@ -79,32 +81,15 @@ export const Plugin = {
|
||||
})
|
||||
|
||||
const notifyWhenDone = Effect.fn("SubagentTool.notifyWhenDone")(function* (
|
||||
parentID: SessionSchema.ID,
|
||||
childID: SessionSchema.ID,
|
||||
agent: string,
|
||||
description: string,
|
||||
recovery: Extract<Job.Recovery, { kind: "subagent" }>,
|
||||
startedAt: number,
|
||||
) {
|
||||
const key = `${childID}:${startedAt}`
|
||||
const key = `${recovery.childSessionID}:${startedAt}`
|
||||
if (notifications.has(key)) return
|
||||
notifications.add(key)
|
||||
yield* Effect.gen(function* () {
|
||||
const info = (yield* runtime.job.wait({ id: childID })).info
|
||||
if (!info || info.status === "running") return
|
||||
const text =
|
||||
info.status === "completed"
|
||||
? (info.output ?? NO_TEXT)
|
||||
: info.status === "error"
|
||||
? (info.error ?? "Subagent failed")
|
||||
: "Subagent cancelled"
|
||||
yield* runtime.session.synthetic({
|
||||
...(info.notificationID ? { id: info.notificationID } : {}),
|
||||
sessionID: parentID,
|
||||
text: `<subagent sessionID="${childID}" state="${info.status}" description="${description}">\n${text}\n</subagent>`,
|
||||
description,
|
||||
metadata: { source: "subagent", childID, agent, state: info.status },
|
||||
})
|
||||
if (info.notificationID) yield* runtime.job.completeBackground(info.notificationID)
|
||||
const info = (yield* runtime.job.wait({ id: recovery.childSessionID })).info
|
||||
if (info) yield* SubagentCompletion.deliver(runtime.session, runtime.job, { ...info, recovery })
|
||||
}).pipe(
|
||||
Effect.ensuring(Effect.sync(() => notifications.delete(key))),
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
@@ -232,24 +217,25 @@ export const Plugin = {
|
||||
),
|
||||
)
|
||||
|
||||
const recovery = {
|
||||
kind: "subagent" as const,
|
||||
parentSessionID: context.sessionID,
|
||||
childSessionID: child.id,
|
||||
agent: agent.name,
|
||||
description: input.description,
|
||||
}
|
||||
const info = yield* runtime.job.start({
|
||||
id: child.id,
|
||||
type: name,
|
||||
title: input.description,
|
||||
metadata: {},
|
||||
recovery: {
|
||||
kind: "subagent",
|
||||
parentSessionID: context.sessionID,
|
||||
childSessionID: child.id,
|
||||
agent: agent.name,
|
||||
description: input.description,
|
||||
},
|
||||
recovery,
|
||||
run: runtime.session.resume(child.id).pipe(Effect.andThen(latestAssistantText(child.id))),
|
||||
})
|
||||
|
||||
if (background) {
|
||||
yield* runtime.job.background(info.id)
|
||||
yield* notifyWhenDone(context.sessionID, child.id, agent.name, input.description, info.started_at)
|
||||
yield* notifyWhenDone(recovery, info.started_at)
|
||||
return backgroundResult(child.id)
|
||||
}
|
||||
|
||||
@@ -261,13 +247,7 @@ export const Plugin = {
|
||||
),
|
||||
)
|
||||
if (result?.type === "backgrounded") {
|
||||
yield* notifyWhenDone(
|
||||
context.sessionID,
|
||||
child.id,
|
||||
agent.name,
|
||||
input.description,
|
||||
result.info.started_at,
|
||||
)
|
||||
yield* notifyWhenDone(recovery, result.info.started_at)
|
||||
return backgroundResult(child.id)
|
||||
}
|
||||
// Failure surfaces keep the sessionID visible so the model can continue the child.
|
||||
|
||||
+51
-40
@@ -1,11 +1,11 @@
|
||||
export * as Vcs from "./vcs.js"
|
||||
|
||||
import path from "path"
|
||||
import { Cause, Context, Effect, Exit, Fiber, FiberSet, Layer, Schema, Semaphore, Stream } from "effect"
|
||||
import { Cause, Context, Effect, Layer, Schema, Stream } from "effect"
|
||||
import type { VcsDefinition, VcsDraft } from "@opencode-ai/plugin/effect/vcs"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import { BranchList, FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
|
||||
import { Base, BranchList, FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
|
||||
import { VcsEvent } from "@opencode-ai/schema/vcs-event"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
@@ -14,10 +14,15 @@ import { Bus } from "./bus.js"
|
||||
import { State } from "./state.js"
|
||||
import { emptyPatch, MAX_TOTAL_PATCH_BYTES, PATCH_CONTEXT_LINES } from "./vcs/patch.js"
|
||||
|
||||
export { BranchList, FileStatus, Info, Mode }
|
||||
export { Base, BranchList, FileStatus, Info, Mode }
|
||||
|
||||
export class DiffError extends Schema.TaggedError<DiffError>()("Vcs.DiffError", {
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export interface DiffOptions {
|
||||
readonly context?: number
|
||||
readonly base?: string
|
||||
}
|
||||
|
||||
export interface BranchOptions {
|
||||
@@ -27,12 +32,15 @@ export interface BranchOptions {
|
||||
|
||||
export interface Adapter {
|
||||
readonly info: () => Effect.Effect<Info>
|
||||
readonly base?: () => Effect.Effect<Base | null, DiffError>
|
||||
readonly branches: (options?: BranchOptions) => Effect.Effect<BranchList>
|
||||
readonly status: () => Effect.Effect<FileStatus[]>
|
||||
readonly diff: (mode: Mode, options?: DiffOptions) => Effect.Effect<FileDiff.Info[]>
|
||||
readonly diff: (mode: Mode, options?: DiffOptions) => Effect.Effect<FileDiff.Info[], DiffError>
|
||||
}
|
||||
|
||||
export interface Interface extends Adapter, State.Transformable<VcsDraft> {}
|
||||
export interface Interface extends Adapter, State.Transformable<VcsDraft> {
|
||||
readonly base: () => Effect.Effect<Base | null, DiffError>
|
||||
}
|
||||
|
||||
interface Data {
|
||||
readonly providers: Map<string, VcsDefinition>
|
||||
@@ -47,11 +55,8 @@ const layer = Layer.effect(
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const bus = yield* Bus.Service
|
||||
const root = yield* Effect.scope
|
||||
const fork = yield* FiberSet.makeRuntime<never, void, never>()
|
||||
const vcs = location.vcs
|
||||
const current: { info: Info } = { info: { branch: {} } }
|
||||
const refreshLock = Semaphore.makeUnsafe(1)
|
||||
const scope = {
|
||||
directory: location.directory,
|
||||
worktree: location.project.directory,
|
||||
@@ -59,6 +64,7 @@ const layer = Layer.effect(
|
||||
...(vcs ? { store: vcs.store } : {}),
|
||||
}
|
||||
const decodeInfo = Schema.decodeUnknownEffect(Schema.toType(Info))
|
||||
const decodeBase = Schema.decodeUnknownEffect(Schema.NullOr(Base))
|
||||
const decodeBranches = Schema.decodeUnknownEffect(BranchList)
|
||||
const decodeStatus = Schema.decodeUnknownEffect(Schema.Array(FileStatus))
|
||||
const decodeDiff = Schema.decodeUnknownEffect(Schema.Array(FileDiff.Info))
|
||||
@@ -72,12 +78,7 @@ const layer = Layer.effect(
|
||||
set: (selection) => (draft.selection = selection),
|
||||
},
|
||||
}),
|
||||
notify: () =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* Fiber.await(fork(refresh()))
|
||||
if (Exit.isFailure(exit) && root.state._tag === "Closed" && Cause.hasInterruptsOnly(exit.cause)) return
|
||||
yield* exit
|
||||
}),
|
||||
finalize: () => refresh(),
|
||||
})
|
||||
const selected = () => {
|
||||
const value = state.get()
|
||||
@@ -94,24 +95,35 @@ const layer = Layer.effect(
|
||||
),
|
||||
),
|
||||
)
|
||||
const review = <A>(provider: VcsDefinition, operation: "base" | "diff", effect: Effect.Effect<A, unknown>) =>
|
||||
effect.pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
if (Cause.hasInterrupts(cause)) return Effect.failCause(cause).pipe(Effect.orDie)
|
||||
const error = Cause.squash(cause)
|
||||
return Effect.logWarning("vcs provider failed", { provider: provider.id, operation, cause }).pipe(
|
||||
Effect.andThen(
|
||||
Effect.fail(
|
||||
error instanceof DiffError
|
||||
? error
|
||||
: new DiffError({
|
||||
message:
|
||||
operation === "base"
|
||||
? "VCS provider could not resolve a review base"
|
||||
: "VCS provider could not produce a diff",
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
const refresh = Effect.fn("Vcs.refresh")(function* () {
|
||||
const changed = yield* Effect.gen(function* () {
|
||||
const provider = selected()
|
||||
const next: Info = provider
|
||||
? yield* protect(provider, "info", provider.info(scope).pipe(Effect.flatMap(decodeInfo)), { branch: {} })
|
||||
: { branch: {} }
|
||||
const changed = current.info.branch.current !== next.branch.current
|
||||
current.info = next
|
||||
return changed
|
||||
}).pipe(refreshLock.withPermit)
|
||||
if (!changed) return
|
||||
// Legacy listeners can publish nested updates before streams and SSE receive
|
||||
// this event. Re-announce the latest branch if publication was overtaken.
|
||||
while (true) {
|
||||
const branch = current.info.branch.current
|
||||
yield* bus.publish(VcsEvent.BranchUpdated, { branch })
|
||||
if (branch === current.info.branch.current) return
|
||||
}
|
||||
const provider = selected()
|
||||
const next: Info = provider
|
||||
? yield* protect(provider, "info", provider.info(scope).pipe(Effect.flatMap(decodeInfo)), { branch: {} })
|
||||
: { branch: {} }
|
||||
const changed = current.info.branch.current !== next.branch.current
|
||||
current.info = next
|
||||
if (changed) yield* bus.publish(VcsEvent.BranchUpdated, { branch: next.branch.current })
|
||||
})
|
||||
|
||||
if (vcs) {
|
||||
@@ -123,13 +135,7 @@ const layer = Layer.effect(
|
||||
yield* bus.subscribe(FileSystem.Event.Changed).pipe(
|
||||
Stream.filter((event) => isBranchMetadata(event.data.file)),
|
||||
Stream.runForEach((event) =>
|
||||
refresh().pipe(
|
||||
Effect.catchCauseIf(
|
||||
(cause) => !Cause.hasInterrupts(cause),
|
||||
(cause) => Effect.logWarning("vcs refresh failed", { file: event.data.file, cause }),
|
||||
),
|
||||
Effect.withSpan("Vcs.refreshBranch", { attributes: { file: event.data.file } }),
|
||||
),
|
||||
refresh().pipe(Effect.withSpan("Vcs.refreshBranch", { attributes: { file: event.data.file } })),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
@@ -141,6 +147,11 @@ const layer = Layer.effect(
|
||||
info: Effect.fn("Vcs.info")(function* () {
|
||||
return current.info
|
||||
}),
|
||||
base: Effect.fn("Vcs.base")(function* () {
|
||||
const provider = selected()
|
||||
if (!provider?.base) return null
|
||||
return yield* review(provider, "base", provider.base(scope).pipe(Effect.flatMap(decodeBase)))
|
||||
}),
|
||||
branches: Effect.fn("Vcs.branches")(function* (options?: BranchOptions) {
|
||||
const provider = selected()
|
||||
if (provider)
|
||||
@@ -169,18 +180,18 @@ const layer = Layer.effect(
|
||||
diff: Effect.fn("Vcs.diff")(function* (mode: Mode, options?: DiffOptions) {
|
||||
const provider = selected()
|
||||
if (!provider) return []
|
||||
const rows = yield* protect(
|
||||
const rows = yield* review(
|
||||
provider,
|
||||
"diff",
|
||||
provider
|
||||
.diff({
|
||||
...scope,
|
||||
mode,
|
||||
...(options?.base !== undefined ? { base: options.base } : {}),
|
||||
context: options?.context ?? PATCH_CONTEXT_LINES,
|
||||
maxOutputBytes: MAX_TOTAL_PATCH_BYTES,
|
||||
})
|
||||
.pipe(Effect.flatMap(decodeDiff)),
|
||||
[],
|
||||
)
|
||||
let total = 0
|
||||
return rows.map((row) => {
|
||||
|
||||
@@ -88,7 +88,7 @@ const layer = Layer.effect(
|
||||
set: (selection) => (draft.selection = selection),
|
||||
},
|
||||
}),
|
||||
notify: () => bus.publish(WebSearch.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
finalize: () => bus.publish(WebSearch.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
const requireProvider = (providers: Map<ID, ProviderImplementation>, providerID: ID) => {
|
||||
|
||||
@@ -11,7 +11,6 @@ import { Location } from "@opencode-ai/core/location"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { State } from "@opencode-ai/core/state"
|
||||
import { location } from "./fixture/location"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
@@ -31,57 +30,6 @@ const catalogLayer = AppNodeBuilder.build(
|
||||
const it = testEffect(catalogLayer)
|
||||
|
||||
describe("Catalog", () => {
|
||||
it.effect("reads available and default models inside a batch before publishing", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const bus = yield* Bus.Service
|
||||
const observed: string[] = []
|
||||
const unsubscribe = yield* bus.listen((event) =>
|
||||
event.type === Catalog.Event.Updated.type
|
||||
? catalog.model.default().pipe(
|
||||
Effect.map((model) => {
|
||||
observed.push(model?.id ?? "none")
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
)
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
const providerID = Provider.ID.make("test")
|
||||
const old = Model.ID.make("old")
|
||||
const newest = Model.ID.make("new")
|
||||
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* catalog.transform((draft) => {
|
||||
draft.provider.update(providerID, () => {})
|
||||
draft.model.update(providerID, old, (model) => {
|
||||
model.time.released = 1000
|
||||
})
|
||||
draft.model.update(providerID, newest, (model) => {
|
||||
model.time.released = 2000
|
||||
})
|
||||
draft.model.default.set(providerID, old)
|
||||
})
|
||||
expect((yield* catalog.model.available()).map((model) => model.id)).toEqual([newest, old])
|
||||
expect((yield* catalog.model.default())?.id).toBe(old)
|
||||
|
||||
const overlay = yield* catalog.transform((draft) =>
|
||||
draft.model.update(providerID, old, (model) => {
|
||||
model.enabled = false
|
||||
}),
|
||||
)
|
||||
expect((yield* catalog.model.available()).map((model) => model.id)).toEqual([newest])
|
||||
expect((yield* catalog.model.default())?.id).toBe(newest)
|
||||
yield* overlay.dispose
|
||||
expect((yield* catalog.model.default())?.id).toBe(old)
|
||||
expect(observed).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
expect(observed).toEqual([old])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("publishes an updated event after catalog changes", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
@@ -346,7 +294,6 @@ describe("Catalog", () => {
|
||||
|
||||
configured = false
|
||||
const reload = yield* catalog.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
expect((yield* catalog.model.default())?.id).toBe(newest)
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(reload)
|
||||
expect((yield* catalog.model.default())?.id).toBe(newest)
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Deferred, Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import {
|
||||
AgentsDirectory,
|
||||
ClaudeDirectory,
|
||||
Directory as ConfigDirectory,
|
||||
Document,
|
||||
type Entry,
|
||||
Info,
|
||||
} from "@opencode-ai/schema/config"
|
||||
import { AgentsDirectory, ClaudeDirectory, Directory, Document, type Entry, Info } from "@opencode-ai/schema/config"
|
||||
import { ConfigSkillPlugin } from "@opencode-ai/core/config/plugin/skill"
|
||||
import { SkillFile } from "@opencode-ai/core/config/plugin/skill-file"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -31,26 +24,10 @@ import { location } from "../fixture/location"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { host } from "../plugin/host"
|
||||
|
||||
const urls = new Map<string, AbsolutePath[]>()
|
||||
const failedUrls = new Set<string>()
|
||||
let pulls = 0
|
||||
const discoveryLayer = Layer.succeed(
|
||||
SkillDiscovery.Service,
|
||||
SkillDiscovery.Service.of({
|
||||
pull: (url) => {
|
||||
pulls++
|
||||
if (failedUrls.has(url)) return Effect.die(`failed to pull ${url}`)
|
||||
return Effect.succeed(urls.get(url) ?? [])
|
||||
},
|
||||
}),
|
||||
)
|
||||
const emptyDiscovery = SkillDiscovery.Service.of({ pull: () => Effect.succeed([]) })
|
||||
const watcherLayer = Watcher.testLayer
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
AppNodeBuilder.build(LayerNode.group([Skill.node, Bus.node, FSUtil.node])),
|
||||
discoveryLayer,
|
||||
watcherLayer,
|
||||
),
|
||||
Layer.merge(AppNodeBuilder.build(LayerNode.group([Skill.node, Bus.node, FSUtil.node])), watcherLayer),
|
||||
)
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
@@ -65,7 +42,12 @@ description: ${description}
|
||||
)
|
||||
}
|
||||
|
||||
const startEntries = Effect.fnUntraced(function* (entries: Entry[], directory: string, home = directory) {
|
||||
const startEntries = Effect.fnUntraced(function* (
|
||||
entries: Entry[],
|
||||
directory: string,
|
||||
home = directory,
|
||||
discovery = emptyDiscovery,
|
||||
) {
|
||||
const service = yield* Skill.Service
|
||||
yield* ConfigSkillPlugin.Plugin.effect(
|
||||
host({
|
||||
@@ -77,13 +59,14 @@ const startEntries = Effect.fnUntraced(function* (entries: Entry[], directory: s
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(Config.testLayer(entries)),
|
||||
Effect.provideService(SkillDiscovery.Service, discovery),
|
||||
Effect.provideService(Global.Service, Global.Service.of({ ...Global.make(), home })),
|
||||
Effect.provideService(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
||||
)
|
||||
return service
|
||||
})
|
||||
|
||||
const start = (skills: string[], directory: string) =>
|
||||
const start = (skills: string[], directory: string, discovery = emptyDiscovery) =>
|
||||
startEntries(
|
||||
[
|
||||
new Document({
|
||||
@@ -92,6 +75,8 @@ const start = (skills: string[], directory: string) =>
|
||||
}),
|
||||
],
|
||||
directory,
|
||||
directory,
|
||||
discovery,
|
||||
)
|
||||
|
||||
const discover = (directory: string, global: string) =>
|
||||
@@ -130,14 +115,13 @@ function emitAndWait(update: Watcher.Update) {
|
||||
}
|
||||
|
||||
describe("SkillFile.parse", () => {
|
||||
it.effect("parses root and nested skill ids and metadata flags", () =>
|
||||
Effect.sync(() => {
|
||||
const directory = "/repo/skills"
|
||||
expect(
|
||||
SkillFile.parse(
|
||||
directory,
|
||||
"/repo/skills/manual/SKILL.md",
|
||||
`---
|
||||
test("parses root and nested skill ids and metadata flags", () => {
|
||||
const directory = "/repo/skills"
|
||||
expect(
|
||||
SkillFile.parse(
|
||||
directory,
|
||||
"/repo/skills/manual/SKILL.md",
|
||||
`---
|
||||
name: Manual
|
||||
description: Manual only
|
||||
metadata:
|
||||
@@ -145,45 +129,41 @@ metadata:
|
||||
opencode/autoinvoke: false
|
||||
---
|
||||
# manual`,
|
||||
),
|
||||
).toEqual({
|
||||
_tag: "Parsed",
|
||||
skill: {
|
||||
id: Skill.ID.make("manual"),
|
||||
name: Skill.Name.make("Manual"),
|
||||
description: "Manual only",
|
||||
slash: true,
|
||||
autoinvoke: false,
|
||||
location: AbsolutePath.make("/repo/skills/manual/SKILL.md"),
|
||||
content: "# manual",
|
||||
},
|
||||
})
|
||||
expect(SkillFile.parse(directory, "/repo/skills/foo.md", "---\nslash: true\n---\n# foo")).toMatchObject({
|
||||
_tag: "Parsed",
|
||||
skill: { id: Skill.ID.make("foo") },
|
||||
})
|
||||
expect(SkillFile.parse("/repo/skills/manual", "/repo/skills/manual/SKILL.md", "# manual")).toMatchObject({
|
||||
_tag: "Parsed",
|
||||
skill: { id: Skill.ID.make("manual"), name: Skill.Name.make("manual") },
|
||||
})
|
||||
expect(
|
||||
SkillFile.parse(directory, "/repo/skills/broken.md", "---\ndescription: foo: bar\nmetadata: [\n---\n# broken"),
|
||||
).toEqual({ _tag: "Skipped", reason: "markdown" })
|
||||
expect(SkillFile.parse(directory, "/repo/skills/broken.md", "---\nslash: nope\n---\n# broken")).toMatchObject({
|
||||
_tag: "Skipped",
|
||||
reason: "frontmatter",
|
||||
issue: expect.anything(),
|
||||
})
|
||||
}),
|
||||
)
|
||||
),
|
||||
).toEqual({
|
||||
_tag: "Parsed",
|
||||
skill: {
|
||||
id: Skill.ID.make("manual"),
|
||||
name: Skill.Name.make("Manual"),
|
||||
description: "Manual only",
|
||||
slash: true,
|
||||
autoinvoke: false,
|
||||
location: AbsolutePath.make("/repo/skills/manual/SKILL.md"),
|
||||
content: "# manual",
|
||||
},
|
||||
})
|
||||
expect(SkillFile.parse(directory, "/repo/skills/foo.md", "---\nslash: true\n---\n# foo")).toMatchObject({
|
||||
_tag: "Parsed",
|
||||
skill: { id: Skill.ID.make("foo") },
|
||||
})
|
||||
expect(SkillFile.parse("/repo/skills/manual", "/repo/skills/manual/SKILL.md", "# manual")).toMatchObject({
|
||||
_tag: "Parsed",
|
||||
skill: { id: Skill.ID.make("manual"), name: Skill.Name.make("manual") },
|
||||
})
|
||||
expect(
|
||||
SkillFile.parse(directory, "/repo/skills/broken.md", "---\ndescription: foo: bar\nmetadata: [\n---\n# broken"),
|
||||
).toEqual({ _tag: "Skipped", reason: "markdown" })
|
||||
expect(SkillFile.parse(directory, "/repo/skills/broken.md", "---\nslash: nope\n---\n# broken")).toMatchObject({
|
||||
_tag: "Skipped",
|
||||
reason: "frontmatter",
|
||||
issue: expect.anything(),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("ConfigSkillPlugin.Plugin", () => {
|
||||
it.live("maps config entry types to skill directories", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const claude = path.join(tmp.path, "claude")
|
||||
@@ -205,7 +185,7 @@ describe("ConfigSkillPlugin.Plugin", () => {
|
||||
[
|
||||
new ClaudeDirectory({ type: "claude", path: AbsolutePath.make(claude) }),
|
||||
new AgentsDirectory({ type: "agents", path: AbsolutePath.make(agents) }),
|
||||
new ConfigDirectory({ type: "directory", path: AbsolutePath.make(opencode) }),
|
||||
new Directory({ type: "directory", path: AbsolutePath.make(opencode) }),
|
||||
new Document({ type: "document", info: decode({ skills: ["~/shared", "./relative"] }) }),
|
||||
],
|
||||
directory,
|
||||
@@ -219,10 +199,7 @@ describe("ConfigSkillPlugin.Plugin", () => {
|
||||
)
|
||||
|
||||
it.live("loads directory and individual downloaded skill roots with later-source precedence", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const first = path.join(tmp.path, "first")
|
||||
@@ -235,30 +212,32 @@ describe("ConfigSkillPlugin.Plugin", () => {
|
||||
await write(second, "deploy", "Deploy")
|
||||
await write(second, "review", "Second")
|
||||
})
|
||||
pulls = 0
|
||||
urls.set("https://example.test/skills/", [
|
||||
AbsolutePath.make(path.join(second, "deploy")),
|
||||
AbsolutePath.make(path.join(second, "review")),
|
||||
])
|
||||
const pulls: string[] = []
|
||||
const discovery = SkillDiscovery.Service.of({
|
||||
pull: (url) => {
|
||||
pulls.push(url)
|
||||
return Effect.succeed([
|
||||
AbsolutePath.make(path.join(second, "deploy")),
|
||||
AbsolutePath.make(path.join(second, "review")),
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
const skill = yield* start([first, "https://example.test/skills/"], tmp.path)
|
||||
const skill = yield* start([first, "https://example.test/skills/"], tmp.path, discovery)
|
||||
expect((yield* skill.list()).map((item) => item.id).toSorted()).toEqual([
|
||||
Skill.ID.make("deploy"),
|
||||
Skill.ID.make("review"),
|
||||
])
|
||||
expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Deploy")
|
||||
expect((yield* skill.list()).find((item) => item.id === "review")?.description).toBe("Second")
|
||||
expect(pulls).toBe(1)
|
||||
expect(pulls).toEqual(["https://example.test/skills/"])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("prefers a worktree skill over the parent checkout copy", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const checkout = path.join(tmp.path, "repo")
|
||||
@@ -286,10 +265,7 @@ describe("ConfigSkillPlugin.Plugin", () => {
|
||||
)
|
||||
|
||||
it.live("keeps directory skills when a URL source fails", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
@@ -297,21 +273,17 @@ describe("ConfigSkillPlugin.Plugin", () => {
|
||||
await write(tmp.path, "review", "Available")
|
||||
})
|
||||
const url = "https://unreachable.example.test/skills/"
|
||||
failedUrls.add(url)
|
||||
const discovery = SkillDiscovery.Service.of({ pull: () => Effect.die(`failed to pull ${url}`) })
|
||||
|
||||
const skill = yield* start([tmp.path, url], tmp.path)
|
||||
const skill = yield* start([tmp.path, url], tmp.path, discovery)
|
||||
expect((yield* skill.list()).find((item) => item.id === "review")?.description).toBe("Available")
|
||||
failedUrls.delete(url)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rescans directory sources when watched files change", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
@@ -345,10 +317,7 @@ describe("ConfigSkillPlugin.Plugin", () => {
|
||||
)
|
||||
|
||||
it.live("watches canonical directories behind symlinked skills", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const source = path.join(tmp.path, "source")
|
||||
@@ -375,10 +344,7 @@ describe("ConfigSkillPlugin.Plugin", () => {
|
||||
)
|
||||
|
||||
it.live("reloads symlinked sources when their target changes", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const source = path.join(tmp.path, "source")
|
||||
@@ -419,10 +385,7 @@ describe("ConfigSkillPlugin.Plugin", () => {
|
||||
)
|
||||
|
||||
it.live("follows missing source directories as their parents appear", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const source = path.join(tmp.path, "generated", "skills")
|
||||
|
||||
@@ -6,10 +6,11 @@ import { expect, test } from "bun:test"
|
||||
import { SqliteClient } from "@effect/sql-sqlite-bun"
|
||||
import { eq, sql } from "drizzle-orm"
|
||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"
|
||||
import { Effect, Tracer } from "effect"
|
||||
import { Cause, Effect, Tracer } from "effect"
|
||||
import type { SqlClient } from "effect/unstable/sql/SqlClient"
|
||||
import { isSqlError } from "effect/unstable/sql/SqlError"
|
||||
import { EffectDrizzleSqlite } from "@opencode-ai/core/database/drizzle"
|
||||
import { EffectDrizzleQueryError } from "drizzle-orm/effect-core/errors"
|
||||
|
||||
const users = sqliteTable("users", {
|
||||
id: integer().primaryKey({ autoIncrement: true }),
|
||||
@@ -47,6 +48,22 @@ test("selects rows through Effect-yieldable query builders", async () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("maps query failures with query, params, and cause", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* EffectDrizzleSqlite.makeWithDefaults()
|
||||
const error = yield* db.run(sql`select * from missing_table where id = ${42}`).pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(EffectDrizzleQueryError)
|
||||
expect(error.query).toBe("select * from missing_table where id = ?")
|
||||
expect(error.params).toEqual([42])
|
||||
expect(Cause.isCause(error.cause)).toBe(true)
|
||||
if (!Cause.isCause(error.cause)) return
|
||||
expect(error.cause.reasons[0]?._tag).toBe("Fail")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("suppresses statement spans", async () => {
|
||||
const spans: Tracer.NativeSpan[] = []
|
||||
const tracer = Tracer.make({
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Fiber, Scope } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LocationWatcherPolicy } from "@opencode-ai/core/filesystem/location-watcher-policy"
|
||||
import { State } from "@opencode-ai/core/state"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(LocationWatcherPolicy.node))
|
||||
|
||||
describe("LocationWatcherPolicy", () => {
|
||||
it.effect("reads batched registrations and disposals without notifying observers", () =>
|
||||
Effect.gen(function* () {
|
||||
const policy = yield* LocationWatcherPolicy.Service
|
||||
const observed: string[][] = []
|
||||
yield* policy.observe((ignore) =>
|
||||
Effect.sync(() => {
|
||||
observed.push([...ignore])
|
||||
}),
|
||||
)
|
||||
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* policy.transform((draft) => draft.add(["base"]))
|
||||
const overlay = yield* policy.transform((draft) => draft.add(["overlay"]))
|
||||
const snapshot = policy.current()
|
||||
expect(snapshot).toEqual(["base", "overlay"])
|
||||
expect(observed).toEqual([])
|
||||
|
||||
yield* overlay.dispose
|
||||
expect(policy.current()).toEqual(["base"])
|
||||
expect(snapshot).toEqual(["base", "overlay"])
|
||||
expect(observed).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
expect(observed).toEqual([["base"]])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reads reloaded patterns before debounced observer reconciliation", () =>
|
||||
Effect.gen(function* () {
|
||||
const policy = yield* LocationWatcherPolicy.Service
|
||||
const observed: string[][] = []
|
||||
let ignore = ["first"]
|
||||
yield* policy.observe((ignore) =>
|
||||
Effect.sync(() => {
|
||||
observed.push([...ignore])
|
||||
}),
|
||||
)
|
||||
yield* policy.transform((draft) => draft.add(ignore))
|
||||
const snapshot = policy.current()
|
||||
observed.length = 0
|
||||
|
||||
ignore = ["second"]
|
||||
const reload = yield* policy.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
expect(policy.current()).toEqual(["second"])
|
||||
expect(snapshot).toEqual(["first"])
|
||||
expect(observed).toEqual([])
|
||||
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(reload)
|
||||
expect(observed).toEqual([["second"]])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("passes the latest policy to later observers after a reentrant registration", () =>
|
||||
Effect.gen(function* () {
|
||||
const policy = yield* LocationWatcherPolicy.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const observed: string[][] = []
|
||||
let reentered = false
|
||||
yield* policy.observe(() =>
|
||||
Effect.gen(function* () {
|
||||
if (reentered) return
|
||||
reentered = true
|
||||
yield* policy.transform((draft) => draft.add(["inner"])).pipe(Scope.provide(scope))
|
||||
}),
|
||||
)
|
||||
yield* policy.observe((ignore) =>
|
||||
Effect.sync(() => {
|
||||
observed.push([...ignore])
|
||||
}),
|
||||
)
|
||||
|
||||
yield* policy.transform((draft) => draft.add(["outer"]))
|
||||
|
||||
expect(policy.current()).toEqual(["outer", "inner"])
|
||||
expect(observed).toEqual([
|
||||
["outer", "inner"],
|
||||
["outer", "inner"],
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("allows an observer to await a reload and keeps later observers current", () =>
|
||||
Effect.gen(function* () {
|
||||
const policy = yield* LocationWatcherPolicy.Service
|
||||
const observed: string[][] = []
|
||||
let ignore = ["first"]
|
||||
let reentered = false
|
||||
yield* policy.observe(() =>
|
||||
Effect.gen(function* () {
|
||||
if (reentered) return
|
||||
reentered = true
|
||||
ignore = ["second"]
|
||||
yield* policy.reload()
|
||||
}),
|
||||
)
|
||||
yield* policy.observe((ignore) =>
|
||||
Effect.sync(() => {
|
||||
observed.push([...ignore])
|
||||
}),
|
||||
)
|
||||
|
||||
const writer = yield* policy
|
||||
.transform((draft) => draft.add(ignore))
|
||||
.pipe(Effect.forkChild({ startImmediately: true }))
|
||||
expect(policy.current()).toEqual(["second"])
|
||||
expect(observed).toEqual([])
|
||||
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(writer)
|
||||
expect(observed).toEqual([["second"], ["second"]])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -14,10 +14,6 @@ export function location(ref: Location.Ref, input: { projectDirectory?: Absolute
|
||||
} satisfies Location.Interface
|
||||
}
|
||||
|
||||
export function locationLayer(ref: Location.Ref, input: { projectDirectory?: AbsolutePath; vcs?: Project.Vcs } = {}) {
|
||||
return Layer.succeed(Location.Service, Location.Service.of(location(ref, input)))
|
||||
}
|
||||
|
||||
export const tempLocationLayer = Layer.unwrap(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
@@ -25,7 +21,7 @@ export const tempLocationLayer = Layer.unwrap(
|
||||
).pipe(
|
||||
Effect.map((tmp) => {
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make(tmp.path) })
|
||||
return locationLayer(ref)
|
||||
return Layer.succeed(Location.Service, Location.Service.of(location(ref)))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export function fakeSelectorSdk(calls: string[]) {
|
||||
const make = (method: string) => (id: string) => {
|
||||
calls.push(`${method}:${id}`)
|
||||
return { modelId: id, provider: method, specificationVersion: "v3" }
|
||||
}
|
||||
return {
|
||||
responses: make("responses"),
|
||||
messages: make("messages"),
|
||||
chat: make("chat"),
|
||||
languageModel: make("languageModel"),
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,197 @@
|
||||
import { OpenAIResponsesLanguageModel } from "@opencode-ai/core/github-copilot/responses/openai-responses-language-model"
|
||||
import { convertToOpenAIResponsesInput } from "@opencode-ai/core/github-copilot/responses/convert-to-openai-responses-input"
|
||||
import { describe, test, expect, mock } from "bun:test"
|
||||
import type { LanguageModelV3Prompt, LanguageModelV3StreamPart } from "@ai-sdk/provider"
|
||||
import type { LanguageModelV3Prompt, LanguageModelV3ProviderTool, LanguageModelV3StreamPart } from "@ai-sdk/provider"
|
||||
|
||||
const TEST_PROMPT: LanguageModelV3Prompt = [{ role: "user", content: [{ type: "text", text: "Hello" }] }]
|
||||
|
||||
const HOSTED_TOOL_CASES = [
|
||||
{
|
||||
id: "openai.web_search",
|
||||
name: "current_web",
|
||||
args: {},
|
||||
wireType: "web_search",
|
||||
output: {
|
||||
type: "web_search_call",
|
||||
id: "web_1",
|
||||
status: "completed",
|
||||
action: { type: "search", query: "news" },
|
||||
},
|
||||
stream: [
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
output_index: 0,
|
||||
item: {
|
||||
type: "web_search_call",
|
||||
id: "web_1",
|
||||
status: "in_progress",
|
||||
action: { type: "search", query: "news" },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
output_index: 0,
|
||||
item: {
|
||||
type: "web_search_call",
|
||||
id: "web_1",
|
||||
status: "completed",
|
||||
action: { type: "search", query: "news" },
|
||||
},
|
||||
},
|
||||
],
|
||||
streamEventTypes: ["tool-input-start", "tool-input-end", "tool-call", "tool-result"],
|
||||
eventTypes: ["tool-input-start", "tool-call", "tool-result"],
|
||||
},
|
||||
{
|
||||
id: "openai.web_search_preview",
|
||||
name: "preview_web",
|
||||
args: {},
|
||||
wireType: "web_search_preview",
|
||||
output: {
|
||||
type: "web_search_call",
|
||||
id: "preview_1",
|
||||
status: "completed",
|
||||
action: { type: "search", query: "news" },
|
||||
},
|
||||
stream: [
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
output_index: 0,
|
||||
item: {
|
||||
type: "web_search_call",
|
||||
id: "preview_1",
|
||||
status: "in_progress",
|
||||
action: { type: "search", query: "news" },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
output_index: 0,
|
||||
item: {
|
||||
type: "web_search_call",
|
||||
id: "preview_1",
|
||||
status: "completed",
|
||||
action: { type: "search", query: "news" },
|
||||
},
|
||||
},
|
||||
],
|
||||
streamEventTypes: ["tool-input-start", "tool-input-end", "tool-call", "tool-result"],
|
||||
eventTypes: ["tool-input-start", "tool-call", "tool-result"],
|
||||
},
|
||||
{
|
||||
id: "openai.file_search",
|
||||
name: "documents",
|
||||
args: { vectorStoreIds: ["store_1"] },
|
||||
wireType: "file_search",
|
||||
output: { type: "file_search_call", id: "file_1", queries: ["news"], results: null },
|
||||
stream: [
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
output_index: 0,
|
||||
item: { type: "file_search_call", id: "file_1" },
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
output_index: 0,
|
||||
item: { type: "file_search_call", id: "file_1", queries: ["news"], results: null },
|
||||
},
|
||||
],
|
||||
streamEventTypes: ["tool-call", "tool-result"],
|
||||
eventTypes: ["tool-call", "tool-result"],
|
||||
},
|
||||
{
|
||||
id: "openai.code_interpreter",
|
||||
name: "python",
|
||||
args: {},
|
||||
wireType: "code_interpreter",
|
||||
output: {
|
||||
type: "code_interpreter_call",
|
||||
id: "code_1",
|
||||
code: "print(1)",
|
||||
container_id: "container_1",
|
||||
outputs: null,
|
||||
},
|
||||
stream: [
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
output_index: 0,
|
||||
item: {
|
||||
type: "code_interpreter_call",
|
||||
id: "code_1",
|
||||
code: null,
|
||||
container_id: "container_1",
|
||||
outputs: null,
|
||||
status: "in_progress",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "response.code_interpreter_call_code.delta",
|
||||
item_id: "code_1",
|
||||
output_index: 0,
|
||||
delta: "print(",
|
||||
},
|
||||
{
|
||||
type: "response.code_interpreter_call_code.done",
|
||||
item_id: "code_1",
|
||||
output_index: 0,
|
||||
code: "print(1)",
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
output_index: 0,
|
||||
item: {
|
||||
type: "code_interpreter_call",
|
||||
id: "code_1",
|
||||
code: "print(1)",
|
||||
container_id: "container_1",
|
||||
outputs: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
streamEventTypes: [
|
||||
"tool-input-start",
|
||||
"tool-input-delta",
|
||||
"tool-input-delta",
|
||||
"tool-input-delta",
|
||||
"tool-input-end",
|
||||
"tool-call",
|
||||
"tool-result",
|
||||
],
|
||||
eventTypes: ["tool-input-start", "tool-call", "tool-result"],
|
||||
},
|
||||
{
|
||||
id: "openai.image_generation",
|
||||
name: "illustrate",
|
||||
args: {},
|
||||
wireType: "image_generation",
|
||||
output: { type: "image_generation_call", id: "image_1", result: "final-image" },
|
||||
stream: [
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
output_index: 0,
|
||||
item: { type: "image_generation_call", id: "image_1" },
|
||||
},
|
||||
{
|
||||
type: "response.image_generation_call.partial_image",
|
||||
item_id: "image_1",
|
||||
output_index: 0,
|
||||
partial_image_b64: "partial-image",
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
output_index: 0,
|
||||
item: { type: "image_generation_call", id: "image_1", result: "final-image" },
|
||||
},
|
||||
],
|
||||
streamEventTypes: ["tool-call", "tool-result", "tool-result"],
|
||||
eventTypes: ["tool-call", "tool-result", "tool-result"],
|
||||
},
|
||||
] as const
|
||||
|
||||
function hostedTool(testCase: (typeof HOSTED_TOOL_CASES)[number]): LanguageModelV3ProviderTool {
|
||||
return { type: "provider", id: testCase.id, name: testCase.name, args: testCase.args }
|
||||
}
|
||||
|
||||
function createMockFetch(body: unknown) {
|
||||
return mock(
|
||||
async () => new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } }),
|
||||
@@ -30,12 +217,144 @@ function createModel(fetchFn: ReturnType<typeof mock>) {
|
||||
})
|
||||
}
|
||||
|
||||
async function readStream(stream: ReadableStream<LanguageModelV3StreamPart>) {
|
||||
const reader = stream.getReader()
|
||||
const events: LanguageModelV3StreamPart[] = []
|
||||
while (true) {
|
||||
const item = await reader.read()
|
||||
if (item.done) return events
|
||||
events.push(item.value)
|
||||
}
|
||||
}
|
||||
|
||||
// GitHub Copilot's Responses model echoes item metadata (itemId, reasoningEncryptedContent,
|
||||
// responseId, ...) under the "copilot" providerOptions/providerMetadata namespace, matching the
|
||||
// namespace request options already use. It used to echo this metadata under "openai" (a leftover
|
||||
// from forking the OpenAI Responses model), which left it unreachable by anything reading the
|
||||
// "copilot" namespace and let stale itemIds slip past stripping meant for that namespace.
|
||||
describe("doGenerate", () => {
|
||||
test.each([...HOSTED_TOOL_CASES])("forces $id by its declared logical name", async (testCase) => {
|
||||
const requests: unknown[] = []
|
||||
const model = createModel(
|
||||
mock(async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
||||
requests.push(await new Response(init?.body).json())
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: "resp_1",
|
||||
created_at: 0,
|
||||
model: "test-model",
|
||||
output: [],
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
await model.doGenerate({
|
||||
prompt: TEST_PROMPT,
|
||||
tools: [hostedTool(testCase)],
|
||||
toolChoice: { type: "tool", toolName: testCase.name },
|
||||
})
|
||||
|
||||
expect(requests[0]).toMatchObject({ tool_choice: { type: testCase.wireType } })
|
||||
})
|
||||
|
||||
test("does not mistake a colliding function name for a hosted tool", async () => {
|
||||
const requests: unknown[] = []
|
||||
const model = createModel(
|
||||
mock(async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
||||
requests.push(await new Response(init?.body).json())
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: "resp_1",
|
||||
created_at: 0,
|
||||
model: "test-model",
|
||||
output: [],
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
)
|
||||
}),
|
||||
)
|
||||
await model.doGenerate({
|
||||
prompt: TEST_PROMPT,
|
||||
tools: [
|
||||
{ type: "provider", id: "openai.web_search", name: "lookup", args: {} },
|
||||
{ type: "function", name: "web_search", inputSchema: { type: "object" } },
|
||||
],
|
||||
toolChoice: { type: "tool", toolName: "web_search" },
|
||||
})
|
||||
|
||||
expect(requests[0]).toMatchObject({ tool_choice: { type: "function", name: "web_search" } })
|
||||
})
|
||||
|
||||
test.each([...HOSTED_TOOL_CASES])("uses $name for generated $id calls and results", async (testCase) => {
|
||||
const model = createModel(
|
||||
createMockFetch({
|
||||
id: "resp_1",
|
||||
created_at: 0,
|
||||
model: "test-model",
|
||||
output: [testCase.output],
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
}),
|
||||
)
|
||||
|
||||
const result = await model.doGenerate({ prompt: TEST_PROMPT, tools: [hostedTool(testCase)] })
|
||||
|
||||
expect(result.content.filter((part) => part.type === "tool-call" || part.type === "tool-result")).toMatchObject([
|
||||
{ type: "tool-call", toolName: testCase.name },
|
||||
{ type: "tool-result", toolName: testCase.name },
|
||||
])
|
||||
})
|
||||
|
||||
test("uses canonical names only when no hosted declaration matches", async () => {
|
||||
const model = createModel(
|
||||
createMockFetch({
|
||||
id: "resp_1",
|
||||
created_at: 0,
|
||||
model: "test-model",
|
||||
output: [
|
||||
HOSTED_TOOL_CASES[0].output,
|
||||
HOSTED_TOOL_CASES[2].output,
|
||||
HOSTED_TOOL_CASES[3].output,
|
||||
HOSTED_TOOL_CASES[4].output,
|
||||
{ type: "computer_call", id: "computer_1", status: "completed" },
|
||||
],
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
}),
|
||||
)
|
||||
|
||||
const result = await model.doGenerate({ prompt: TEST_PROMPT })
|
||||
|
||||
expect(result.content.filter((part) => part.type === "tool-call").map((part) => part.toolName)).toEqual([
|
||||
"web_search",
|
||||
"file_search",
|
||||
"code_interpreter",
|
||||
"image_generation",
|
||||
"computer_use",
|
||||
])
|
||||
})
|
||||
|
||||
test("rejects an automatic web response when both variants have different logical names", async () => {
|
||||
const model = createModel(
|
||||
createMockFetch({
|
||||
id: "resp_1",
|
||||
created_at: 0,
|
||||
model: "test-model",
|
||||
output: [HOSTED_TOOL_CASES[0].output],
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
}),
|
||||
)
|
||||
|
||||
await expect(
|
||||
model.doGenerate({
|
||||
prompt: TEST_PROMPT,
|
||||
tools: [hostedTool(HOSTED_TOOL_CASES[0]), hostedTool(HOSTED_TOOL_CASES[1])],
|
||||
}),
|
||||
).rejects.toThrow("ambiguous web_search response for hosted tools: current_web, preview_web")
|
||||
})
|
||||
|
||||
test("attaches item metadata under the copilot namespace, not openai", async () => {
|
||||
const mockFetch = createMockFetch({
|
||||
id: "resp_1",
|
||||
@@ -129,6 +448,105 @@ describe("doGenerate", () => {
|
||||
})
|
||||
|
||||
describe("doStream", () => {
|
||||
test.each([...HOSTED_TOOL_CASES])("uses $name for every streamed $id identity event", async (testCase) => {
|
||||
const model = createModel(createStreamFetch(testCase.stream))
|
||||
const result = await model.doStream({
|
||||
prompt: TEST_PROMPT,
|
||||
tools: [hostedTool(testCase)],
|
||||
})
|
||||
const streamEvents = (await readStream(result.stream)).filter(
|
||||
(event) => event.type !== "stream-start" && event.type !== "finish",
|
||||
)
|
||||
const events = streamEvents.filter((event) => "toolName" in event)
|
||||
|
||||
expect(streamEvents.map((event) => event.type)).toEqual([...testCase.streamEventTypes])
|
||||
expect(events.map((event) => event.type)).toEqual([...testCase.eventTypes])
|
||||
expect(events.map((event) => event.toolName)).toEqual(testCase.eventTypes.map(() => testCase.name))
|
||||
})
|
||||
|
||||
test("uses the forced web variant's logical name when both variants are declared", async () => {
|
||||
const model = createModel(createStreamFetch(HOSTED_TOOL_CASES[1].stream))
|
||||
|
||||
const result = await model.doStream({
|
||||
prompt: TEST_PROMPT,
|
||||
tools: [hostedTool(HOSTED_TOOL_CASES[0]), hostedTool(HOSTED_TOOL_CASES[1])],
|
||||
toolChoice: { type: "tool", toolName: "preview_web" },
|
||||
})
|
||||
const events = (await readStream(result.stream)).filter((event) => "toolName" in event)
|
||||
|
||||
expect(events.map((event) => event.toolName)).toEqual(["preview_web", "preview_web", "preview_web"])
|
||||
})
|
||||
|
||||
test("rejects ambiguous web variants before fetching or exposing a stream", async () => {
|
||||
const fetchFn = createStreamFetch(HOSTED_TOOL_CASES[0].stream)
|
||||
const model = createModel(fetchFn)
|
||||
|
||||
await expect(
|
||||
model.doStream({
|
||||
prompt: TEST_PROMPT,
|
||||
tools: [hostedTool(HOSTED_TOOL_CASES[0]), hostedTool(HOSTED_TOOL_CASES[1])],
|
||||
}),
|
||||
).rejects.toThrow("ambiguous web_search response for hosted tools")
|
||||
expect(fetchFn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("rejects an ambiguous forced wire choice before fetching or exposing a stream", async () => {
|
||||
const fetchFn = createStreamFetch(HOSTED_TOOL_CASES[0].stream)
|
||||
const model = createModel(fetchFn)
|
||||
|
||||
await expect(
|
||||
model.doStream({
|
||||
prompt: TEST_PROMPT,
|
||||
tools: [hostedTool(HOSTED_TOOL_CASES[0]), { ...hostedTool(HOSTED_TOOL_CASES[0]), name: "backup_web" }],
|
||||
toolChoice: { type: "tool", toolName: HOSTED_TOOL_CASES[0].name },
|
||||
}),
|
||||
).rejects.toThrow("ambiguous web_search tool choice for hosted tools")
|
||||
expect(fetchFn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("streams a shared logical name for both web variants", async () => {
|
||||
const model = createModel(createStreamFetch(HOSTED_TOOL_CASES[0].stream))
|
||||
const tools = [hostedTool(HOSTED_TOOL_CASES[0]), hostedTool(HOSTED_TOOL_CASES[1])].map((tool) => ({
|
||||
...tool,
|
||||
name: "web",
|
||||
}))
|
||||
|
||||
const result = await model.doStream({ prompt: TEST_PROMPT, tools })
|
||||
const events = (await readStream(result.stream)).filter((event) => "toolName" in event)
|
||||
|
||||
expect(events.map((event) => event.toolName)).toEqual(["web", "web", "web"])
|
||||
})
|
||||
|
||||
test("uses canonical names for undeclared streamed web and computer calls", async () => {
|
||||
const model = createModel(
|
||||
createStreamFetch([
|
||||
...HOSTED_TOOL_CASES[0].stream,
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
output_index: 1,
|
||||
item: { type: "computer_call", id: "computer_1", status: "in_progress" },
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
output_index: 1,
|
||||
item: { type: "computer_call", id: "computer_1", status: "completed" },
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
const result = await model.doStream({ prompt: TEST_PROMPT })
|
||||
const events = (await readStream(result.stream)).filter((event) => "toolName" in event)
|
||||
|
||||
expect(events.map((event) => event.toolName)).toEqual([
|
||||
"web_search",
|
||||
"web_search",
|
||||
"web_search",
|
||||
"computer_use",
|
||||
"computer_use",
|
||||
"computer_use",
|
||||
])
|
||||
})
|
||||
|
||||
test("streams sequential Copilot reasoning summary blocks", async () => {
|
||||
const model = createModel(
|
||||
createStreamFetch([
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { LanguageModelV3FunctionTool } from "@ai-sdk/provider"
|
||||
import type {
|
||||
LanguageModelV3CallOptions,
|
||||
LanguageModelV3FunctionTool,
|
||||
LanguageModelV3ProviderTool,
|
||||
} from "@ai-sdk/provider"
|
||||
import { prepareResponsesTools } from "@opencode-ai/core/github-copilot/responses/openai-responses-prepare-tools"
|
||||
|
||||
function prepare(strict: boolean | undefined, strictJsonSchema: boolean) {
|
||||
@@ -18,3 +22,198 @@ test("function tools prefer explicit strictness over the global fallback", () =>
|
||||
expect(prepare(undefined, true)).toMatchObject({ type: "function", strict: true })
|
||||
expect(prepare(undefined, false)).toMatchObject({ type: "function", strict: false })
|
||||
})
|
||||
|
||||
const webTools: LanguageModelV3ProviderTool[] = [
|
||||
{ type: "provider", id: "openai.web_search", name: "current_web", args: {} },
|
||||
{ type: "provider", id: "openai.web_search_preview", name: "preview_web", args: {} },
|
||||
]
|
||||
|
||||
test.each([
|
||||
{ order: webTools, toolChoice: undefined },
|
||||
{ order: webTools.toReversed(), toolChoice: undefined },
|
||||
{ order: webTools, toolChoice: { type: "auto" as const } },
|
||||
{ order: webTools.toReversed(), toolChoice: { type: "auto" as const } },
|
||||
{ order: webTools, toolChoice: { type: "required" as const } },
|
||||
{ order: webTools.toReversed(), toolChoice: { type: "required" as const } },
|
||||
])("rejects differently named web variants before automatic or required selection", ({ order, toolChoice }) => {
|
||||
expect(() => prepareResponsesTools({ tools: order, toolChoice, strictJsonSchema: false })).toThrow(
|
||||
"ambiguous web_search response for hosted tools",
|
||||
)
|
||||
})
|
||||
|
||||
test.each([
|
||||
{ order: webTools, name: "current_web", wireType: "web_search" },
|
||||
{ order: webTools.toReversed(), name: "current_web", wireType: "web_search" },
|
||||
{ order: webTools, name: "preview_web", wireType: "web_search_preview" },
|
||||
{ order: webTools.toReversed(), name: "preview_web", wireType: "web_search_preview" },
|
||||
])("uses the uniquely forced web variant independent of declaration order", ({ order, name, wireType }) => {
|
||||
const result = prepareResponsesTools({
|
||||
tools: order,
|
||||
toolChoice: { type: "tool", toolName: name },
|
||||
strictJsonSchema: false,
|
||||
})
|
||||
|
||||
expect(result.toolChoice).toEqual({ type: wireType })
|
||||
expect(result.selectedHostedTool).toMatchObject({ name, type: wireType, responseType: "web_search" })
|
||||
})
|
||||
|
||||
test.each([{ order: webTools }, { order: webTools.toReversed() }])(
|
||||
"allows indistinguishable web variants when they share one logical name",
|
||||
({ order }) => {
|
||||
const tools = order.map((tool) => ({ ...tool, name: "web" }))
|
||||
const result = prepareResponsesTools({ tools, toolChoice: { type: "required" }, strictJsonSchema: false })
|
||||
|
||||
expect(result.hostedTools.map((tool) => tool.name)).toEqual(["web", "web"])
|
||||
},
|
||||
)
|
||||
|
||||
const duplicateToolCases = [
|
||||
[
|
||||
{ type: "function", name: "lookup", inputSchema: { type: "object" } },
|
||||
{ type: "provider", id: "openai.web_search", name: "lookup", args: {} },
|
||||
],
|
||||
[
|
||||
{ type: "provider", id: "other.unsupported", name: "lookup", args: {} },
|
||||
{ type: "provider", id: "openai.web_search", name: "lookup", args: {} },
|
||||
],
|
||||
[
|
||||
{ type: "provider", id: "openai.web_search", name: "lookup", args: {} },
|
||||
{ type: "provider", id: "openai.web_search_preview", name: "lookup", args: {} },
|
||||
],
|
||||
] satisfies Array<NonNullable<LanguageModelV3CallOptions["tools"]>>
|
||||
|
||||
test.each(duplicateToolCases.flatMap((tools) => [{ tools }, { tools: tools.toReversed() }]))(
|
||||
"rejects duplicate forced definitions independent of type and order",
|
||||
({ tools }) => {
|
||||
expect(() =>
|
||||
prepareResponsesTools({
|
||||
tools,
|
||||
toolChoice: { type: "tool", toolName: "lookup" },
|
||||
strictJsonSchema: false,
|
||||
}),
|
||||
).toThrow("multiple tool definitions share this name")
|
||||
},
|
||||
)
|
||||
|
||||
const duplicateHostedToolCases = [
|
||||
{ id: "openai.web_search", responseType: "web_search", args: {} },
|
||||
{ id: "openai.web_search_preview", responseType: "web_search", args: {} },
|
||||
{ id: "openai.file_search", responseType: "file_search", args: { vectorStoreIds: ["store_1"] } },
|
||||
{ id: "openai.code_interpreter", responseType: "code_interpreter", args: {} },
|
||||
{ id: "openai.image_generation", responseType: "image_generation", args: {} },
|
||||
] as const
|
||||
|
||||
function duplicateHostedTools(testCase: (typeof duplicateHostedToolCases)[number], sameName = false) {
|
||||
return [
|
||||
{ type: "provider" as const, id: testCase.id, name: `${testCase.responseType}_one`, args: testCase.args },
|
||||
{
|
||||
type: "provider" as const,
|
||||
id: testCase.id,
|
||||
name: sameName ? `${testCase.responseType}_one` : `${testCase.responseType}_two`,
|
||||
args: testCase.args,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
test.each(
|
||||
duplicateHostedToolCases.flatMap((testCase) =>
|
||||
[undefined, { type: "auto" as const }, { type: "required" as const }].flatMap((toolChoice) => {
|
||||
const tools = duplicateHostedTools(testCase)
|
||||
return [
|
||||
{ testCase, tools, toolChoice, selection: toolChoice?.type ?? "default" },
|
||||
{ testCase, tools: tools.toReversed(), toolChoice, selection: toolChoice?.type ?? "default" },
|
||||
]
|
||||
}),
|
||||
),
|
||||
)(
|
||||
"rejects differently named duplicate $testCase.id responses for $selection selection",
|
||||
({ testCase, tools, toolChoice }) => {
|
||||
expect(() => prepareResponsesTools({ tools, toolChoice, strictJsonSchema: false })).toThrow(
|
||||
`ambiguous ${testCase.responseType} response for hosted tools`,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
test.each(
|
||||
duplicateHostedToolCases.flatMap((testCase) => {
|
||||
const tools = duplicateHostedTools(testCase, true)
|
||||
return [
|
||||
{ testCase, tools },
|
||||
{ testCase, tools: tools.toReversed() },
|
||||
]
|
||||
}),
|
||||
)("allows duplicate $testCase.id responses with the same logical name", ({ tools }) => {
|
||||
expect(
|
||||
prepareResponsesTools({ tools, toolChoice: { type: "required" }, strictJsonSchema: false }).hostedTools.map(
|
||||
(tool) => tool.name,
|
||||
),
|
||||
).toEqual([tools[0].name, tools[0].name])
|
||||
})
|
||||
|
||||
test.each(
|
||||
duplicateHostedToolCases.flatMap((testCase) => {
|
||||
const tools = duplicateHostedTools(testCase)
|
||||
return [
|
||||
{ testCase, tools },
|
||||
{ testCase, tools: tools.toReversed() },
|
||||
]
|
||||
}),
|
||||
)("rejects a forced $testCase.id wire choice with multiple logical identities", ({ testCase, tools }) => {
|
||||
expect(() =>
|
||||
prepareResponsesTools({
|
||||
tools,
|
||||
toolChoice: { type: "tool", toolName: `${testCase.responseType}_one` },
|
||||
strictJsonSchema: false,
|
||||
}),
|
||||
).toThrow(`ambiguous ${tools[0].id.replace("openai.", "")} tool choice for hosted tools`)
|
||||
})
|
||||
|
||||
test.each(
|
||||
duplicateHostedToolCases.flatMap((testCase) => {
|
||||
const tools = duplicateHostedTools(testCase, true)
|
||||
return [{ tools }, { tools: tools.toReversed() }]
|
||||
}),
|
||||
)("rejects a forced logical name shared by duplicate same-wire definitions", ({ tools }) => {
|
||||
expect(() =>
|
||||
prepareResponsesTools({
|
||||
tools,
|
||||
toolChoice: { type: "tool", toolName: tools[0].name },
|
||||
strictJsonSchema: false,
|
||||
}),
|
||||
).toThrow("multiple tool definitions share this name")
|
||||
})
|
||||
|
||||
test.each([...duplicateHostedToolCases])("skips ambiguous $id responses when tool choice is none", (testCase) => {
|
||||
expect(
|
||||
prepareResponsesTools({
|
||||
tools: duplicateHostedTools(testCase),
|
||||
toolChoice: { type: "none" },
|
||||
strictJsonSchema: false,
|
||||
}).toolChoice,
|
||||
).toBe("none")
|
||||
})
|
||||
|
||||
test.each([...duplicateHostedToolCases])("skips ambiguous $id responses for a uniquely forced function", (testCase) => {
|
||||
expect(
|
||||
prepareResponsesTools({
|
||||
tools: [...duplicateHostedTools(testCase), { type: "function", name: "local", inputSchema: { type: "object" } }],
|
||||
toolChoice: { type: "tool", toolName: "local" },
|
||||
strictJsonSchema: false,
|
||||
}).toolChoice,
|
||||
).toEqual({ type: "function", name: "local" })
|
||||
})
|
||||
|
||||
test.each([{ ambiguousWebTools: duplicateHostedTools(duplicateHostedToolCases[0]) }, { ambiguousWebTools: webTools }])(
|
||||
"validates only the selected wire choice for a forced unrelated hosted tool",
|
||||
({ ambiguousWebTools }) => {
|
||||
const selected = duplicateHostedTools(duplicateHostedToolCases[2], true)[0]
|
||||
const result = prepareResponsesTools({
|
||||
tools: [...ambiguousWebTools, selected],
|
||||
toolChoice: { type: "tool", toolName: selected.name },
|
||||
strictJsonSchema: false,
|
||||
})
|
||||
|
||||
expect(result.toolChoice).toEqual({ type: "file_search" })
|
||||
expect(result.selectedHostedTool).toMatchObject({ name: selected.name, type: "file_search" })
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Cause, Effect, Exit, Fiber } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node])))
|
||||
|
||||
describe("Integration replay", () => {
|
||||
it.effect("fails and closes an OAuth attempt when fresh implementation replay throws", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const integrationID = Integration.ID.make("replay-test")
|
||||
const methodID = Integration.MethodID.make("code")
|
||||
const source = { fail: false, closed: false }
|
||||
const failure = new Error("integration transform replay failed")
|
||||
yield* integrations.transform((editor) => {
|
||||
if (source.fail) throw failure
|
||||
editor.method.update({
|
||||
integrationID,
|
||||
method: { id: methodID, type: "oauth", label: "Fixture" },
|
||||
authorize: () =>
|
||||
Effect.addFinalizer(() => Effect.sync(() => (source.closed = true))).pipe(
|
||||
Effect.as({
|
||||
mode: "code" as const,
|
||||
url: "https://example.com/authorize",
|
||||
instructions: "Enter the fixture code",
|
||||
callback: () =>
|
||||
Effect.succeed(
|
||||
Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID,
|
||||
access: "dummy-access",
|
||||
refresh: "dummy-refresh",
|
||||
expires: Number.MAX_SAFE_INTEGER,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
})
|
||||
})
|
||||
|
||||
const attempt = yield* integrations.oauth.connect({ integrationID, methodID, label: "Fixture" })
|
||||
source.fail = true
|
||||
const reload = yield* integrations.reload().pipe(Effect.exit, Effect.forkChild({ startImmediately: true }))
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.gen(function* () {
|
||||
source.fail = false
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(reload)
|
||||
}),
|
||||
)
|
||||
|
||||
const exit = yield* integrations.oauth
|
||||
.complete({ integrationID, attemptID: attempt.attemptID, code: "dummy-code" })
|
||||
.pipe(Effect.exit)
|
||||
|
||||
expect(exit).toMatchObject(Exit.die(failure))
|
||||
expect(Exit.isFailure(exit) && Cause.squash(exit.cause)).toBe(failure)
|
||||
expect(yield* integrations.oauth.status({ integrationID, attemptID: attempt.attemptID })).toEqual({
|
||||
status: "failed",
|
||||
message: failure.message,
|
||||
time: attempt.time,
|
||||
})
|
||||
expect(source.closed).toBe(true)
|
||||
expect(yield* credentials.list(integrationID)).toEqual([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -7,7 +7,6 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { State } from "@opencode-ai/core/state"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node, Bus.node])))
|
||||
@@ -263,102 +262,6 @@ describe("Integration", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("resolves stored OAuth with refresh registrations made inside a batch", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const integrationID = Integration.ID.make("acme")
|
||||
const method = Integration.OAuthMethod.make({
|
||||
id: Integration.MethodID.make("browser"),
|
||||
type: "oauth",
|
||||
label: "Browser",
|
||||
})
|
||||
const expired = Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: method.id,
|
||||
access: "expired",
|
||||
refresh: "refresh",
|
||||
expires: 0,
|
||||
})
|
||||
const fresh = Credential.OAuth.make({
|
||||
...expired,
|
||||
access: "fresh",
|
||||
refresh: "fresh-refresh",
|
||||
expires: (yield* Clock.currentTimeMillis) + Duration.toMillis(Duration.hours(1)),
|
||||
})
|
||||
const stored = yield* credentials.create({ integrationID, label: "Personal", value: expired })
|
||||
const connection = { type: "credential" as const, id: stored.id, label: stored.label }
|
||||
const calls: string[] = []
|
||||
const implementation = {
|
||||
integrationID,
|
||||
method,
|
||||
authorize: () => Effect.die("unexpected authorization"),
|
||||
refresh: (value: Credential.OAuth) =>
|
||||
Effect.sync(() => {
|
||||
expect(value).toEqual(expired)
|
||||
calls.push("original")
|
||||
return fresh
|
||||
}),
|
||||
}
|
||||
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* integrations.transform((editor) => editor.method.update(implementation))
|
||||
expect(yield* integrations.connection.resolve(connection)).toEqual(fresh)
|
||||
expect((yield* credentials.get(stored.id))?.value).toEqual(fresh)
|
||||
expect(calls).toEqual(["original"])
|
||||
|
||||
expect(yield* integrations.connection.resolve(connection)).toEqual(fresh)
|
||||
expect(calls).toEqual(["original"])
|
||||
|
||||
yield* credentials.update(stored.id, { value: expired })
|
||||
const overridden = Credential.OAuth.make({ ...fresh, access: "override" })
|
||||
const override = yield* integrations.transform((editor) =>
|
||||
editor.method.update({
|
||||
...implementation,
|
||||
refresh: (value) =>
|
||||
Effect.sync(() => {
|
||||
expect(value).toEqual(expired)
|
||||
calls.push("override")
|
||||
return overridden
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(yield* integrations.connection.resolve(connection)).toEqual(overridden)
|
||||
expect((yield* credentials.get(stored.id))?.value).toEqual(overridden)
|
||||
|
||||
yield* override.dispose
|
||||
yield* credentials.update(stored.id, { value: expired })
|
||||
expect(yield* integrations.connection.resolve(connection)).toEqual(fresh)
|
||||
expect(calls).toEqual(["original", "override", "original"])
|
||||
|
||||
yield* credentials.update(stored.id, { value: expired })
|
||||
const removal = yield* integrations.transform((editor) => editor.method.remove(integrationID, method))
|
||||
expect(yield* integrations.connection.resolve(connection)).toEqual(expired)
|
||||
expect((yield* credentials.get(stored.id))?.value).toEqual(expired)
|
||||
expect(calls).toEqual(["original", "override", "original"])
|
||||
|
||||
yield* removal.dispose
|
||||
expect(yield* integrations.connection.resolve(connection)).toEqual(fresh)
|
||||
yield* credentials.update(stored.id, { value: expired })
|
||||
yield* integrations.transform((editor) => editor.method.update({ ...implementation, refresh: undefined }))
|
||||
expect(yield* integrations.connection.resolve(connection)).toEqual(expired)
|
||||
expect((yield* credentials.get(stored.id))?.value).toEqual(expired)
|
||||
expect(calls).toEqual(["original", "override", "original", "original"])
|
||||
|
||||
const failure = new Error("refresh failed")
|
||||
yield* integrations.transform((editor) =>
|
||||
editor.method.update({ ...implementation, refresh: () => Effect.fail(failure) }),
|
||||
)
|
||||
expect(yield* integrations.connection.resolve(connection).pipe(Effect.flip)).toEqual(
|
||||
new Integration.AuthorizationError({ cause: failure }),
|
||||
)
|
||||
expect((yield* credentials.get(stored.id))?.value).toEqual(expired)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("completes code OAuth once and stores the credential", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
|
||||
@@ -33,31 +33,16 @@ import { McpStdio } from "@opencode-ai/core/mcp/stdio"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { State } from "@opencode-ai/core/state"
|
||||
import { McpTool } from "@opencode-ai/core/tool/mcp"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import {
|
||||
Context,
|
||||
Deferred,
|
||||
Effect,
|
||||
Exit,
|
||||
Fiber,
|
||||
Layer,
|
||||
PubSub,
|
||||
Ref,
|
||||
Schedule,
|
||||
Schema,
|
||||
Scope,
|
||||
Sink,
|
||||
Stream,
|
||||
} from "effect"
|
||||
import { Deferred, Effect, Exit, Fiber, Layer, PubSub, Ref, Schedule, Schema, Sink, Stream } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { ExitCode, makeHandle, ProcessId } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import { Image } from "@opencode-ai/core/image"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { imagePassthrough } from "./lib/image"
|
||||
import { location, locationLayer } from "./fixture/location"
|
||||
import { location } from "./fixture/location"
|
||||
import { hostEnvironmentLayer, recordingEnvironmentLayer } from "./fixture/environment"
|
||||
import { executeTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
|
||||
|
||||
@@ -82,7 +67,7 @@ function resourceServer(
|
||||
listChanged?: boolean
|
||||
emptyElicitation?: boolean
|
||||
urlElicitation?: boolean
|
||||
respond?: (request: Request) => Response | undefined | Promise<Response | undefined>
|
||||
respond?: (request: Request) => Response | undefined
|
||||
} = {},
|
||||
) {
|
||||
return Effect.acquireRelease(
|
||||
@@ -191,7 +176,7 @@ function resourceServer(
|
||||
if (typeof body === "object" && body !== null && "method" in body && body.method === "initialize") {
|
||||
state.initializations += 1
|
||||
}
|
||||
return (await input.respond?.(request)) ?? transport.handleRequest(request)
|
||||
return input.respond?.(request) ?? transport.handleRequest(request)
|
||||
},
|
||||
})
|
||||
return {
|
||||
@@ -1426,126 +1411,6 @@ test("reconciles only changed MCP server config", async () => {
|
||||
)
|
||||
})
|
||||
|
||||
testEffect(Layer.empty).live("serializes MCP config restoration behind an in-flight replacement", () =>
|
||||
Effect.gen(function* () {
|
||||
const started = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const accepted = yield* Deferred.make<void>()
|
||||
const server = yield* resourceServer({
|
||||
respond: (request) =>
|
||||
request.method !== "POST"
|
||||
? undefined
|
||||
: Effect.runPromise(
|
||||
Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release)), Effect.as(undefined)),
|
||||
),
|
||||
})
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const service = yield* Mcp.Service
|
||||
expect((yield* service.servers())[0]?.status).toEqual({ status: "disabled" })
|
||||
const replacing = yield* service
|
||||
.transform((draft) => draft.update("resources", (config) => (config.disabled = false)))
|
||||
.pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Deferred.await(started)
|
||||
|
||||
const restoring = yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* service.transform((draft) => draft.update("resources", (config) => (config.disabled = true)))
|
||||
yield* Deferred.succeed(accepted, undefined)
|
||||
}),
|
||||
).pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Deferred.await(accepted)
|
||||
expect((yield* service.servers())[0]?.status).toEqual({ status: "pending" })
|
||||
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(replacing)
|
||||
yield* Fiber.join(restoring)
|
||||
expect((yield* service.servers())[0]?.status).toEqual({ status: "disabled" })
|
||||
expect(yield* service.tools()).toEqual([])
|
||||
expect(server.state.initializations).toBe(1)
|
||||
}).pipe(
|
||||
Effect.ensuring(Deferred.succeed(release, undefined)),
|
||||
Effect.provide(
|
||||
resourceMcpLayer(new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false, disabled: true })),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
const shutdownIt = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Bus.node, Integration.node, Credential.node, Form.node, Environment.node, Location.node]),
|
||||
[
|
||||
[Location.node, locationLayer({ directory: AbsolutePath.make(import.meta.dir) })],
|
||||
[Environment.node, hostEnvironmentLayer],
|
||||
],
|
||||
),
|
||||
)
|
||||
;["active", "queued"].forEach((phase) =>
|
||||
shutdownIt.effect(`discards ${phase} MCP notifications after its layer closes`, () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const entered = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const root = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Deferred.succeed(release, undefined).pipe(
|
||||
Effect.andThen(State.batch(Scope.close(root, Exit.void), { flush: false })),
|
||||
Effect.andThen(TestClock.adjust("500 millis")),
|
||||
),
|
||||
)
|
||||
const context = yield* Layer.buildWithScope(Mcp.layer(), root)
|
||||
const service = Context.get(context, Mcp.Service)
|
||||
const observed: string[] = []
|
||||
let block = false
|
||||
const unsubscribe = yield* bus.listen((event) =>
|
||||
Effect.gen(function* () {
|
||||
if (event.type !== McpEvent.StatusChanged.type) return
|
||||
observed.push(Schema.decodeUnknownSync(McpEvent.StatusChanged.data)(event.data).server)
|
||||
if (!block) return
|
||||
block = false
|
||||
yield* Deferred.succeed(entered, undefined)
|
||||
yield* Deferred.await(release)
|
||||
}),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
const source = { url: "https://example.com/initial", added: false }
|
||||
yield* service
|
||||
.transform((draft) => {
|
||||
draft.set("fixture", { type: "remote", url: source.url, oauth: false, disabled: true })
|
||||
if (source.added) draft.set("queued", { type: "local", command: ["unused"], disabled: true })
|
||||
})
|
||||
.pipe(Scope.provide(root))
|
||||
|
||||
block = true
|
||||
source.url = "https://example.com/first"
|
||||
source.added = phase === "active"
|
||||
const first = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Deferred.await(entered)
|
||||
source.url = "https://example.com/second"
|
||||
source.added = true
|
||||
const second = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("500 millis")
|
||||
|
||||
const shutdown = yield* State.batch(Scope.close(root, Exit.void), { flush: false }).pipe(
|
||||
Effect.forkChild({ startImmediately: true }),
|
||||
)
|
||||
yield* TestClock.adjust("1 millis")
|
||||
expect(shutdown.pollUnsafe()).toBeDefined()
|
||||
expect(first.pollUnsafe()).toBeDefined()
|
||||
expect(second.pollUnsafe()).toBeDefined()
|
||||
expect(yield* Deferred.isDone(release)).toBe(false)
|
||||
yield* Fiber.join(shutdown)
|
||||
observed.length = 0
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(first)
|
||||
yield* Fiber.join(second)
|
||||
expect(observed).toEqual([])
|
||||
expect((yield* service.servers()).map((server) => server.name)).toEqual([Mcp.ServerName.make("fixture")])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
test("serializes concurrent MCP lifecycle operations", async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Permission } from "@opencode-ai/core/permission"
|
||||
import { PermissionTable } from "@opencode-ai/core/permission/sql"
|
||||
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import type { PermissionEvaluation } from "@opencode-ai/plugin/effect/permission"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
@@ -40,7 +41,7 @@ const it = testEffect(
|
||||
),
|
||||
)
|
||||
|
||||
function setup(rules: Permission.Ruleset = []) {
|
||||
function setup(rules: Permission.Ruleset = [], sessionID = Session.ID.make("ses_test")) {
|
||||
return Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
@@ -52,7 +53,7 @@ function setup(rules: Permission.Ruleset = []) {
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: Session.ID.make("ses_test"),
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "test",
|
||||
directory: "/project",
|
||||
@@ -88,18 +89,19 @@ function assertion(input: Partial<Permission.AssertInput> = {}) {
|
||||
} satisfies Permission.AssertInput
|
||||
}
|
||||
|
||||
function waitForRequest() {
|
||||
function waitForRequest(input: Partial<Permission.AssertInput> = {}) {
|
||||
return Effect.gen(function* () {
|
||||
const value = assertion(input)
|
||||
const service = yield* Permission.Service
|
||||
const bus = yield* Bus.Service
|
||||
const asked = yield* Deferred.make<Permission.Request>()
|
||||
const unsubscribe = yield* bus.listen((event) =>
|
||||
event.type === Permission.Event.Asked.type
|
||||
? Deferred.succeed(asked, event.data as Permission.Request).pipe(Effect.asVoid)
|
||||
: Effect.void,
|
||||
)
|
||||
const unsubscribe = yield* bus.listen((event) => {
|
||||
if (event.type !== Permission.Event.Asked.type) return Effect.void
|
||||
const request = event.data as Permission.Request
|
||||
return request.id === value.id ? Deferred.succeed(asked, request).pipe(Effect.asVoid) : Effect.void
|
||||
})
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
const fiber = yield* service.assert(assertion()).pipe(Effect.forkScoped)
|
||||
const fiber = yield* service.assert(value).pipe(Effect.forkScoped)
|
||||
const request = yield* Deferred.await(asked)
|
||||
return { service, fiber, request }
|
||||
})
|
||||
@@ -383,6 +385,103 @@ describe("Permission", () => {
|
||||
expect(yield* saved.list()).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
for (const effect of ["ask", "deny", "allow"] as const) {
|
||||
it.effect(`reevaluates pending requests with hooks after always: ${effect}`, () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup()
|
||||
yield* setup([], Session.ID.make("ses_other"))
|
||||
const agents = yield* Agent.Service
|
||||
yield* agents.transform((editor) =>
|
||||
editor.update(Agent.ID.make("reviewer"), (agent) => {
|
||||
agent.permissions = []
|
||||
}),
|
||||
)
|
||||
const context = {
|
||||
sessionID: Session.ID.make("ses_other"),
|
||||
agent: Agent.ID.make("reviewer"),
|
||||
action: "read",
|
||||
resources: ["src/protected.ts", "src/private.ts"],
|
||||
metadata: { purpose: "protected" },
|
||||
source: { type: "tool", messageID: "msg_other", id: "call_other" },
|
||||
} satisfies Permission.AssertInput
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const seen: PermissionEvaluation[] = []
|
||||
yield* hooks.register("permission", "evaluate", (event) =>
|
||||
Effect.sync(() => {
|
||||
seen.push({ ...event })
|
||||
if (event.effect === "allow") event.effect = effect
|
||||
}),
|
||||
)
|
||||
const selected = yield* waitForRequest({ save: ["src/*"] })
|
||||
const other = yield* waitForRequest({ id: Permission.ID.create("per_other"), ...context })
|
||||
expect(yield* selected.service.list()).toEqual([selected.request, other.request])
|
||||
|
||||
yield* selected.service.reply({ requestID: selected.request.id, reply: "always" })
|
||||
yield* Fiber.join(selected.fiber)
|
||||
expect(yield* selected.service.list()).toEqual(effect === "allow" ? [] : [other.request])
|
||||
expect(seen).toMatchObject([
|
||||
{ sessionID: selected.request.sessionID, effect: "ask" },
|
||||
{ ...context, effect: "ask" },
|
||||
{ ...context, effect: "allow" },
|
||||
])
|
||||
if (effect !== "allow") {
|
||||
expect(other.fiber.pollUnsafe()).toBeUndefined()
|
||||
yield* other.service.reply({ requestID: other.request.id, reply: "once" })
|
||||
}
|
||||
yield* Fiber.join(other.fiber)
|
||||
expect(yield* selected.service.list()).toEqual([])
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
for (const guard of ["configured deny", "missing Session"] as const) {
|
||||
it.effect(`skips pending auto-approval after always for ${guard}`, () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup()
|
||||
yield* setup([], Session.ID.make("ses_other"))
|
||||
const agents = yield* Agent.Service
|
||||
yield* agents.transform((editor) =>
|
||||
editor.update(Agent.ID.make("reviewer"), (agent) => {
|
||||
agent.permissions = []
|
||||
}),
|
||||
)
|
||||
const selected = yield* waitForRequest({ save: ["src/*"] })
|
||||
const other = yield* waitForRequest({
|
||||
id: Permission.ID.create("per_other"),
|
||||
sessionID: Session.ID.make("ses_other"),
|
||||
agent: Agent.ID.make("reviewer"),
|
||||
})
|
||||
if (guard === "configured deny") {
|
||||
yield* agents.transform((editor) =>
|
||||
editor.update(Agent.ID.make("reviewer"), (agent) => {
|
||||
agent.permissions = [{ action: "read", resource: "*", effect: "deny" }]
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (guard === "missing Session") {
|
||||
const { db } = yield* Database.Service
|
||||
yield* db.delete(SessionTable).where(eq(SessionTable.id, other.request.sessionID)).run().pipe(Effect.orDie)
|
||||
}
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const seen: PermissionEvaluation[] = []
|
||||
yield* hooks.register("permission", "evaluate", (event) =>
|
||||
Effect.sync(() => {
|
||||
seen.push({ ...event })
|
||||
event.effect = "allow"
|
||||
}),
|
||||
)
|
||||
|
||||
yield* selected.service.reply({ requestID: selected.request.id, reply: "always" })
|
||||
yield* Fiber.join(selected.fiber)
|
||||
expect(yield* selected.service.list()).toEqual([other.request])
|
||||
expect(other.fiber.pollUnsafe()).toBeUndefined()
|
||||
expect(seen).toEqual([])
|
||||
yield* Fiber.interrupt(other.fiber)
|
||||
expect(yield* selected.service.list()).toEqual([])
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
describe("shell scanner permission impact", () => {
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Clock, Context, Duration, Effect, Exit, Fiber, Schema, Stream } from "effect"
|
||||
import { Context, Effect, Exit, Fiber, Schema, Stream } from "effect"
|
||||
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
|
||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||
@@ -105,64 +103,6 @@ describe("Plugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("refreshes its own stored OAuth connection during plugin activation", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const integrationID = Integration.ID.make("acme")
|
||||
const methodID = Integration.MethodID.make("browser")
|
||||
const expired = Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID,
|
||||
access: "expired",
|
||||
refresh: "refresh",
|
||||
expires: 0,
|
||||
})
|
||||
const fresh = Credential.OAuth.make({
|
||||
...expired,
|
||||
access: "fresh",
|
||||
refresh: "fresh-refresh",
|
||||
expires: (yield* Clock.currentTimeMillis) + Duration.toMillis(Duration.hours(1)),
|
||||
})
|
||||
const stored = yield* credentials.create({ integrationID, label: "Personal", value: expired })
|
||||
const resolved: (Credential.Value | undefined)[] = []
|
||||
const refreshed: Credential.OAuth[] = []
|
||||
|
||||
yield* plugins.activate([
|
||||
versioned(
|
||||
EffectPlugin.define({
|
||||
id: "oauth-refresh",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.integration.transform((editor) =>
|
||||
editor.method.update({
|
||||
integrationID,
|
||||
method: { id: methodID, type: "oauth", label: "Browser" },
|
||||
authorize: () => Effect.die("unexpected authorization"),
|
||||
refresh: (value) =>
|
||||
Effect.sync(() => {
|
||||
refreshed.push(value)
|
||||
return fresh
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const connection = yield* ctx.integration.connection.active(integrationID)
|
||||
if (!connection) return yield* Effect.die("stored connection missing")
|
||||
resolved.push(yield* ctx.integration.connection.resolve(connection).pipe(Effect.orDie))
|
||||
}),
|
||||
}),
|
||||
),
|
||||
])
|
||||
|
||||
expect(resolved).toEqual([fresh])
|
||||
expect(refreshed).toEqual([expired])
|
||||
expect((yield* credentials.get(stored.id))?.value).toEqual(fresh)
|
||||
expect(yield* plugins.list()).toEqual([
|
||||
{ id: Plugin.ID.make("oauth-refresh"), source: { type: "builtin" }, status: "active", tui: false },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("exposes public events through the plugin context", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
|
||||
@@ -129,6 +129,7 @@ export function host(overrides: Overrides = {}): Plugin.Context {
|
||||
hook: () => Effect.die("unused tool.hook"),
|
||||
},
|
||||
vcs: overrides.vcs ?? {
|
||||
base: () => Effect.die("unused vcs.base"),
|
||||
get: () => Effect.die("unused vcs.get"),
|
||||
branches: () => Effect.die("unused vcs.branches"),
|
||||
status: () => Effect.die("unused vcs.status"),
|
||||
|
||||
@@ -3,8 +3,6 @@ import { Message, SystemPart } from "@opencode-ai/ai"
|
||||
import { DateTime, Effect, Schema } from "effect"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
@@ -29,7 +27,7 @@ import { Pty } from "@opencode-ai/schema/pty"
|
||||
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
import { host as testHost } from "./host"
|
||||
import { host } from "./host"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
@@ -44,7 +42,7 @@ describe("fromPromise", () => {
|
||||
foregroundProcess: "bun",
|
||||
screen: { text: "one\ntwo\nthree", cols: 80, rows: 2, cursor: { x: 3, y: 1 } },
|
||||
})
|
||||
const host = testHost({
|
||||
const context = host({
|
||||
experimental: {
|
||||
terminal: {
|
||||
read: (input) => {
|
||||
@@ -74,7 +72,7 @@ describe("fromPromise", () => {
|
||||
await ctx.experimental.terminal.read({ sessionID: "ses_terminal", lines: 65535 })
|
||||
},
|
||||
}),
|
||||
).effect(host)
|
||||
).effect(context)
|
||||
|
||||
expect(seen).toEqual([
|
||||
{ sessionID: Session.ID.make("ses_terminal") },
|
||||
@@ -87,7 +85,7 @@ describe("fromPromise", () => {
|
||||
|
||||
it.effect("preserves null terminal reads and rejects daemon failures", () =>
|
||||
Effect.gen(function* () {
|
||||
const host = testHost({
|
||||
const context = host({
|
||||
experimental: {
|
||||
terminal: {
|
||||
read: (input) =>
|
||||
@@ -108,60 +106,7 @@ describe("fromPromise", () => {
|
||||
)
|
||||
},
|
||||
}),
|
||||
).effect(host)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("refreshes its own stored OAuth connection during plugin activation", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const integrationID = Integration.ID.make("acme")
|
||||
const methodID = Integration.MethodID.make("browser")
|
||||
const expired = Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID,
|
||||
access: "expired",
|
||||
refresh: "dummy",
|
||||
expires: 0,
|
||||
})
|
||||
const fresh = Credential.OAuth.make({ ...expired, access: "fresh", expires: Number.MAX_SAFE_INTEGER })
|
||||
const stored = yield* credentials.create({ integrationID, label: "Fixture", value: expired })
|
||||
const resolved: string[] = []
|
||||
const refreshed: string[] = []
|
||||
const adapted = PluginPromise.fromPromise(
|
||||
define({
|
||||
id: "promise-oauth-refresh",
|
||||
setup: async (ctx) => {
|
||||
await ctx.integration.transform((editor) =>
|
||||
editor.method.update({
|
||||
integrationID,
|
||||
method: { id: methodID, type: "oauth", label: "Browser" },
|
||||
authorize: async () => {
|
||||
throw new Error("unexpected authorization")
|
||||
},
|
||||
refresh: async (value) => {
|
||||
refreshed.push(value.access)
|
||||
return fresh
|
||||
},
|
||||
}),
|
||||
)
|
||||
const connection = await ctx.integration.connection.active(integrationID)
|
||||
if (!connection) throw new Error("stored connection missing")
|
||||
const value = await ctx.integration.connection.resolve(connection)
|
||||
resolved.push(value?.type === "oauth" ? value.access : "missing")
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
yield* plugins.activate([{ ...adapted, version: "1" }])
|
||||
|
||||
expect(resolved).toEqual(["fresh"])
|
||||
expect(refreshed).toEqual(["expired"])
|
||||
expect((yield* credentials.get(stored.id))?.value).toEqual(fresh)
|
||||
expect(yield* plugins.list()).toEqual([
|
||||
{ id: Plugin.ID.make("promise-oauth-refresh"), source: { type: "builtin" }, status: "active", tui: false },
|
||||
])
|
||||
).effect(context)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -229,7 +174,7 @@ describe("fromPromise", () => {
|
||||
it.effect("adapts session creation through the protocol schema", () =>
|
||||
Effect.gen(function* () {
|
||||
let seen: unknown
|
||||
const host = testHost({
|
||||
const context = host({
|
||||
session: {
|
||||
create: (input) => {
|
||||
seen = input
|
||||
@@ -267,7 +212,7 @@ describe("fromPromise", () => {
|
||||
})
|
||||
},
|
||||
}),
|
||||
).effect(host)
|
||||
).effect(context)
|
||||
|
||||
expect(seen).toEqual({ title: "Promise title" })
|
||||
}),
|
||||
@@ -275,7 +220,7 @@ describe("fromPromise", () => {
|
||||
|
||||
it.effect("forwards transient session generation", () =>
|
||||
Effect.gen(function* () {
|
||||
const host = testHost({
|
||||
const context = host({
|
||||
session: {
|
||||
generate: (input) => Effect.succeed({ text: `${input.sessionID}: ${input.prompt}` }),
|
||||
},
|
||||
@@ -290,14 +235,14 @@ describe("fromPromise", () => {
|
||||
})
|
||||
},
|
||||
}),
|
||||
).effect(host)
|
||||
).effect(context)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves interrupt results and rejected Promise behavior", () =>
|
||||
Effect.gen(function* () {
|
||||
const seen: unknown[] = []
|
||||
const host = testHost({
|
||||
const context = host({
|
||||
session: {
|
||||
interrupt: (input) => {
|
||||
if (input.sessionID === Session.ID.make("ses_failure")) {
|
||||
@@ -336,7 +281,7 @@ describe("fromPromise", () => {
|
||||
expect(await ctx.session.wait({ sessionID: "ses_success" })).toBeUndefined()
|
||||
},
|
||||
}),
|
||||
).effect(host)
|
||||
).effect(context)
|
||||
|
||||
expect(seen).toEqual([
|
||||
{ sessionID: Session.ID.make("ses_success"), agent: Agent.ID.make("build") },
|
||||
@@ -367,7 +312,7 @@ describe("fromPromise", () => {
|
||||
resume: null,
|
||||
}
|
||||
let seen: unknown
|
||||
const host = testHost({
|
||||
const context = host({
|
||||
session: {
|
||||
synthetic: (value) => {
|
||||
seen = value
|
||||
@@ -395,7 +340,7 @@ describe("fromPromise", () => {
|
||||
await ctx.session.synthetic(input)
|
||||
},
|
||||
}),
|
||||
).effect(host)
|
||||
).effect(context)
|
||||
|
||||
expect(seen).toEqual({
|
||||
...input,
|
||||
@@ -622,7 +567,7 @@ describe("fromPromise", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("registers a Promise VCS provider and forwards client reads", () =>
|
||||
it.effect("registers a Promise VCS provider and preserves its receiver when forwarding client reads", () =>
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
@@ -639,6 +584,11 @@ describe("fromPromise", () => {
|
||||
signals.push(request.signal)
|
||||
return { branch: { current: "feature", default: "main" } }
|
||||
},
|
||||
async base(_input, request) {
|
||||
expect(this.id).toBe("custom")
|
||||
signals.push(request.signal)
|
||||
return { name: "main", ref: "refs/heads/main", source: "default" }
|
||||
},
|
||||
branches: async (input, request) => {
|
||||
signals.push(request.signal)
|
||||
expect(input.search).toBe("feat")
|
||||
@@ -659,6 +609,7 @@ describe("fromPromise", () => {
|
||||
})
|
||||
|
||||
expect((await ctx.vcs.get()).data.branch.current).toBe("feature")
|
||||
expect((await ctx.vcs.base()).data).toEqual({ name: "main", ref: "refs/heads/main", source: "default" })
|
||||
expect((await ctx.vcs.branches({ search: "feat" })).data).toEqual(["feature"])
|
||||
expect((await ctx.vcs.status()).data).toHaveLength(1)
|
||||
expect((await ctx.vcs.diff({ mode: "working", context: 2 })).data[0].patch).toBe("+hello")
|
||||
@@ -667,7 +618,7 @@ describe("fromPromise", () => {
|
||||
|
||||
yield* PluginPromise.fromPromise(promisePlugin).effect(host)
|
||||
expect((yield* vcs.info()).branch.current).toBe("feature")
|
||||
expect(signals).toHaveLength(4)
|
||||
expect(signals).toHaveLength(5)
|
||||
expect(signals.every((signal) => signal instanceof AbortSignal)).toBeTrue()
|
||||
}),
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user