Compare commits

..

1 Commits

Author SHA1 Message Date
Luke Parker 67e4c6916b fix(ui): accumulate page key scrolling
Replace Chromium smooth scrolling for PageUp and PageDown with a short owned tween. Repeated key presses now extend the active target instead of cancelling and restarting browser easing.
2026-08-07 06:37:27 +00:00
121 changed files with 1710 additions and 5061 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

-1
View File
@@ -395,7 +395,6 @@
"ignore": "7.0.5",
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
"mime-types": "3.0.2",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"turndown": "7.2.0",
+5 -37
View File
@@ -25,20 +25,8 @@ import { ToolSchemaProjection } from "./utils/tool-schema"
const ADAPTER = "gemini"
const MEDIA_MIMES = new Set<string>(ProviderShared.MEDIA_MIMES)
// Google documents this sentinel for replaying Gemini 3 function calls after their original signature was lost.
const SKIP_THOUGHT_SIGNATURE_VALIDATOR = "skip_thought_signature_validator"
export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
// Gemini 3 rejects replayed function calls without a thought signature. Google's SDKs avoid that in normal chats by
// retaining complete model responses, but OpenCode reconstructs durable history and may encounter an unsigned call
// from an older or external session. Model IDs are open-ended, so unknown Gemini aliases inherit current behavior.
const requiresThoughtSignatureFallback = (modelID: string) => {
if (!/(^|\/)gemini-/i.test(modelID)) return false
if (/(^|\/)gemini-(?:1|2)(?:[.-]|$)/i.test(modelID)) return false
if (/(^|\/)gemini-pro(?:-vision)?$/i.test(modelID)) return false
return !/(^|\/)gemini-robotics-er-1\.5(?:[.-]|$)/i.test(modelID)
}
export interface OptionsInput {
readonly [key: string]: unknown
readonly cachedContent?: string
@@ -157,9 +145,6 @@ const GeminiGenerationConfig = Schema.Struct({
temperature: Schema.optional(Schema.Number),
topP: Schema.optional(Schema.Number),
topK: Schema.optional(Schema.Number),
frequencyPenalty: Schema.optional(Schema.Number),
presencePenalty: Schema.optional(Schema.Number),
seed: Schema.optional(Schema.Number),
stopSequences: optionalArray(Schema.String),
thinkingConfig: Schema.optional(GeminiThinkingConfig),
})
@@ -217,13 +202,11 @@ interface ParserState {
// keys on non-object scalars. Mirrors OpenCode's historical Gemini rules.
//
// 2. Project — lossy mapping from JSON Schema to Gemini's schema dialect:
// drop empty root parameter schemas while preserving nested empty objects,
// expand type arrays into `anyOf`, derive `nullable: true` from null members,
// coerce `const` to `[const]` enum, recurse properties/items, and propagate
// drop empty objects, derive `nullable: true` from `type: [..., "null"]`,
// coerce `const` to `[const]` enum, recurse properties/items, propagate
// only an allowlisted set of keys (description, required, format, type,
// nullable, enum, properties, items, allOf, anyOf, oneOf, minLength).
// Anything outside the allowlist (e.g. `additionalProperties`, `$ref`) is
// silently dropped.
// properties, items, allOf, anyOf, oneOf, minLength). Anything outside the
// allowlist (e.g. `additionalProperties`, `$ref`) is silently dropped.
//
// Sanitize runs first, then project. The implementation lives in
// `utils/gemini-tool-schema` so this protocol keeps the same shape as the other
@@ -299,8 +282,6 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
if (message.role === "assistant") {
const parts: Array<Schema.Schema.Type<typeof GeminiContentPart>> = []
// Parallel Gemini 3 calls may carry one signature on the first call; unsigned sibling calls are valid.
let hasSignedToolCall = false
for (const part of message.content) {
if (!ProviderShared.supportsContent(part, ["text", "reasoning", "tool-call"]))
return yield* ProviderShared.unsupportedContent("Gemini", "assistant", ["text", "reasoning", "tool-call"])
@@ -313,17 +294,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
continue
}
if (part.type === "tool-call") {
const lowered = lowerToolCall(part)
const signature = lowered.thoughtSignature
parts.push({
...lowered,
thoughtSignature:
signature ??
(requiresThoughtSignatureFallback(request.model.id) && !hasSignedToolCall
? SKIP_THOUGHT_SIGNATURE_VALIDATOR
: undefined),
})
if (signature !== undefined) hasSignedToolCall = true
parts.push(lowerToolCall(part))
continue
}
}
@@ -417,9 +388,6 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
temperature: generation?.temperature,
topP: generation?.topP,
topK: generation?.topK,
frequencyPenalty: generation?.frequencyPenalty,
presencePenalty: generation?.presencePenalty,
seed: generation?.seed,
stopSequences: generation?.stop,
thinkingConfig: options.thinkingConfig,
}
+49 -106
View File
@@ -90,15 +90,10 @@ const OpenResponsesFunctionCallOutput = Schema.Union([
])
export const InputItem = Schema.Union([
Schema.Struct({ role: Schema.tag("system"), id: Schema.optionalKey(Schema.String), content: Schema.String }),
Schema.Struct({
role: Schema.tag("user"),
id: Schema.optionalKey(Schema.String),
content: Schema.Array(OpenResponsesInputContent),
}),
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenResponsesInputContent) }),
Schema.Struct({
role: Schema.tag("assistant"),
id: Schema.optionalKey(Schema.String),
content: Schema.Array(OpenResponsesOutputText),
phase: Schema.optionalKey(MessagePhase),
}),
@@ -106,23 +101,19 @@ export const InputItem = Schema.Union([
OpenResponsesItemReference,
Schema.Struct({
type: Schema.tag("function_call"),
id: Schema.optionalKey(Schema.String),
call_id: Schema.String,
name: Schema.String,
arguments: Schema.String,
}),
Schema.Struct({
type: Schema.tag("function_call_output"),
id: Schema.optionalKey(Schema.String),
call_id: Schema.String,
output: OpenResponsesFunctionCallOutput,
}),
])
type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
type ProviderInputItem = Readonly<Record<string, unknown>> & { readonly type: string; readonly id?: string }
type LoweredInputItem =
| OpenResponsesInputItem
| ProviderInputItem
| {
readonly role: "assistant"
readonly content: ReadonlyArray<{ readonly type: "output_text"; readonly text: string }>
@@ -137,7 +128,7 @@ type OpenResponsesReasoningInput = {
summary: Array<{ type: "summary_text"; text: string }>
encrypted_content?: string | null
}
type OpenResponsesReasoningReplay = Omit<OpenResponsesReasoningInput, "id"> & { id?: string }
type OpenResponsesReasoningReplay = Omit<OpenResponsesReasoningInput, "id">
export const Tool = Schema.Struct({
type: Schema.tag("function"),
@@ -263,11 +254,6 @@ export interface Extension {
readonly request: LLMRequest
}) => MediaInput | undefined
readonly messagePhase?: (value: unknown) => MessagePhase | null | undefined
readonly lowerProviderItem?: (
part: ToolResultPart,
providerMetadataKey: string,
store: boolean | undefined,
) => ProviderInputItem | undefined
}
const BASE: Extension = { id: ADAPTER, name: NAME }
@@ -324,17 +310,6 @@ export const lowerToolChoice = (protocolName: string, toolChoice: NonNullable<LL
tool: (toolName) => ({ type: "function" as const, name: toolName }),
})
const metadataItemID = (
part: { readonly itemId?: string; readonly providerMetadata?: ProviderMetadata },
providerMetadataKey: string,
) => {
if (part.itemId) return part.itemId
const metadata = part.providerMetadata?.[providerMetadataKey]
return ProviderShared.isRecord(metadata) && typeof metadata.itemId === "string" && metadata.itemId.length > 0
? metadata.itemId
: undefined
}
const lowerToolCall = (part: ToolCallPart): OpenResponsesInputItem => ({
type: "function_call",
call_id: part.id,
@@ -344,23 +319,26 @@ const lowerToolCall = (part: ToolCallPart): OpenResponsesInputItem => ({
const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => {
const metadata = part.providerMetadata?.[providerMetadataKey]
const itemId = metadataItemID(part, providerMetadataKey)
if (!itemId) return undefined
if (!ProviderShared.isRecord(metadata) || typeof metadata.itemId !== "string" || metadata.itemId.length === 0)
return undefined
const encryptedContent =
ProviderShared.isRecord(metadata) &&
(typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null)
typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null
? metadata.reasoningEncryptedContent
: undefined
return {
type: "reasoning",
id: itemId,
id: metadata.itemId,
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
encrypted_content: encryptedContent,
}
}
const hostedToolItemID = (part: ToolResultPart, providerMetadataKey: string) =>
metadataItemID(part, providerMetadataKey)
const hostedToolItemID = (part: ToolResultPart, providerMetadataKey: string) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
return ProviderShared.isRecord(metadata) && typeof metadata.itemId === "string" && metadata.itemId.length > 0
? metadata.itemId
: undefined
}
const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
part: MediaPart,
@@ -419,18 +397,17 @@ const lowerToolResultOutput = Effect.fn("OpenResponses.lowerToolResultOutput")(f
})
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (request: LLMRequest, extension: Extension) {
const options = OpenResponsesOptions.resolve(request)
const system: LoweredInputItem[] =
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
const input: LoweredInputItem[] = [...system]
const store = options.store
const store = OpenResponsesOptions.resolve(request).store
const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses"
for (const message of request.messages) {
if (message.role === "system") {
const part = yield* ProviderShared.wrappedSystemUpdate(extension.name, message)
const previous = input.at(-1)
if (previous && "role" in previous && previous.role === "user" && Array.isArray(previous.content))
if (previous && "role" in previous && previous.role === "user")
input[input.length - 1] = {
role: "user",
content: [...previous.content, { type: "input_text", text: part.text }],
@@ -450,24 +427,24 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
if (message.role === "assistant") {
const content: TextPart[] = []
const reasoningItems: Record<string, OpenResponsesReasoningReplay> = {}
const hostedToolItems = new Set<string>()
const reasoningReferences = new Set<string>()
const hostedToolReferences = new Set<string>()
const flushText = () => {
if (content.length === 0) return
const groups = content.reduce<
Array<{ phase: MessagePhase | null | undefined; itemId: string | undefined; parts: TextPart[] }>
>((groups, part) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase, extension) : undefined
const itemId = metadataItemID(part, providerMetadataKey)
const group = groups.at(-1)
if (group && group.phase === phase && group.itemId === itemId) group.parts.push(part)
else groups.push({ phase, itemId, parts: [part] })
return groups
}, [])
const groups = content.reduce<Array<{ phase: MessagePhase | null | undefined; parts: TextPart[] }>>(
(groups, part) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase, extension) : undefined
const group = groups.at(-1)
if (group && group.phase === phase) group.parts.push(part)
else groups.push({ phase, parts: [part] })
return groups
},
[],
)
input.push(
...groups.map((group) => ({
role: "assistant" as const,
...(group.itemId === undefined ? {} : { id: group.itemId }),
content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
...(group.phase === undefined ? {} : { phase: group.phase }),
})),
@@ -483,6 +460,11 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
flushText()
const reasoning = lowerReasoning(part, providerMetadataKey)
if (!reasoning) continue
if (store !== false) {
if (!reasoningReferences.has(reasoning.id)) input.push({ type: "item_reference", id: reasoning.id })
reasoningReferences.add(reasoning.id)
continue
}
const existing = reasoningItems[reasoning.id]
if (existing) {
existing.summary.push(...reasoning.summary)
@@ -492,7 +474,6 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
}
const replay = {
type: reasoning.type,
id: reasoning.id,
summary: reasoning.summary,
encrypted_content: reasoning.encrypted_content,
}
@@ -509,18 +490,16 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
if (part.type === "tool-result" && part.providerExecuted === true) {
flushText()
const itemID = hostedToolItemID(part, providerMetadataKey)
const providerItem = extension.lowerProviderItem?.(part, providerMetadataKey, store)
if (providerItem && itemID && !hostedToolItems.has(itemID)) input.push(providerItem)
if (!providerItem && store !== false && itemID && !hostedToolItems.has(itemID))
if (store !== false && itemID && !hostedToolReferences.has(itemID))
input.push({ type: "item_reference", id: itemID })
if (!providerItem && store === false && part.result.type === "content") {
if (store === false && part.result.type === "content") {
const content: ReadonlyArray<Content> = part.result.value
input.push({
role: "user",
content: yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension)),
})
}
if (itemID) hostedToolItems.add(itemID)
if (itemID) hostedToolReferences.add(itemID)
continue
}
return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [
@@ -662,9 +641,9 @@ const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepRe
if (!event.delta) return [state, NO_EVENTS]
const events: LLMEvent[] = []
const phase = state.messagePhases[id]
const metadata = providerMetadata(state, { itemId: id, ...(phase === undefined ? {} : { phase }) })
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata, id)
return [{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta, metadata, id) }, events]
const metadata = phase === undefined ? undefined : providerMetadata(state, { phase })
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata)
return [{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta) }, events]
}
const onOutputTextDone = (state: ParserState, event: Event, id: string): StepResult => {
@@ -673,13 +652,7 @@ const onOutputTextDone = (state: ParserState, event: Event, id: string): StepRes
return onOutputTextDelta(state, { ...event, delta: event.text }, id)
}
const events: LLMEvent[] = []
return [
{
...state,
lifecycle: Lifecycle.textEnd(state.lifecycle, events, id, providerMetadata(state, { itemId: id }), id),
},
events,
]
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, id) }, events]
}
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
@@ -690,14 +663,7 @@ export const onReasoningDelta = (state: ParserState, event: Event, itemID: strin
return [
{
...state,
lifecycle: Lifecycle.reasoningDelta(
state.lifecycle,
events,
id,
event.delta,
providerMetadata(state, { itemId: itemID }),
itemID,
),
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, id, event.delta),
},
events,
]
@@ -739,13 +705,7 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
return [
{
...state,
lifecycle: Lifecycle.reasoningStart(
state.lifecycle,
events,
`${item.id}:0`,
reasoningMetadata(state, item),
item.id,
),
lifecycle: Lifecycle.reasoningStart(state.lifecycle, events, `${item.id}:0`, reasoningMetadata(state, item)),
reasoningItems: {
...state.reasoningItems,
[item.id]: { encryptedContent: item.encrypted_content, summaryParts: { 0: "active" } },
@@ -764,7 +724,6 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
lifecycle,
tools: ToolStream.start(state.tools, item.id, {
id: item.call_id ?? item.id,
itemId: item.id,
name: item.name ?? "",
input: item.arguments ?? "",
providerMetadata: metadata,
@@ -772,12 +731,7 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
},
[
...events,
LLMEvent.toolInputStart({
id: item.call_id ?? item.id,
itemId: item.id,
name: item.name ?? "",
providerMetadata: metadata,
}),
LLMEvent.toolInputStart({ id: item.call_id ?? item.id, name: item.name ?? "", providerMetadata: metadata }),
],
]
}
@@ -796,7 +750,6 @@ const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResu
events,
`${event.item_id}:0`,
providerMetadata(state, { itemId: event.item_id, reasoningEncryptedContent: null }),
event.item_id,
),
reasoningItems: {
...state.reasoningItems,
@@ -817,7 +770,6 @@ const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResu
events,
`${event.item_id}:${entry[0]}`,
providerMetadata(state, { itemId: event.item_id }),
event.item_id,
),
state.lifecycle,
)
@@ -829,7 +781,6 @@ const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResu
events,
`${event.item_id}:${event.summary_index}`,
providerMetadata(state, { itemId: event.item_id, reasoningEncryptedContent: item.encryptedContent ?? null }),
event.item_id,
),
reasoningItems: {
...state.reasoningItems,
@@ -865,7 +816,6 @@ const onReasoningSummaryPartDone = (state: ParserState, event: Event): StepResul
events,
`${event.item_id}:${event.summary_index}`,
providerMetadata(state, { itemId: event.item_id }),
event.item_id,
)
: state.lifecycle,
reasoningItems: {
@@ -920,8 +870,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
state.lifecycle,
events,
item.id,
providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) }),
item.id,
phase === undefined ? undefined : providerMetadata(state, { phase }),
),
messageItems,
messagePhases,
@@ -932,15 +881,9 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (item.type === "function_call") {
if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
const metadata = providerMetadata(state, { itemId: item.id })
const tools = state.tools[item.id]
? state.tools
: ToolStream.start(state.tools, item.id, {
id: item.call_id,
itemId: item.id,
name: item.name,
providerMetadata: metadata,
})
: ToolStream.start(state.tools, item.id, { id: item.call_id, name: item.name })
const result =
item.arguments === undefined
? yield* ToolStream.finish(state.id, tools, item.id)
@@ -970,7 +913,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
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, item.id),
(lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${entry[0]}`, metadata),
state.lifecycle,
)
const { [item.id]: _removed, ...reasoningItems } = state.reasoningItems
@@ -978,12 +921,12 @@ 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, itemId: item.id, providerMetadata: metadata }))
events.push(LLMEvent.reasoningEnd({ id: item.id, itemId: item.id, providerMetadata: metadata }))
events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata: metadata }))
events.push(LLMEvent.reasoningEnd({ id: item.id, providerMetadata: metadata }))
return [{ ...state, lifecycle }, events] satisfies StepResult
}
return [
{ ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, metadata, item.id) },
{ ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, metadata) },
events,
] satisfies StepResult
}
+3 -32
View File
@@ -38,14 +38,10 @@ const OpenAIResponsesToolChoice = Schema.Union([
const OpenAIResponsesInputItem = Schema.Union([
Schema.Struct({
role: Schema.tag("assistant"),
id: Schema.optionalKey(Schema.String),
content: Schema.Array(Schema.Struct({ type: Schema.tag("output_text"), text: Schema.String })),
phase: Schema.optionalKey(Schema.NullOr(OpenResponses.MessagePhase)),
}),
OpenResponses.InputItem,
Schema.StructWithRest(Schema.Struct({ type: Schema.String, id: Schema.optionalKey(Schema.String) }), [
Schema.Record(Schema.String, Schema.Unknown),
]),
])
const OpenAIResponsesCoreFields = {
@@ -84,25 +80,6 @@ const extension = {
mime_type: media.mime,
}
},
lowerProviderItem: (part, providerMetadataKey, store) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
if (!ProviderShared.isRecord(metadata) || !ProviderShared.isRecord(metadata.item)) return undefined
if (typeof metadata.item.type !== "string") return undefined
const id = typeof metadata.item.id === "string" ? metadata.item.id : undefined
// The public API requires stored state to replay image-generation items. In
// stateless mode, lower the generated file through the existing user-image fallback.
if (metadata.item.type === "image_generation_call" && store === false) return undefined
if (metadata.item.type === "image_generation_call")
return {
type: metadata.item.type,
...(id === undefined ? {} : { id }),
...(typeof metadata.item.status === "string" ? { status: metadata.item.status } : {}),
...(typeof metadata.item.revised_prompt === "string" ? { revised_prompt: metadata.item.revised_prompt } : {}),
...(typeof metadata.item.result === "string" ? { result: metadata.item.result } : {}),
}
const item: Record<string, unknown> & { type: string } = { ...metadata.item, type: metadata.item.type }
return item
},
} satisfies OpenResponses.Extension
const nativeImageToolInput = (tool: ToolDefinition) => {
@@ -218,29 +195,23 @@ const onHostedToolDone = Effect.fn("OpenAIResponses.onHostedToolDone")(function*
item: HostedToolItem,
) {
const tool = HOSTED_TOOLS[item.type]
const callMetadata = OpenResponses.providerMetadata(state, { itemId: item.id })
const resultMetadata = OpenResponses.providerMetadata(
state,
item.type === "image_generation_call" ? { itemId: item.id } : { itemId: item.id, item },
)
const providerMetadata = OpenResponses.providerMetadata(state, { itemId: item.id })
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
events.push(
LLMEvent.toolCall({
id: item.id,
itemId: item.id,
name: tool.name,
input: tool.input(item),
providerExecuted: true,
providerMetadata: callMetadata,
providerMetadata,
}),
LLMEvent.toolResult({
id: item.id,
itemId: item.id,
name: tool.name,
result: yield* hostedToolResult(item),
providerExecuted: true,
providerMetadata: resultMetadata,
providerMetadata,
}),
)
return [{ ...state, lifecycle }, events] satisfies OpenResponses.StepResult
@@ -61,57 +61,37 @@ const emptyObjectSchema = (schema: Record<string, unknown>) =>
(!isRecord(schema.properties) || Object.keys(schema.properties).length === 0) &&
!schema.additionalProperties
const projectNode = (schema: unknown, nested = false): Record<string, unknown> | undefined => {
const projectNode = (schema: unknown): Record<string, unknown> | undefined => {
if (!isRecord(schema)) return undefined
if (!nested && emptyObjectSchema(schema)) return undefined
const types = Array.isArray(schema.type) ? schema.type.filter((type) => type !== "null") : undefined
const anyOf = Array.isArray(schema.anyOf) ? schema.anyOf : undefined
const hasNullAnyOf = anyOf?.some((item) => isRecord(item) && item.type === "null") ?? false
const anyOfTypes = hasNullAnyOf ? anyOf?.filter((item) => !isRecord(item) || item.type !== "null") : anyOf
const flattenedAnyOf = hasNullAnyOf && anyOfTypes?.length === 1 ? projectNode(anyOfTypes[0], true) : undefined
const result = Object.fromEntries(
if (emptyObjectSchema(schema)) return undefined
return Object.fromEntries(
[
["description", schema.description],
["required", schema.required],
["format", schema.format],
["type", types ? (types.length === 0 ? "null" : undefined) : schema.type],
[
"nullable",
(Array.isArray(schema.type) && schema.type.includes("null") && types && types.length > 0) || hasNullAnyOf
? true
: undefined,
],
["type", Array.isArray(schema.type) ? schema.type.filter((type) => type !== "null")[0] : schema.type],
["nullable", Array.isArray(schema.type) && schema.type.includes("null") ? true : undefined],
["enum", schema.const !== undefined ? [schema.const] : schema.enum],
[
"properties",
isRecord(schema.properties)
? Object.fromEntries(Object.entries(schema.properties).map(([key, value]) => [key, projectNode(value, true)]))
? Object.fromEntries(Object.entries(schema.properties).map(([key, value]) => [key, projectNode(value)]))
: undefined,
],
[
"items",
Array.isArray(schema.items)
? schema.items.map((item) => projectNode(item, true))
? schema.items.map(projectNode)
: schema.items === undefined
? undefined
: projectNode(schema.items, true),
: projectNode(schema.items),
],
["allOf", Array.isArray(schema.allOf) ? schema.allOf.map((item) => projectNode(item, true)) : undefined],
[
"anyOf",
anyOfTypes
? hasNullAnyOf && anyOfTypes.length === 1
? undefined
: anyOfTypes.map((item) => projectNode(item, true))
: types && types.length > 0
? types.map((type) => ({ type }))
: undefined,
],
["oneOf", Array.isArray(schema.oneOf) ? schema.oneOf.map((item) => projectNode(item, true)) : undefined],
["allOf", Array.isArray(schema.allOf) ? schema.allOf.map(projectNode) : undefined],
["anyOf", Array.isArray(schema.anyOf) ? schema.anyOf.map(projectNode) : undefined],
["oneOf", Array.isArray(schema.oneOf) ? schema.oneOf.map(projectNode) : undefined],
["minLength", schema.minLength],
].filter((entry) => entry[1] !== undefined),
)
return flattenedAnyOf ? { ...result, ...flattenedAnyOf } : result
}
export const convert = (schema: unknown) => projectNode(sanitizeNode(schema))
+12 -40
View File
@@ -1,10 +1,4 @@
import {
LLMEvent,
type FinishReasonDetails,
type ProviderMetadata,
type ResponseItemID,
type Usage,
} from "../../schema"
import { LLMEvent, type FinishReasonDetails, type ProviderMetadata, type Usage } from "../../schema"
export interface State {
readonly stepStarted: boolean
@@ -20,29 +14,16 @@ export const stepStart = (state: State, events: LLMEvent[]): State => {
return { ...state, stepStarted: true }
}
export const textStart = (
state: State,
events: LLMEvent[],
id: string,
providerMetadata?: ProviderMetadata,
itemId?: ResponseItemID,
): State => {
export const textStart = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => {
if (state.text.has(id)) return state
const stepped = stepStart(state, events)
events.push(LLMEvent.textStart({ id, ...(itemId === undefined ? {} : { itemId }), providerMetadata }))
events.push(LLMEvent.textStart({ id, providerMetadata }))
return { ...stepped, text: new Set([...stepped.text, id]) }
}
export const textDelta = (
state: State,
events: LLMEvent[],
id: string,
text: string,
providerMetadata?: ProviderMetadata,
itemId?: ResponseItemID,
): State => {
const started = textStart(state, events, id, providerMetadata, itemId)
events.push(LLMEvent.textDelta({ id, ...(itemId === undefined ? {} : { itemId }), text, providerMetadata }))
export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
const started = textStart(state, events, id)
events.push(LLMEvent.textDelta({ id, text }))
return started
}
@@ -51,11 +32,10 @@ export const reasoningStart = (
events: LLMEvent[],
id: string,
providerMetadata?: ProviderMetadata,
itemId?: ResponseItemID,
): State => {
if (state.reasoning.has(id)) return state
const stepped = stepStart(state, events)
events.push(LLMEvent.reasoningStart({ id, ...(itemId === undefined ? {} : { itemId }), providerMetadata }))
events.push(LLMEvent.reasoningStart({ id, providerMetadata }))
return { ...stepped, reasoning: new Set([...stepped.reasoning, id]) }
}
@@ -65,10 +45,9 @@ export const reasoningDelta = (
id: string,
text: string,
providerMetadata?: ProviderMetadata,
itemId?: ResponseItemID,
): State => {
const started = reasoningStart(state, events, id, providerMetadata, itemId)
events.push(LLMEvent.reasoningDelta({ id, ...(itemId === undefined ? {} : { itemId }), text, providerMetadata }))
const started = reasoningStart(state, events, id, providerMetadata)
events.push(LLMEvent.reasoningDelta({ id, text, providerMetadata }))
return started
}
@@ -77,26 +56,19 @@ export const reasoningEnd = (
events: LLMEvent[],
id: string,
providerMetadata?: ProviderMetadata,
itemId?: ResponseItemID,
): State => {
if (!state.reasoning.has(id)) return state
const stepped = stepStart(state, events)
events.push(LLMEvent.reasoningEnd({ id, ...(itemId === undefined ? {} : { itemId }), providerMetadata }))
events.push(LLMEvent.reasoningEnd({ id, providerMetadata }))
const reasoning = new Set(stepped.reasoning)
reasoning.delete(id)
return { ...stepped, reasoning }
}
export const textEnd = (
state: State,
events: LLMEvent[],
id: string,
providerMetadata?: ProviderMetadata,
itemId?: ResponseItemID,
): State => {
export const textEnd = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => {
if (!state.text.has(id)) return state
const stepped = stepStart(state, events)
events.push(LLMEvent.textEnd({ id, ...(itemId === undefined ? {} : { itemId }), providerMetadata }))
events.push(LLMEvent.textEnd({ id, providerMetadata }))
const text = new Set(stepped.text)
text.delete(id)
return { ...stepped, text }
+2 -23
View File
@@ -1,12 +1,5 @@
import { Effect } from "effect"
import {
AIError,
LLMEvent,
type ProviderMetadata,
type ResponseItemID,
type ToolCall,
type ToolInputError,
} from "../../schema"
import { AIError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputError } from "../../schema"
import { eventError, parseToolInput, type ToolAccumulator } from "../shared"
type StreamKey = string | number
@@ -17,7 +10,6 @@ type StreamKey = string | number
* so far, not the parsed object.
*/
export interface PendingTool extends ToolAccumulator {
readonly itemId?: ResponseItemID
readonly providerExecuted?: boolean
readonly providerMetadata?: ProviderMetadata
}
@@ -60,7 +52,6 @@ const withoutTool = <K extends StreamKey>(tools: State<K>, key: K): State<K> =>
const inputStart = (tool: PendingTool) =>
LLMEvent.toolInputStart({
id: tool.id,
...(tool.itemId === undefined ? {} : { itemId: tool.itemId }),
name: tool.name,
providerExecuted: tool.providerExecuted ? true : undefined,
providerMetadata: tool.providerMetadata,
@@ -69,7 +60,6 @@ const inputStart = (tool: PendingTool) =>
const inputDelta = (tool: PendingTool, text: string) =>
LLMEvent.toolInputDelta({
id: tool.id,
...(tool.itemId === undefined ? {} : { itemId: tool.itemId }),
name: tool.name,
text,
})
@@ -80,7 +70,6 @@ const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
Effect.map((input): ToolCall | ToolInputError =>
LLMEvent.toolCall({
id: tool.id,
...(tool.itemId === undefined ? {} : { itemId: tool.itemId }),
name: tool.name,
input,
providerExecuted: tool.providerExecuted ? true : undefined,
@@ -93,7 +82,6 @@ const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
: Effect.succeed(
LLMEvent.toolInputError({
id: tool.id,
...(tool.itemId === undefined ? {} : { itemId: tool.itemId }),
name: tool.name,
raw,
}),
@@ -105,15 +93,7 @@ const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
const finishEvents = (tool: PendingTool, event: ToolCall | ToolInputError): ReadonlyArray<LLMEvent> =>
event.type === "tool-input-error"
? [event]
: [
LLMEvent.toolInputEnd({
id: tool.id,
...(tool.itemId === undefined ? {} : { itemId: tool.itemId }),
name: tool.name,
providerMetadata: tool.providerMetadata,
}),
event,
]
: [LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }), event]
/** Store the updated tool and produce the optional public delta event. */
const appendTool = <K extends StreamKey>(
@@ -168,7 +148,6 @@ export const appendOrStart = <K extends StreamKey>(
id,
name,
input: `${current?.input ?? ""}${delta.text}`,
itemId: current?.itemId,
providerExecuted: current?.providerExecuted,
providerMetadata: current?.providerMetadata,
}
+24 -72
View File
@@ -1,5 +1,5 @@
import { Schema } from "effect"
import { ContentBlockID, FinishReason, ProviderMetadata, ResponseItemID, ToolCallID } from "./ids"
import { ContentBlockID, FinishReason, ProviderMetadata, ToolCallID } from "./ids"
import { Message, ToolCallPart, ToolOutput, ToolResultPart, ToolResultValue, type ContentPart } from "./messages"
import { ProviderFailureClassification } from "./errors"
@@ -84,7 +84,6 @@ export type StepStart = Schema.Schema.Type<typeof StepStart>
export const TextStart = Schema.Struct({
type: Schema.tag("text-start"),
id: ContentBlockID,
itemId: Schema.optional(ResponseItemID),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.TextStart" })
export type TextStart = Schema.Schema.Type<typeof TextStart>
@@ -93,7 +92,6 @@ export const TextDelta = Schema.Struct({
type: Schema.tag("text-delta"),
id: ContentBlockID,
text: Schema.String,
itemId: Schema.optional(ResponseItemID),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.TextDelta" })
export type TextDelta = Schema.Schema.Type<typeof TextDelta>
@@ -101,7 +99,6 @@ export type TextDelta = Schema.Schema.Type<typeof TextDelta>
export const TextEnd = Schema.Struct({
type: Schema.tag("text-end"),
id: ContentBlockID,
itemId: Schema.optional(ResponseItemID),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.TextEnd" })
export type TextEnd = Schema.Schema.Type<typeof TextEnd>
@@ -109,7 +106,6 @@ export type TextEnd = Schema.Schema.Type<typeof TextEnd>
export const ReasoningStart = Schema.Struct({
type: Schema.tag("reasoning-start"),
id: ContentBlockID,
itemId: Schema.optional(ResponseItemID),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ReasoningStart" })
export type ReasoningStart = Schema.Schema.Type<typeof ReasoningStart>
@@ -118,7 +114,6 @@ export const ReasoningDelta = Schema.Struct({
type: Schema.tag("reasoning-delta"),
id: ContentBlockID,
text: Schema.String,
itemId: Schema.optional(ResponseItemID),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ReasoningDelta" })
export type ReasoningDelta = Schema.Schema.Type<typeof ReasoningDelta>
@@ -126,7 +121,6 @@ export type ReasoningDelta = Schema.Schema.Type<typeof ReasoningDelta>
export const ReasoningEnd = Schema.Struct({
type: Schema.tag("reasoning-end"),
id: ContentBlockID,
itemId: Schema.optional(ResponseItemID),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ReasoningEnd" })
export type ReasoningEnd = Schema.Schema.Type<typeof ReasoningEnd>
@@ -135,7 +129,6 @@ export const ToolInputStart = Schema.Struct({
type: Schema.tag("tool-input-start"),
id: ToolCallID,
name: Schema.String,
itemId: Schema.optional(ResponseItemID),
providerExecuted: Schema.optional(Schema.Boolean),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ToolInputStart" })
@@ -144,7 +137,6 @@ export type ToolInputStart = Schema.Schema.Type<typeof ToolInputStart>
export const ToolInputDelta = Schema.Struct({
type: Schema.tag("tool-input-delta"),
id: ToolCallID,
itemId: Schema.optional(ResponseItemID),
name: Schema.String,
text: Schema.String,
}).annotate({ identifier: "LLM.Event.ToolInputDelta" })
@@ -154,7 +146,6 @@ export const ToolInputEnd = Schema.Struct({
type: Schema.tag("tool-input-end"),
id: ToolCallID,
name: Schema.String,
itemId: Schema.optional(ResponseItemID),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ToolInputEnd" })
export type ToolInputEnd = Schema.Schema.Type<typeof ToolInputEnd>
@@ -163,7 +154,6 @@ export type ToolInputEnd = Schema.Schema.Type<typeof ToolInputEnd>
export const ToolInputError = Schema.Struct({
type: Schema.tag("tool-input-error"),
id: ToolCallID,
itemId: Schema.optional(ResponseItemID),
name: Schema.String,
raw: Schema.String,
}).annotate({ identifier: "LLM.Event.ToolInputError" })
@@ -172,7 +162,6 @@ export type ToolInputError = Schema.Schema.Type<typeof ToolInputError>
export const ToolCall = Schema.Struct({
type: Schema.tag("tool-call"),
id: ToolCallID,
itemId: Schema.optional(ResponseItemID),
name: Schema.String,
input: Schema.Unknown,
providerExecuted: Schema.optional(Schema.Boolean),
@@ -183,7 +172,6 @@ export type ToolCall = Schema.Schema.Type<typeof ToolCall>
export const ToolResult = Schema.Struct({
type: Schema.tag("tool-result"),
id: ToolCallID,
itemId: Schema.optional(ResponseItemID),
name: Schema.String,
result: ToolResultValue,
output: Schema.optional(ToolOutput),
@@ -195,7 +183,6 @@ export type ToolResult = Schema.Schema.Type<typeof ToolResult>
export const ToolError = Schema.Struct({
type: Schema.tag("tool-error"),
id: ToolCallID,
itemId: Schema.optional(ResponseItemID),
name: Schema.String,
message: Schema.String,
error: Schema.optional(Schema.Defect()),
@@ -347,14 +334,12 @@ const responseUsage = (events: ReadonlyArray<LLMEvent>) =>
interface ContentAssembly {
readonly contentIndex: number
readonly text: string
readonly itemId?: ResponseItemID
readonly providerMetadata?: ProviderMetadata
}
interface ToolInputAssembly {
readonly name: string
readonly text: string
readonly itemId?: ResponseItemID
readonly providerMetadata?: ProviderMetadata
}
@@ -400,27 +385,11 @@ const appendEvent = (state: ResponseState, event: LLMEvent): ResponseState => {
}
}
const textContent = (
text: string,
itemId: ResponseItemID | undefined,
providerMetadata: ProviderMetadata | undefined,
): ContentPart => ({
type: "text",
text,
...(itemId === undefined ? {} : { itemId }),
...(providerMetadata === undefined ? {} : { providerMetadata }),
})
const textContent = (text: string, providerMetadata: ProviderMetadata | undefined): ContentPart =>
providerMetadata === undefined ? { type: "text", text } : { type: "text", text, providerMetadata }
const reasoningContent = (
text: string,
itemId: ResponseItemID | undefined,
providerMetadata: ProviderMetadata | undefined,
): ContentPart => ({
type: "reasoning",
text,
...(itemId === undefined ? {} : { itemId }),
...(providerMetadata === undefined ? {} : { providerMetadata }),
})
const reasoningContent = (text: string, providerMetadata: ProviderMetadata | undefined): ContentPart =>
providerMetadata === undefined ? { type: "reasoning", text } : { type: "reasoning", text, providerMetadata }
const contentWith = (state: ResponseState, content: ReadonlyArray<ContentPart>): ResponseState => ({
...state,
@@ -435,32 +404,26 @@ const replaceContent = (state: ResponseState, index: number, part: ContentPart)
state.message.content.map((item, itemIndex) => (itemIndex === index ? part : item)),
)
const ensureText = (
state: ResponseState,
id: string,
itemId?: ResponseItemID,
providerMetadata?: ProviderMetadata,
): ResponseState => {
const ensureText = (state: ResponseState, id: string, providerMetadata?: ProviderMetadata): ResponseState => {
if (state.textParts[id]) return state
return {
...appendContent(state, textContent("", itemId, providerMetadata)),
...appendContent(state, textContent("", providerMetadata)),
textParts: {
...state.textParts,
[id]: { contentIndex: state.message.content.length, text: "", itemId, providerMetadata },
[id]: { contentIndex: state.message.content.length, text: "", providerMetadata },
},
}
}
const reduceTextDelta = (state: ResponseState, event: TextDelta): ResponseState => {
const started = ensureText(state, event.id, event.itemId, event.providerMetadata)
const started = ensureText(state, event.id, event.providerMetadata)
const current = started.textParts[event.id]
if (!current) return started
const text = current.text + event.text
const itemId = event.itemId ?? current.itemId
const providerMetadata = event.providerMetadata ?? current.providerMetadata
return {
...replaceContent(started, current.contentIndex, textContent(text, itemId, providerMetadata)),
textParts: { ...started.textParts, [event.id]: { ...current, text, itemId, providerMetadata } },
...replaceContent(started, current.contentIndex, textContent(text, providerMetadata)),
textParts: { ...started.textParts, [event.id]: { ...current, text, providerMetadata } },
}
}
@@ -468,39 +431,32 @@ const reduceTextEnd = (state: ResponseState, event: TextEnd): ResponseState => {
const current = state.textParts[event.id]
if (!current) return state
const providerMetadata = event.providerMetadata ?? current.providerMetadata
const itemId = event.itemId ?? current.itemId
return {
...replaceContent(state, current.contentIndex, textContent(current.text, itemId, providerMetadata)),
textParts: { ...state.textParts, [event.id]: { ...current, itemId, providerMetadata } },
...replaceContent(state, current.contentIndex, textContent(current.text, providerMetadata)),
textParts: { ...state.textParts, [event.id]: { ...current, providerMetadata } },
}
}
const ensureReasoning = (
state: ResponseState,
id: string,
itemId?: ResponseItemID,
providerMetadata?: ProviderMetadata,
): ResponseState => {
const ensureReasoning = (state: ResponseState, id: string, providerMetadata?: ProviderMetadata): ResponseState => {
if (state.reasoningParts[id]) return state
return {
...appendContent(state, reasoningContent("", itemId, providerMetadata)),
...appendContent(state, reasoningContent("", providerMetadata)),
reasoningParts: {
...state.reasoningParts,
[id]: { contentIndex: state.message.content.length, text: "", itemId, providerMetadata },
[id]: { contentIndex: state.message.content.length, text: "", providerMetadata },
},
}
}
const reduceReasoningDelta = (state: ResponseState, event: ReasoningDelta): ResponseState => {
const started = ensureReasoning(state, event.id, event.itemId, event.providerMetadata)
const started = ensureReasoning(state, event.id, event.providerMetadata)
const current = started.reasoningParts[event.id]
if (!current) return started
const text = current.text + event.text
const itemId = event.itemId ?? current.itemId
const providerMetadata = event.providerMetadata ?? current.providerMetadata
return {
...replaceContent(started, current.contentIndex, reasoningContent(text, itemId, providerMetadata)),
reasoningParts: { ...started.reasoningParts, [event.id]: { ...current, text, itemId, providerMetadata } },
...replaceContent(started, current.contentIndex, reasoningContent(text, providerMetadata)),
reasoningParts: { ...started.reasoningParts, [event.id]: { ...current, text, providerMetadata } },
}
}
@@ -508,10 +464,9 @@ const reduceReasoningEnd = (state: ResponseState, event: ReasoningEnd): Response
const current = state.reasoningParts[event.id]
if (!current) return state
const providerMetadata = event.providerMetadata ?? current.providerMetadata
const itemId = event.itemId ?? current.itemId
return {
...replaceContent(state, current.contentIndex, reasoningContent(current.text, itemId, providerMetadata)),
reasoningParts: { ...state.reasoningParts, [event.id]: { ...current, itemId, providerMetadata } },
...replaceContent(state, current.contentIndex, reasoningContent(current.text, providerMetadata)),
reasoningParts: { ...state.reasoningParts, [event.id]: { ...current, providerMetadata } },
}
}
@@ -519,7 +474,7 @@ const reduceToolInputStart = (state: ResponseState, event: ToolInputStart): Resp
...state,
toolInputs: {
...state.toolInputs,
[event.id]: { name: event.name, text: "", itemId: event.itemId, providerMetadata: event.providerMetadata },
[event.id]: { name: event.name, text: "", providerMetadata: event.providerMetadata },
},
})
@@ -540,7 +495,6 @@ const reduceToolInputEnd = (state: ResponseState, event: ToolInputEnd): Response
[event.id]: {
...current,
name: event.name,
itemId: event.itemId ?? current.itemId,
providerMetadata: event.providerMetadata ?? current.providerMetadata,
},
},
@@ -550,7 +504,6 @@ const reduceToolInputEnd = (state: ResponseState, event: ToolInputEnd): Response
const toolCallContent = (event: ToolCall): ContentPart =>
ToolCallPart.make({
id: event.id,
...(event.itemId === undefined ? {} : { itemId: event.itemId }),
name: event.name,
input: event.input,
...(event.providerExecuted === undefined ? {} : { providerExecuted: event.providerExecuted }),
@@ -560,7 +513,6 @@ const toolCallContent = (event: ToolCall): ContentPart =>
const toolResultContent = (event: ToolResult): ContentPart =>
ToolResultPart.make({
id: event.id,
...(event.itemId === undefined ? {} : { itemId: event.itemId }),
name: event.name,
result: event.result,
...(event.providerExecuted === undefined ? {} : { providerExecuted: event.providerExecuted }),
@@ -576,13 +528,13 @@ const reduceResponseState = (state: ResponseState, event: LLMEvent): ResponseSta
const next = appendEvent(state, event)
switch (event.type) {
case "text-start":
return ensureText(next, event.id, event.itemId, event.providerMetadata)
return ensureText(next, event.id, event.providerMetadata)
case "text-delta":
return reduceTextDelta(next, event)
case "text-end":
return reduceTextEnd(next, event)
case "reasoning-start":
return ensureReasoning(next, event.id, event.itemId, event.providerMetadata)
return ensureReasoning(next, event.id, event.providerMetadata)
case "reasoning-delta":
return reduceReasoningDelta(next, event)
case "reasoning-end":
-3
View File
@@ -21,9 +21,6 @@ export type ProviderID = typeof ProviderID.Type
export const ResponseID = Schema.String
export type ResponseID = Schema.Schema.Type<typeof ResponseID>
export const ResponseItemID = Schema.String
export type ResponseItemID = Schema.Schema.Type<typeof ResponseItemID>
export const ContentBlockID = Schema.String
export type ContentBlockID = Schema.Schema.Type<typeof ContentBlockID>
+2 -7
View File
@@ -1,6 +1,6 @@
import { Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { JsonSchema, MessageRole, ProviderMetadata, ResponseItemID } from "./ids"
import { JsonSchema, MessageRole, ProviderMetadata } from "./ids"
import { CacheHint, CachePolicy, GenerationOptions, HttpOptions, LanguageModelSchema, ProviderOptions } from "./options"
import { isRecord } from "../utils/record"
@@ -25,7 +25,6 @@ export const SystemPart = Object.assign(systemPartSchema, {
export const TextPart = Schema.Struct({
type: Schema.Literal("text"),
text: Schema.String,
itemId: Schema.optional(ResponseItemID),
cache: Schema.optional(CacheHint),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
providerMetadata: Schema.optional(ProviderMetadata),
@@ -122,7 +121,6 @@ export const ToolCallPart = Object.assign(
Schema.Struct({
type: Schema.Literal("tool-call"),
id: Schema.String,
itemId: Schema.optional(ResponseItemID),
name: Schema.String,
input: Schema.Unknown,
providerExecuted: Schema.optional(Schema.Boolean),
@@ -140,7 +138,6 @@ export const ToolResultPart = Object.assign(
Schema.Struct({
type: Schema.Literal("tool-result"),
id: Schema.String,
itemId: Schema.optional(ResponseItemID),
name: Schema.String,
result: ToolResultValue,
providerExecuted: Schema.optional(Schema.Boolean),
@@ -157,7 +154,6 @@ export const ToolResultPart = Object.assign(
): ToolResultPart => ({
type: "tool-result",
id: input.id,
...(input.itemId === undefined ? {} : { itemId: input.itemId }),
name: input.name,
result: ToolResultValue.make(input.result, input.resultType),
providerExecuted: input.providerExecuted,
@@ -172,7 +168,6 @@ export type ToolResultPart = Schema.Schema.Type<typeof ToolResultPart>
export const ReasoningPart = Schema.Struct({
type: Schema.Literal("reasoning"),
text: Schema.String,
itemId: Schema.optional(ResponseItemID),
encrypted: Schema.optional(Schema.String),
cache: Schema.optional(CacheHint),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
@@ -186,7 +181,7 @@ export const ContentPart = Schema.Union([TextPart, MediaPart, ToolCallPart, Tool
export type ContentPart = Schema.Schema.Type<typeof ContentPart>
export class Message extends Schema.Class<Message>("LLM.Message")({
id: Schema.optional(ResponseItemID),
id: Schema.optional(Schema.String),
role: MessageRole,
content: Schema.Array(ContentPart),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
+2 -2
View File
@@ -79,7 +79,7 @@ const result = (call: ToolCallPart, value: ToolResultValueType | ToolSettlement,
id: call.id,
name: call.name,
result: settlement.result,
...(call.providerMetadata === undefined ? {} : { providerMetadata: call.providerMetadata }),
providerMetadata: call.providerMetadata,
}),
]
: [
@@ -88,7 +88,7 @@ const result = (call: ToolCallPart, value: ToolResultValueType | ToolSettlement,
name: call.name,
result: settlement.result,
output: settlement.output,
...(call.providerMetadata === undefined ? {} : { providerMetadata: call.providerMetadata }),
providerMetadata: call.providerMetadata,
}),
],
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -44,7 +44,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]},{\"type\":\"reasoning\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEjXoGMCw3WDXpoD9151PEr2Lt8raW7KBKefQhZJGWx5f8jy152bApO6oE-Mr1BhUtfZNq3OPBVfSL4ioQ9bHREfujIBXgk9LUDBAz2Sle7KjOr9HaUV16A4HBiaFIRFjsHPS9G8yEySp1m6F1CD_WR6apyUGgugRh_y39EcOJmxPOzmiac5DVM6fraA1VpcGbqrZ1x2ANHFDOfnYTycPtPNTgzE7LjkYjDDWbT03uN1YxfP4pqjDVRzY14pA8bSZ8ys-pDv5kUFCAsw-OlU4jYKUXp-M8_6KTaRQP71LPwppt__zG_NJPfy-qUil4pOU8_NoxtxerHgLLXbfExZdzfpoGinoEjn7nj7BJDEtl-LNeNEb5c-1ZymNfVMp-Cs3fLEPkAV8rtHFtZ0MhE_07GKbGo7hTrOmkM4DydxmHsdWGNbXAG35cprslEA5P7p3GHFKnRs5hGs2eq-XcZ3yki64ZBOU_Tv6UR7nUH09gF1rdrJo3dpre6M00COwwdZ02zUP5KxCuI8FKu2jsZu9zgMVXDALsdtM5orTCVLXsn4rddWd111zE-vMjNmMMmktW2cHMjH7j1ooA-9P083koNVYiLi4UhMA64gTqgyl8MxkZekl7eFSMa7qk295NaHOKtFxzYYcZ9jdioCwSPSZ0ZZWLoNgrK7SWfRh0uaTHNcMZ3wq8ae6CguktIeVTCPTQAqJLQqd7AU0oOCKCJ7BWnC-L8UC6m7Pm9ZS958uUVeWBhgKHzMAGq9UeQB7IEeAcbMn3EDgOSfd8qCb8iwU9iG9dcu9axQwWU7pd7kd-T-He61W7z5wWgpx1KehWCxrN6kuKSo6p-uUfwVnJukreOn8BJNAzADQgz68bhmN9VGih7YcKVnLgwDwKditrjSd6-tfE0Baarj3jWENvT6ohY17R9FDrKS-2v8IIX6tGjoKJw8SRhaWLNv4vWlmxRgR0gdac3qumd0GKqsWSveNz01naA==\",\"id\":\"rs_0a0794dab3b8ec7d016a1235e7ce3881958a5eca32a36a14c5\"},{\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello!\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Now reply exactly with: Done.\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":40,\"stream\":true}"
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]},{\"type\":\"reasoning\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEjXoGMCw3WDXpoD9151PEr2Lt8raW7KBKefQhZJGWx5f8jy152bApO6oE-Mr1BhUtfZNq3OPBVfSL4ioQ9bHREfujIBXgk9LUDBAz2Sle7KjOr9HaUV16A4HBiaFIRFjsHPS9G8yEySp1m6F1CD_WR6apyUGgugRh_y39EcOJmxPOzmiac5DVM6fraA1VpcGbqrZ1x2ANHFDOfnYTycPtPNTgzE7LjkYjDDWbT03uN1YxfP4pqjDVRzY14pA8bSZ8ys-pDv5kUFCAsw-OlU4jYKUXp-M8_6KTaRQP71LPwppt__zG_NJPfy-qUil4pOU8_NoxtxerHgLLXbfExZdzfpoGinoEjn7nj7BJDEtl-LNeNEb5c-1ZymNfVMp-Cs3fLEPkAV8rtHFtZ0MhE_07GKbGo7hTrOmkM4DydxmHsdWGNbXAG35cprslEA5P7p3GHFKnRs5hGs2eq-XcZ3yki64ZBOU_Tv6UR7nUH09gF1rdrJo3dpre6M00COwwdZ02zUP5KxCuI8FKu2jsZu9zgMVXDALsdtM5orTCVLXsn4rddWd111zE-vMjNmMMmktW2cHMjH7j1ooA-9P083koNVYiLi4UhMA64gTqgyl8MxkZekl7eFSMa7qk295NaHOKtFxzYYcZ9jdioCwSPSZ0ZZWLoNgrK7SWfRh0uaTHNcMZ3wq8ae6CguktIeVTCPTQAqJLQqd7AU0oOCKCJ7BWnC-L8UC6m7Pm9ZS958uUVeWBhgKHzMAGq9UeQB7IEeAcbMn3EDgOSfd8qCb8iwU9iG9dcu9axQwWU7pd7kd-T-He61W7z5wWgpx1KehWCxrN6kuKSo6p-uUfwVnJukreOn8BJNAzADQgz68bhmN9VGih7YcKVnLgwDwKditrjSd6-tfE0Baarj3jWENvT6ohY17R9FDrKS-2v8IIX6tGjoKJw8SRhaWLNv4vWlmxRgR0gdac3qumd0GKqsWSveNz01naA==\"},{\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello!\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Now reply exactly with: Done.\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":40,\"stream\":true}"
},
"response": {
"status": 200,
+5 -14
View File
@@ -8,6 +8,7 @@ import {
type ProviderMetadata,
type ToolCallPart,
ToolResultPart,
type ToolResultValue,
type Usage,
} from "../../src/schema"
import { type Tools, toDefinitions } from "../../src/tool"
@@ -60,10 +61,9 @@ export const runTools = <T extends Tools>(options: RunOptions<T>) =>
...dispatched.map(([call, dispatched]) =>
Message.tool({
id: call.id,
itemId: dispatched.events.find(LLMEvent.is.toolResult)?.itemId,
name: call.name,
result: dispatched.result,
providerMetadata: dispatched.events.find(LLMEvent.is.toolResult)?.providerMetadata,
providerMetadata: call.providerMetadata,
}),
),
],
@@ -89,15 +89,9 @@ const stepState = (events: ReadonlyArray<LLMEvent>) => {
for (const event of events) {
if (event.type === "text-delta" || event.type === "reasoning-delta") {
appendText(assistantContent, event.type === "text-delta" ? "text" : "reasoning", event.text, event.itemId)
appendText(assistantContent, event.type === "text-delta" ? "text" : "reasoning", event.text)
} else if (event.type === "text-end" || event.type === "reasoning-end") {
appendText(
assistantContent,
event.type === "text-end" ? "text" : "reasoning",
"",
event.itemId,
event.providerMetadata,
)
appendText(assistantContent, event.type === "text-end" ? "text" : "reasoning", "", event.providerMetadata)
} else if (event.type === "tool-call") {
assistantContent.push(event)
if (!event.providerExecuted) toolCalls.push(event)
@@ -105,7 +99,6 @@ const stepState = (events: ReadonlyArray<LLMEvent>) => {
assistantContent.push(
ToolResultPart.make({
id: event.id,
itemId: event.itemId,
name: event.name,
result: event.result,
providerExecuted: true,
@@ -125,7 +118,6 @@ const appendText = (
content: ContentPart[],
type: "text" | "reasoning",
text: string,
itemId?: string,
providerMetadata?: ProviderMetadata,
) => {
const last = content.at(-1)
@@ -133,12 +125,11 @@ const appendText = (
content[content.length - 1] = {
...last,
text: `${last.text}${text}`,
itemId: itemId ?? last.itemId,
providerMetadata: providerMetadata ?? last.providerMetadata,
}
return
}
content.push({ type, text, itemId, providerMetadata })
content.push({ type, text, providerMetadata })
}
const addUsage = (left: Usage | undefined, right: Usage | undefined): Usage | undefined => {
-172
View File
@@ -16,13 +16,6 @@ const model = Gemini.route
})
.model({ id: "gemini-2.5-flash" })
const gemini3 = Gemini.route
.with({
endpoint: { baseURL: "https://generativelanguage.test/v1beta/" },
auth: Auth.header("x-goog-api-key", "test"),
})
.model({ id: "gemini-3-flash-preview" })
const request = LLM.request({
id: "req_1",
model,
@@ -93,39 +86,6 @@ describe("Gemini route", () => {
}),
)
it.effect("forwards standard Gemini generation options", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
prompt: "Say hello.",
generation: {
maxTokens: 40,
temperature: 0.2,
topP: 0.8,
topK: 12,
frequencyPenalty: 0.3,
presencePenalty: 0.4,
seed: 42,
stop: ["done"],
},
}),
)
expect(prepared.body.generationConfig).toEqual({
maxOutputTokens: 40,
temperature: 0.2,
topP: 0.8,
topK: 12,
frequencyPenalty: 0.3,
presencePenalty: 0.4,
seed: 42,
stopSequences: ["done"],
thinkingConfig: undefined,
})
}),
)
it.effect("lowers chronological system updates to wrapped user text in order", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -390,100 +350,6 @@ describe("Gemini route", () => {
}),
)
it.effect("preserves nested empty object tool schemas", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
prompt: "Use the tool.",
tools: [
{
name: "configure",
description: "Configure the operation",
inputSchema: {
type: "object",
required: ["options"],
properties: {
options: { type: "object", description: "Optional provider settings", properties: {} },
},
},
},
],
}),
)
expect(prepared.body.tools).toEqual([
{
functionDeclarations: [
{
name: "configure",
description: "Configure the operation",
parameters: {
type: "object",
required: ["options"],
properties: {
options: { type: "object", description: "Optional provider settings", properties: {} },
},
},
},
],
},
])
}),
)
it.effect("projects Gemini type arrays without narrowing their allowed values", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
prompt: "Use the tool.",
tools: [
{
name: "filter",
description: "Filter values",
inputSchema: {
type: "object",
properties: {
status: { type: ["number", "string"], description: "Status filter" },
maybe: { type: ["string", "null"] },
nothing: { type: ["null"] },
explicit: { anyOf: [{ type: "string" }, { type: "null" }] },
choice: { anyOf: [{ type: "string" }, { type: "number" }, { type: "null" }] },
},
},
},
],
}),
)
expect(prepared.body.tools?.[0]?.functionDeclarations[0]?.parameters).toEqual({
type: "object",
properties: {
status: {
description: "Status filter",
anyOf: [{ type: "number" }, { type: "string" }],
},
maybe: {
nullable: true,
anyOf: [{ type: "string" }],
},
nothing: {
type: "null",
},
explicit: {
type: "string",
nullable: true,
},
choice: {
anyOf: [{ type: "string" }, { type: "number" }],
nullable: true,
},
},
})
}),
)
it.effect("parses text, reasoning, and usage stream fixtures", () =>
Effect.gen(function* () {
const body = sseEvents(
@@ -670,44 +536,6 @@ describe("Gemini route", () => {
}),
)
it.effect("replays unsigned Gemini 3 tool calls with the validator bypass sentinel", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: gemini3,
messages: [
Message.assistant([ToolCallPart.make({ id: "tool_0", name: "lookup", input: { query: "weather" } })]),
Message.tool({ id: "tool_0", name: "lookup", result: "done", resultType: "text" }),
],
}),
)
expect(prepared.body.contents).toEqual([
{
role: "model",
parts: [
{
functionCall: { id: undefined, name: "lookup", args: { query: "weather" } },
thoughtSignature: "skip_thought_signature_validator",
},
],
},
{
role: "user",
parts: [
{
functionResponse: {
id: undefined,
name: "lookup",
response: { name: "lookup", content: "done" },
},
},
],
},
])
}),
)
it.effect("emits streamed tool calls and maps finish reason", () =>
Effect.gen(function* () {
const body = sseEvents({
@@ -45,7 +45,7 @@ describe("Open Responses-compatible route", () => {
},
},
})
expect(prepared.body).toMatchObject({
expect(prepared.body).toEqual({
model: "example-model",
input: [
{ role: "system", content: "You are concise." },
@@ -53,8 +53,6 @@ describe("Open Responses-compatible route", () => {
],
stream: true,
})
expect(prepared.body.input[0]).not.toHaveProperty("id")
expect(prepared.body.input[1]).not.toHaveProperty("id")
}),
)
@@ -69,9 +69,6 @@ describe("OpenAI Responses route", () => {
stream: true,
max_output_tokens: 20,
temperature: 0,
tool_choice: undefined,
tools: undefined,
top_p: undefined,
})
}),
)
@@ -332,7 +329,7 @@ describe("OpenAI Responses route", () => {
yield* LLMClient.generate(
LLMRequest.update(request, {
model: Azure.configure({
baseURL: "https://opencode-test.openai.azure.com/openai/",
baseURL: "https://opencode-test.openai.azure.com/openai/v1/",
apiKey: "azure-key",
headers: { authorization: "Bearer stale" },
}).responses("gpt-4.1-mini"),
@@ -413,7 +410,7 @@ describe("OpenAI Responses route", () => {
}),
)
expect(prepared.body).toMatchObject({
expect(prepared.body).toEqual({
model: "gpt-4.1-mini",
input: [
{ role: "user", content: [{ type: "input_text", text: "What is the weather?" }] },
@@ -428,60 +425,6 @@ describe("OpenAI Responses route", () => {
tools: undefined,
top_p: undefined,
})
const call = prepared.body.input.find((item) => "type" in item && item.type === "function_call")
const output = prepared.body.input.find((item) => "type" in item && item.type === "function_call_output")
expect(call?.id).toBeUndefined()
expect(output?.id).toBeUndefined()
}),
)
it.effect("does not generate response item ids for client-created history", () =>
Effect.sync(() => {
const canonical = LLM.request({
model,
messages: [
Message.assistant([
{ type: "text", text: "Working." },
{ type: "reasoning", text: "Thinking." },
ToolCallPart.make({ id: "call_1", name: "lookup", input: {} }),
]),
Message.tool({ id: "call_1", name: "lookup", result: "done" }),
],
})
expect(canonical.messages[0]?.id).toBeUndefined()
expect(canonical.messages[1]?.id).toBeUndefined()
expect(canonical.messages[0]?.content.every((part) => part.type === "media" || part.itemId === undefined)).toBe(
true,
)
expect(canonical.messages[1]?.content[0]).not.toHaveProperty("itemId")
}),
)
it.effect("preserves opaque assistant item ids without assigning ids to function items", () =>
Effect.gen(function* () {
const canonical = LLM.request({
model,
messages: [
Message.assistant([
{ type: "text", text: "Calling.", itemId: "plain-text" },
ToolCallPart.make({ id: "call_1", itemId: "plain-call", name: "lookup", input: {} }),
]),
Message.tool({ id: "call_1", itemId: "plain-output", name: "lookup", result: "done" }),
],
})
const prepared = yield* compileRequest(canonical)
expect(canonical.messages[0]?.content.map((part) => (part.type === "media" ? undefined : part.itemId))).toEqual([
"plain-text",
"plain-call",
])
expect(canonical.messages[1]?.content[0]).toMatchObject({ itemId: "plain-output" })
expect(prepared.body.input).toEqual([
{ role: "assistant", id: "plain-text", content: [{ type: "output_text", text: "Calling." }] },
{ type: "function_call", call_id: "call_1", name: "lookup", arguments: "{}" },
{ type: "function_call_output", call_id: "call_1", output: '"done"' },
])
}),
)
@@ -921,21 +864,9 @@ describe("OpenAI Responses route", () => {
expect(response.text).toBe("Hello!")
expect(response.events).toEqual([
{ type: "step-start", index: 0 },
{ type: "text-start", id: "msg_1", itemId: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
{
type: "text-delta",
id: "msg_1",
itemId: "msg_1",
text: "Hello",
providerMetadata: { openai: { itemId: "msg_1" } },
},
{
type: "text-delta",
id: "msg_1",
itemId: "msg_1",
text: "!",
providerMetadata: { openai: { itemId: "msg_1" } },
},
{ type: "text-start", id: "msg_1" },
{ type: "text-delta", id: "msg_1", text: "Hello" },
{ type: "text-delta", id: "msg_1", text: "!" },
{ type: "text-end", id: "msg_1" },
{
type: "step-finish",
@@ -992,20 +923,17 @@ describe("OpenAI Responses route", () => {
{
type: "text",
text: "Checking.",
itemId: "msg_commentary",
providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
providerMetadata: { openai: { phase: "commentary" } },
},
{
type: "text",
text: "Finished.",
itemId: "msg_final",
providerMetadata: { openai: { itemId: "msg_final", phase: "final_answer" } },
providerMetadata: { openai: { phase: "final_answer" } },
},
{
type: "text",
text: "Unclassified.",
itemId: "msg_null",
providerMetadata: { openai: { itemId: "msg_null", phase: null } },
providerMetadata: { openai: { phase: null } },
},
])
@@ -1013,19 +941,16 @@ describe("OpenAI Responses route", () => {
expect(prepared.body.input).toEqual([
{
role: "assistant",
id: "msg_commentary",
content: [{ type: "output_text", text: "Checking." }],
phase: "commentary",
},
{
role: "assistant",
id: "msg_final",
content: [{ type: "output_text", text: "Finished." }],
phase: "final_answer",
},
{
role: "assistant",
id: "msg_null",
content: [{ type: "output_text", text: "Unclassified." }],
phase: null,
},
@@ -1118,24 +1043,12 @@ describe("OpenAI Responses route", () => {
)
expect(response.events.filter((event) => event.type.startsWith("text-"))).toEqual([
{ type: "text-start", id: "msg_1", itemId: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
{
type: "text-delta",
id: "msg_1",
itemId: "msg_1",
text: "First",
providerMetadata: { openai: { itemId: "msg_1" } },
},
{ type: "text-end", id: "msg_1", itemId: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
{ type: "text-start", id: "msg_2", itemId: "msg_2", providerMetadata: { openai: { itemId: "msg_2" } } },
{
type: "text-delta",
id: "msg_2",
itemId: "msg_2",
text: "Second",
providerMetadata: { openai: { itemId: "msg_2" } },
},
{ type: "text-end", id: "msg_2", itemId: "msg_2", providerMetadata: { openai: { itemId: "msg_2" } } },
{ type: "text-start", id: "msg_1" },
{ type: "text-delta", id: "msg_1", text: "First" },
{ type: "text-end", id: "msg_1" },
{ type: "text-start", id: "msg_2" },
{ type: "text-delta", id: "msg_2", text: "Second" },
{ type: "text-end", id: "msg_2" },
])
}),
)
@@ -1155,15 +1068,9 @@ describe("OpenAI Responses route", () => {
expect(response.text).toBe("Hello")
expect(response.events).toMatchObject([
{ type: "step-start", index: 0 },
{ type: "reasoning-start", id: "rs_1", itemId: "rs_1", providerMetadata: { openai: { itemId: "rs_1" } } },
{
type: "reasoning-delta",
id: "rs_1",
itemId: "rs_1",
text: "thinking",
providerMetadata: { openai: { itemId: "rs_1" } },
},
{ type: "text-start", id: "msg_1", itemId: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
{ type: "reasoning-start", id: "rs_1" },
{ type: "reasoning-delta", id: "rs_1", text: "thinking" },
{ type: "text-start", id: "msg_1" },
{ type: "text-delta", id: "msg_1", text: "Hello" },
{ type: "reasoning-end", id: "rs_1" },
{ type: "text-end", id: "msg_1" },
@@ -1172,8 +1079,8 @@ describe("OpenAI Responses route", () => {
])
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
expect(response.message.content).toEqual([
{ type: "reasoning", text: "thinking", itemId: "rs_1", providerMetadata: { openai: { itemId: "rs_1" } } },
{ type: "text", text: "Hello", itemId: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
{ type: "reasoning", text: "thinking" },
{ type: "text", text: "Hello" },
])
}),
)
@@ -1204,7 +1111,6 @@ describe("OpenAI Responses route", () => {
expect.objectContaining({
type: "reasoning-end",
id: "rs_1",
itemId: "rs_1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
}),
)
@@ -1245,34 +1151,19 @@ describe("OpenAI Responses route", () => {
{
type: "reasoning-start",
id: "rs_1:0",
itemId: "rs_1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{
type: "reasoning-delta",
id: "rs_1:0",
itemId: "rs_1",
text: "First",
providerMetadata: { openai: { itemId: "rs_1" } },
},
{ type: "reasoning-end", id: "rs_1:0", itemId: "rs_1", providerMetadata: { openai: { itemId: "rs_1" } } },
{ type: "reasoning-delta", id: "rs_1:0", text: "First" },
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
{
type: "reasoning-start",
id: "rs_1:1",
itemId: "rs_1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{
type: "reasoning-delta",
id: "rs_1:1",
itemId: "rs_1",
text: "Second",
providerMetadata: { openai: { itemId: "rs_1" } },
},
{ type: "reasoning-delta", id: "rs_1:1", text: "Second" },
{
type: "reasoning-end",
id: "rs_1:1",
itemId: "rs_1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
{ type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } },
@@ -1310,8 +1201,8 @@ describe("OpenAI Responses route", () => {
)
expect(response.events.filter((event) => event.type === "reasoning-end")).toEqual([
{ type: "reasoning-end", id: "rs_1:0", itemId: "rs_1", providerMetadata: { openai: { itemId: "rs_1" } } },
{ type: "reasoning-end", id: "rs_1:1", itemId: "rs_1", providerMetadata: { openai: { itemId: "rs_1" } } },
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
{ type: "reasoning-end", id: "rs_1:1", providerMetadata: { openai: { itemId: "rs_1" } } },
])
}),
)
@@ -1359,7 +1250,7 @@ describe("OpenAI Responses route", () => {
{ role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
],
})
expect(body.input[1]).toHaveProperty("id", "rs_1")
expect(body.input[1]).not.toHaveProperty("id")
return input.respond(
sseEvents(
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Parser now round-trips reasoning." },
@@ -1406,7 +1297,6 @@ describe("OpenAI Responses route", () => {
{ role: "assistant", content: [{ type: "output_text", text: "Before." }] },
{
type: "reasoning",
id: "rs_1",
encrypted_content: "encrypted-state",
summary: [{ type: "summary_text", text: "Checked order." }],
},
@@ -1415,7 +1305,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("replays complete stored reasoning items with their id", () =>
it.effect("references stored reasoning items by id", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
@@ -1433,14 +1323,7 @@ describe("OpenAI Responses route", () => {
}),
)
expect(prepared.body.input).toEqual([
{
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Checked the previous diff." }],
encrypted_content: undefined,
},
])
expect(prepared.body.input).toEqual([{ type: "item_reference", id: "rs_1" }])
}),
)
@@ -1549,7 +1432,6 @@ describe("OpenAI Responses route", () => {
expect(prepared.body.input).toEqual([
{
type: "reasoning",
id: "rs_1",
encrypted_content: "encrypted-state",
summary: [
{ type: "summary_text", text: "First" },
@@ -1629,7 +1511,6 @@ describe("OpenAI Responses route", () => {
outputTokens: 1,
nonCachedInputTokens: 5,
cacheReadInputTokens: undefined,
cacheWriteInputTokens: undefined,
reasoningTokens: undefined,
totalTokens: 6,
providerMetadata: { openai: { input_tokens: 5, output_tokens: 1 } },
@@ -1640,35 +1521,30 @@ describe("OpenAI Responses route", () => {
{
type: "tool-input-start",
id: "call_1",
itemId: "item_1",
name: "lookup",
providerMetadata: { openai: { itemId: "item_1" } },
},
{
type: "tool-input-delta",
id: "call_1",
itemId: "item_1",
name: "lookup",
text: '{"query"',
},
{
type: "tool-input-delta",
id: "call_1",
itemId: "item_1",
name: "lookup",
text: ':"weather"}',
},
{
type: "tool-input-end",
id: "call_1",
itemId: "item_1",
name: "lookup",
providerMetadata: { openai: { itemId: "item_1" } },
},
{
type: "tool-call",
id: "call_1",
itemId: "item_1",
name: "lookup",
input: { query: "weather" },
providerExecuted: undefined,
@@ -1688,17 +1564,6 @@ describe("OpenAI Responses route", () => {
usage,
},
])
expect(response.message.content).toEqual([
{
type: "tool-call",
id: "call_1",
itemId: "item_1",
name: "lookup",
input: { query: "weather" },
providerExecuted: undefined,
providerMetadata: { openai: { itemId: "item_1" } },
},
])
}),
)
@@ -1731,7 +1596,6 @@ describe("OpenAI Responses route", () => {
expect(response.events.find(LLMEvent.is.toolInputError)).toEqual({
type: "tool-input-error",
id: "call_1",
itemId: "item_1",
name: "lookup",
raw: '{"query":"partial',
})
@@ -1788,7 +1652,6 @@ describe("OpenAI Responses route", () => {
{
type: "tool-call",
id: "ws_1",
itemId: "ws_1",
name: "web_search",
input: { type: "search", query: "effect 4" },
providerExecuted: true,
@@ -1797,35 +1660,11 @@ describe("OpenAI Responses route", () => {
{
type: "tool-result",
id: "ws_1",
itemId: "ws_1",
name: "web_search",
result: { type: "json", value: item },
providerExecuted: true,
providerMetadata: { openai: { itemId: "ws_1", item } },
output: undefined,
},
])
expect(response.message.content).toEqual([
{
type: "tool-call",
id: "ws_1",
itemId: "ws_1",
name: "web_search",
input: { type: "search", query: "effect 4" },
providerExecuted: true,
providerMetadata: { openai: { itemId: "ws_1" } },
},
{
type: "tool-result",
id: "ws_1",
itemId: "ws_1",
name: "web_search",
result: { type: "json", value: item },
providerExecuted: true,
providerMetadata: { openai: { itemId: "ws_1", item } },
metadata: undefined,
cache: undefined,
},
])
}),
)
@@ -1903,7 +1742,6 @@ describe("OpenAI Responses route", () => {
expect(toolCall).toEqual({
type: "tool-call",
id: "ci_1",
itemId: "ci_1",
name: "code_interpreter",
input: { code: "print(1+1)", container_id: "cnt_xyz" },
providerExecuted: true,
@@ -1913,12 +1751,10 @@ describe("OpenAI Responses route", () => {
expect(toolResult).toEqual({
type: "tool-result",
id: "ci_1",
itemId: "ci_1",
name: "code_interpreter",
result: { type: "json", value: item },
providerExecuted: true,
providerMetadata: { openai: { itemId: "ci_1", item } },
output: undefined,
providerMetadata: { openai: { itemId: "ci_1" } },
})
}),
)
-37
View File
@@ -49,43 +49,6 @@ describe("LLMResponse reducer", () => {
expect(state.message.content).toEqual([{ type: "text", text: "partial" }])
})
test("assembles response item identity and provider metadata", () => {
const response = LLMResponse.fromEvents([
LLMEvent.textStart({ id: "text-block", itemId: "msg_existing" }),
LLMEvent.textDelta({
id: "text-block",
itemId: "msg_existing",
text: "Answer",
providerMetadata: { openai: { itemId: "msg_existing" } },
}),
LLMEvent.textEnd({ id: "text-block", itemId: "msg_existing" }),
LLMEvent.reasoningStart({ id: "reasoning-block", itemId: "rs_existing" }),
LLMEvent.reasoningDelta({
id: "reasoning-block",
itemId: "rs_existing",
text: "Thought",
providerMetadata: { openai: { itemId: "rs_existing" } },
}),
LLMEvent.reasoningEnd({ id: "reasoning-block", itemId: "rs_existing" }),
LLMEvent.finish({ reason: { normalized: "stop" } }),
])
expect(response?.message.content).toEqual([
{
type: "text",
text: "Answer",
itemId: "msg_existing",
providerMetadata: { openai: { itemId: "msg_existing" } },
},
{
type: "reasoning",
text: "Thought",
itemId: "rs_existing",
providerMetadata: { openai: { itemId: "rs_existing" } },
},
])
})
test("does not complete ended content without a terminal finish", () => {
const state = reduce([
LLMEvent.textStart({ id: "t1" }),
+5 -29
View File
@@ -172,7 +172,7 @@ describe("LLMClient tools", () => {
expect(calls).toEqual([{ id: "call_projected", parameters: { prefix: "count" }, output: { count: "2" } }])
expect(dispatched.result).toEqual({ type: "text", value: "count:2" })
expect(dispatched.output).toEqual({ structured: { count: "2" }, content: [{ type: "text", text: "count:2" }] })
expect(dispatched.events).toMatchObject([
expect(dispatched.events).toEqual([
LLMEvent.toolResult({
id: "call_projected",
name: "projected",
@@ -180,7 +180,6 @@ describe("LLMClient tools", () => {
output: { structured: { count: "2" }, content: [{ type: "text", text: "count:2" }] },
}),
])
expect(dispatched.events[0]?.itemId).toBeUndefined()
}),
)
@@ -198,7 +197,7 @@ describe("LLMClient tools", () => {
LLMEvent.toolCall({ id: "call_1", name: "tool", input: {}, providerMetadata }),
)
expect(dispatched.events).toMatchObject([
expect(dispatched.events).toEqual([
LLMEvent.toolResult({
id: "call_1",
name: "tool",
@@ -207,13 +206,12 @@ describe("LLMClient tools", () => {
providerMetadata,
}),
])
expect(dispatched.events[0]?.itemId).toBeUndefined()
const failed = yield* ToolRuntime.dispatch(
{},
LLMEvent.toolCall({ id: "call_2", itemId: "fc_failed", name: "missing", input: {}, providerMetadata }),
LLMEvent.toolCall({ id: "call_2", name: "missing", input: {}, providerMetadata }),
)
expect(failed.events).toMatchObject([
expect(failed.events).toEqual([
LLMEvent.toolError({
id: "call_2",
name: "missing",
@@ -227,27 +225,6 @@ describe("LLMClient tools", () => {
providerMetadata,
}),
])
const errorItemID = failed.events.find(LLMEvent.is.toolError)?.itemId
const resultItemID = failed.events.find(LLMEvent.is.toolResult)?.itemId
expect(errorItemID).toBeUndefined()
expect(resultItemID).toBeUndefined()
}),
)
it.effect("does not derive a function output item id from the function call item id", () =>
Effect.gen(function* () {
const tool = Tool.make({
description: "Return text.",
parameters: Schema.Struct({}),
success: Schema.String,
execute: () => Effect.succeed("hello"),
})
const dispatched = yield* ToolRuntime.dispatch(
{ tool },
LLMEvent.toolCall({ id: "call_1", itemId: "fc_existing", name: "tool", input: {} }),
)
expect(dispatched.events.find(LLMEvent.is.toolResult)?.itemId).toBeUndefined()
}),
)
@@ -460,7 +437,7 @@ describe("LLMClient tools", () => {
)
expect(dispatched.result).toEqual(callerOwned)
expect(dispatched.events).toMatchObject([
expect(dispatched.events).toEqual([
LLMEvent.toolResult({
id: "call_1",
name: "eventful",
@@ -468,7 +445,6 @@ describe("LLMClient tools", () => {
output: { structured: { ok: true }, content: [] },
}),
])
expect(dispatched.events[0]?.itemId).toBeUndefined()
}),
)
@@ -134,6 +134,22 @@ test("does not pull a keyboard-scrolled user during shell remeasurement", async
await reportVisualStability(testInfo, "keyboard-during-resize", trace, anchorPlan(regions))
})
test("accumulates rapid page key presses", async ({ page }) => {
await setupTimeline(page, {
messages: history(80),
viewport: { width: 1400, height: 700 },
})
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
await scroller.focus()
const before = await scroller.evaluate((element) => ({ top: element.scrollTop, height: element.clientHeight }))
for (let index = 0; index < 3; index++) await scroller.press("PageUp")
await page.waitForTimeout(150)
expect(before.top - (await scroller.evaluate((element) => element.scrollTop))).toBeGreaterThan(before.height * 2.2)
})
test("tracks keyboard scrolling from a focused timeline descendant", async ({ page }, testInfo) => {
const shellID = "prt_descendant_keyboard_01_shell"
const timeline = await setupTimeline(page, {
@@ -153,88 +153,4 @@ describe("v2 session reducer", () => {
expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] })
})
test("removes cancelled input from the pending promotion fold", () => {
const reducer = createV2SessionReducer()
reducer.reduce(
[],
event({
...base,
id: "evt_admitted",
type: "session.input.admitted",
data: {
sessionID: "ses_1",
inputID: "msg_user",
input: { type: "user", delivery: "queue", data: { text: "cancel me" } },
},
}),
)
reducer.reduce(
[],
event({
...base,
id: "evt_cancelled",
type: "session.input.cancelled",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
const result = reducer.reduce(
[],
event({
...base,
id: "evt_promoted",
type: "session.input.promoted",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
expect(result).toMatchObject({ missing: "msg_user" })
})
test("keeps steered input available to the promotion fold", () => {
const reducer = createV2SessionReducer()
reducer.reduce(
[],
event({
...base,
id: "evt_admitted",
type: "session.input.admitted",
data: {
sessionID: "ses_1",
inputID: "msg_user",
input: { type: "user", delivery: "queue", data: { text: "steer me" } },
},
}),
)
reducer.reduce(
[],
event({
...base,
id: "evt_steered",
type: "session.input.steered",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
reducer.reduce(
[],
event({
...base,
id: "evt_queued",
type: "session.input.queued",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
const result = reducer.reduce(
[],
event({
...base,
id: "evt_promoted",
type: "session.input.promoted",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
expect(result?.messages).toMatchObject([{ id: "msg_user", type: "user", text: "steer me" }])
})
})
@@ -29,9 +29,6 @@ export function createV2SessionReducer() {
case "session.input.admitted":
pending.set(key(sessionID, event.data.inputID), event.data.input)
return result([...source])
case "session.input.cancelled":
pending.delete(key(sessionID, event.data.inputID))
return
case "session.input.promoted": {
const input = pending.get(key(sessionID, event.data.inputID))
pending.delete(key(sessionID, event.data.inputID))
+13 -12
View File
@@ -688,8 +688,6 @@ export default function Page() {
return {
queryKey: [...vcsKey(), mode] as const,
enabled,
refetchOnMount: "always" as const,
refetchOnWindowFocus: true,
queryFn: mode
? () =>
sdk()
@@ -703,16 +701,6 @@ export default function Page() {
}
})
const refreshVcs = debounce(() => void queryClient.invalidateQueries({ queryKey: vcsKey() }), 100)
createEffect(
on(
() => desktopReviewOpen() || mobileChanges(),
(open, previous) => {
if (!open || previous || !desktopFileTreeOpen() || vcsQuery.isFetching) return
refreshVcs()
},
{ defer: true },
),
)
const reviewDiffs = () => {
if (reviewMode() === "git" || reviewMode() === "branch")
// avoids suspense
@@ -959,6 +947,19 @@ export default function Page() {
),
)
const stopVcs = sdk().event.listen((evt) => {
const details = evt.details as { type: string; properties?: unknown }
if (details.type !== "file.watcher.updated" && details.type !== "filesystem.changed") return
const props =
typeof details.properties === "object" && details.properties
? (details.properties as Record<string, unknown>)
: undefined
const file = typeof props?.file === "string" ? props.file : undefined
if (!file || file.startsWith(".git/")) return
refreshVcs()
})
onCleanup(stopVcs)
createEffect(
on(
() => sdk().directory,
+27 -73
View File
@@ -263,52 +263,38 @@ export type Endpoint5_23Input = { readonly sessionID: Session.ID }
export type Endpoint5_23Output = ReadonlyArray<SessionPending.Info>
export type SessionPendingListOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
export type Endpoint5_24Output = void
export type SessionPendingCancelOperation<E = never> = (
export type Endpoint5_24Input = { readonly sessionID: Session.ID }
export type Endpoint5_24Output = ReadonlyArray<InstructionEntry.Info>
export type SessionInstructionsEntryListOperation<E = never> = (
input: Endpoint5_24Input,
) => Effect.Effect<Endpoint5_24Output, E>
export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
export type Endpoint5_25Output = void
export type SessionPendingSteerOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
export type Endpoint5_26Output = void
export type SessionPendingQueueOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
export type Endpoint5_27Input = { readonly sessionID: Session.ID }
export type Endpoint5_27Output = ReadonlyArray<InstructionEntry.Info>
export type SessionInstructionsEntryListOperation<E = never> = (
input: Endpoint5_27Input,
) => Effect.Effect<Endpoint5_27Output, E>
export type Endpoint5_28Input = {
export type Endpoint5_25Input = {
readonly sessionID: Session.ID
readonly key: InstructionEntry.Key
readonly value: Schema.Json
}
export type Endpoint5_28Output = void
export type Endpoint5_25Output = void
export type SessionInstructionsEntryPutOperation<E = never> = (
input: Endpoint5_28Input,
) => Effect.Effect<Endpoint5_28Output, E>
input: Endpoint5_25Input,
) => Effect.Effect<Endpoint5_25Output, E>
export type Endpoint5_29Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
export type Endpoint5_29Output = void
export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
export type Endpoint5_26Output = void
export type SessionInstructionsEntryRemoveOperation<E = never> = (
input: Endpoint5_29Input,
) => Effect.Effect<Endpoint5_29Output, E>
input: Endpoint5_26Input,
) => Effect.Effect<Endpoint5_26Output, E>
export type Endpoint5_30Input = { readonly sessionID: Session.ID; readonly prompt: string }
export type Endpoint5_30Output = { readonly text: string }
export type SessionGenerateOperation<E = never> = (input: Endpoint5_30Input) => Effect.Effect<Endpoint5_30Output, E>
export type Endpoint5_27Input = { readonly sessionID: Session.ID; readonly prompt: string }
export type Endpoint5_27Output = { readonly text: string }
export type SessionGenerateOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
export type Endpoint5_31Input = {
export type Endpoint5_28Input = {
readonly sessionID: Session.ID
readonly after?: Event.Seq | undefined
readonly follow?: boolean | undefined
}
export type Endpoint5_31Output =
export type Endpoint5_28Output =
| (
| {
readonly id: Event.ID
@@ -418,33 +404,6 @@ export type Endpoint5_31Output =
readonly input: SessionPending.Message
}
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.input.cancelled"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.input.steered"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.input.queued"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
@@ -903,19 +862,19 @@ export type Endpoint5_31Output =
}
)
| EventLog.Synced
export type SessionLogOperation<E = never> = (input: Endpoint5_31Input) => Stream.Stream<Endpoint5_31Output, E>
export type SessionLogOperation<E = never> = (input: Endpoint5_28Input) => Stream.Stream<Endpoint5_28Output, E>
export type Endpoint5_32Input = { readonly sessionID: Session.ID }
export type Endpoint5_32Output = void
export type SessionInterruptOperation<E = never> = (input: Endpoint5_32Input) => Effect.Effect<Endpoint5_32Output, E>
export type Endpoint5_29Input = { readonly sessionID: Session.ID }
export type Endpoint5_29Output = void
export type SessionInterruptOperation<E = never> = (input: Endpoint5_29Input) => Effect.Effect<Endpoint5_29Output, E>
export type Endpoint5_33Input = { readonly sessionID: Session.ID }
export type Endpoint5_33Output = void
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_33Input) => Effect.Effect<Endpoint5_33Output, E>
export type Endpoint5_30Input = { readonly sessionID: Session.ID }
export type Endpoint5_30Output = void
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_30Input) => Effect.Effect<Endpoint5_30Output, E>
export type Endpoint5_34Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
export type Endpoint5_34Output = SessionMessage.Info
export type SessionMessageOperation<E = never> = (input: Endpoint5_34Input) => Effect.Effect<Endpoint5_34Output, E>
export type Endpoint5_31Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
export type Endpoint5_31Output = SessionMessage.Info
export type SessionMessageOperation<E = never> = (input: Endpoint5_31Input) => Effect.Effect<Endpoint5_31Output, E>
export interface SessionApi<E = never> {
readonly list: SessionListOperation<E>
@@ -943,12 +902,7 @@ export interface SessionApi<E = never> {
readonly commit: SessionRevertCommitOperation<E>
}
readonly context: SessionContextOperation<E>
readonly pending: {
readonly list: SessionPendingListOperation<E>
readonly cancel: SessionPendingCancelOperation<E>
readonly steer: SessionPendingSteerOperation<E>
readonly queue: SessionPendingQueueOperation<E>
}
readonly pending: { readonly list: SessionPendingListOperation<E> }
readonly instructions: {
readonly entry: {
readonly list: SessionInstructionsEntryListOperation<E>
+21 -48
View File
@@ -80,12 +80,6 @@ import type {
Endpoint5_30Output,
Endpoint5_31Input,
Endpoint5_31Output,
Endpoint5_32Input,
Endpoint5_32Output,
Endpoint5_33Input,
Endpoint5_33Output,
Endpoint5_34Input,
Endpoint5_34Output,
Endpoint6_0Input,
Endpoint6_0Output,
Endpoint7_0Input,
@@ -529,58 +523,37 @@ const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23I
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
preserveEffect<Endpoint5_24Output>()(
raw["session.pending.cancel"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
preserveEffect<Endpoint5_25Output>()(
raw["session.pending.steer"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
preserveEffect<Endpoint5_26Output>()(
raw["session.pending.queue"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
preserveEffect<Endpoint5_27Output>()(
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
preserveEffect<Endpoint5_28Output>()(
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
preserveEffect<Endpoint5_25Output>()(
raw["session.instructions.entry.put"]({
params: { sessionID: input["sessionID"], key: input["key"] },
payload: { value: input["value"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
preserveEffect<Endpoint5_29Output>()(
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
preserveEffect<Endpoint5_26Output>()(
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
preserveEffect<Endpoint5_30Output>()(
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
preserveEffect<Endpoint5_27Output>()(
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) =>
preserveStream<Endpoint5_31Output>()(
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
preserveStream<Endpoint5_28Output>()(
Stream.unwrap(
raw["session.log"]({
params: { sessionID: input["sessionID"] },
@@ -592,18 +565,18 @@ const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31I
),
)
const Endpoint5_32 = (raw: RawClient["server.session"]) => (input: Endpoint5_32Input) =>
preserveEffect<Endpoint5_32Output>()(
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
preserveEffect<Endpoint5_29Output>()(
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_33 = (raw: RawClient["server.session"]) => (input: Endpoint5_33Input) =>
preserveEffect<Endpoint5_33Output>()(
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
preserveEffect<Endpoint5_30Output>()(
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_34 = (raw: RawClient["server.session"]) => (input: Endpoint5_34Input) =>
preserveEffect<Endpoint5_34Output>()(
const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) =>
preserveEffect<Endpoint5_31Output>()(
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
@@ -632,13 +605,13 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
wait: Endpoint5_18(raw),
revert: { stage: Endpoint5_19(raw), clear: Endpoint5_20(raw), commit: Endpoint5_21(raw) },
context: Endpoint5_22(raw),
pending: { list: Endpoint5_23(raw), cancel: Endpoint5_24(raw), steer: Endpoint5_25(raw), queue: Endpoint5_26(raw) },
instructions: { entry: { list: Endpoint5_27(raw), put: Endpoint5_28(raw), remove: Endpoint5_29(raw) } },
generate: Endpoint5_30(raw),
log: Endpoint5_31(raw),
interrupt: Endpoint5_32(raw),
background: Endpoint5_33(raw),
message: Endpoint5_34(raw),
pending: { list: Endpoint5_23(raw) },
instructions: { entry: { list: Endpoint5_24(raw), put: Endpoint5_25(raw), remove: Endpoint5_26(raw) } },
generate: Endpoint5_27(raw),
log: Endpoint5_28(raw),
interrupt: Endpoint5_29(raw),
background: Endpoint5_30(raw),
message: Endpoint5_31(raw),
})
const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>
@@ -58,12 +58,6 @@ import type {
SessionContextOutput,
SessionPendingListInput,
SessionPendingListOutput,
SessionPendingCancelInput,
SessionPendingCancelOutput,
SessionPendingSteerInput,
SessionPendingSteerOutput,
SessionPendingQueueInput,
SessionPendingQueueOutput,
SessionInstructionsEntryListInput,
SessionInstructionsEntryListOutput,
SessionInstructionsEntryPutInput,
@@ -772,39 +766,6 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
cancel: (input: SessionPendingCancelInput, requestOptions?: RequestOptions) =>
request<SessionPendingCancelOutput>(
{
method: "DELETE",
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}`,
successStatus: 204,
declaredStatuses: [409, 404, 401, 400],
empty: true,
},
requestOptions,
),
steer: (input: SessionPendingSteerInput, requestOptions?: RequestOptions) =>
request<SessionPendingSteerOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}/steer`,
successStatus: 204,
declaredStatuses: [409, 404, 401, 400],
empty: true,
},
requestOptions,
),
queue: (input: SessionPendingQueueInput, requestOptions?: RequestOptions) =>
request<SessionPendingQueueOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}/queue`,
successStatus: 204,
declaredStatuses: [409, 404, 401, 400],
empty: true,
},
requestOptions,
),
},
instructions: {
entry: {
@@ -502,36 +502,6 @@ export type SessionInputPromoted = {
data: { sessionID: string; inputID: string }
}
export type SessionInputCancelled = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.input.cancelled"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; inputID: string }
}
export type SessionInputSteered = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.input.steered"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; inputID: string }
}
export type SessionInputQueued = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.input.queued"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; inputID: string }
}
export type SessionExecutionStarted = {
id: string
created: number
@@ -2000,9 +1970,6 @@ export type SessionEventDurable =
| SessionForked
| SessionInputPromoted
| SessionInputAdmitted
| SessionInputCancelled
| SessionInputSteered
| SessionInputQueued
| SessionExecutionStarted
| SessionExecutionSucceeded
| SessionExecutionFailed
@@ -2057,9 +2024,6 @@ export type V2Event =
| SessionForked
| SessionInputPromoted
| SessionInputAdmitted
| SessionInputCancelled
| SessionInputSteered
| SessionInputQueued
| SessionExecutionStarted
| SessionExecutionSucceeded
| SessionExecutionFailed
@@ -3725,27 +3689,6 @@ export type SessionPendingListInput = { readonly sessionID: { readonly sessionID
export type SessionPendingListOutput = { data: Array<SessionPendingInfo> }["data"]
export type SessionPendingCancelInput = {
readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
}
export type SessionPendingCancelOutput = void
export type SessionPendingSteerInput = {
readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
}
export type SessionPendingSteerOutput = void
export type SessionPendingQueueInput = {
readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
}
export type SessionPendingQueueOutput = void
export type SessionInstructionsEntryListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionInstructionsEntryListOutput = { data: Array<InstructionEntryInfo> }["data"]
@@ -19,7 +19,7 @@ test("effect entrypoint exposes canonical Schema contracts", () => {
test("generated Effect API names canonical and composed outputs", async () => {
const source = await Bun.file(new URL("../src/effect/api/api.ts", import.meta.url)).text()
expect(source).toContain("export type Endpoint5_5Output = Session.Info")
expect(source).toContain("export type Endpoint5_3Output = Session.Info")
expect(source).toContain("export type Endpoint19_0Output = OpenCodeEvent")
expect(source).not.toContain("HttpApiClient.ForApi")
})
-23
View File
@@ -32,7 +32,6 @@ test("exposes every standard HTTP API group", () => {
"projectCopy",
"vcs",
"debug",
"migration",
"websearch",
"config",
])
@@ -357,28 +356,6 @@ test("session.pending.list uses the public HTTP contract", async () => {
expect(requests).toEqual([{ method: "GET", url: "http://localhost:3000/api/session/ses_test/pending" }])
})
test("session.pending mutations use the public HTTP contract", async () => {
const requests: Array<{ method: string; url: string }> = []
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
requests.push({ method: request.method, url: request.url })
return new Response(null, { status: 204 })
},
})
await client.session.pending.cancel({ sessionID: "ses_test", inputID: "msg_cancel" })
await client.session.pending.steer({ sessionID: "ses_test", inputID: "msg_steer" })
await client.session.pending.queue({ sessionID: "ses_test", inputID: "msg_queue" })
expect(requests).toEqual([
{ method: "DELETE", url: "http://localhost:3000/api/session/ses_test/pending/msg_cancel" },
{ method: "POST", url: "http://localhost:3000/api/session/ses_test/pending/msg_steer/steer" },
{ method: "POST", url: "http://localhost:3000/api/session/ses_test/pending/msg_queue/queue" },
])
})
test("event.subscribe exposes the Promise event stream wire projection", async () => {
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
-2
View File
@@ -17,7 +17,6 @@
"opencode": "./bin/opencode"
},
"exports": {
"./environment": "./src/environment/index.ts",
"./session/runner": "./src/session/runner/index.ts",
"./instructions": "./src/instructions/index.ts",
"./*": "./src/*.ts"
@@ -118,7 +117,6 @@
"immer": "11.1.4",
"ignore": "7.0.5",
"jsonc-parser": "3.3.1",
"mime-types": "3.0.2",
"turndown": "7.2.0",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
+4 -6
View File
@@ -13,14 +13,12 @@ export const Plugin = define({
const config = yield* Config.Service
const loaded = { entries: yield* config.entries() }
yield* ctx.integration.transform((integrations) => {
const configuredIntegrations = new Set(
configuredProviders(loaded.entries).flatMap(([id, provider]) => (provider.env === undefined ? [] : [id])),
)
for (const [id, provider] of configuredProviders(loaded.entries)) {
const integrationID = id
if (!integrations.get(integrationID)) {
integrations.method.update({
integrationID,
method: { type: "key", label: "Manually enter API Key" },
})
}
if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue
integrations.update(integrationID, (integration) => {
integration.name = provider.name ?? integration.name
})
-9
View File
@@ -1,9 +0,0 @@
import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import type { FilesImpl } from "./files"
export interface Driver {
readonly spawner: ChildProcessSpawner["Service"]
readonly overrides?: Partial<FilesImpl>
}
export * as EnvironmentDriver from "./driver"
@@ -1,26 +0,0 @@
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer } from "effect"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import type { Files } from "./files"
import { makeFiles } from "./index"
import { makeLocalDriver } from "./local"
export interface Interface {
readonly files: Files
readonly spawner: ChildProcessSpawner["Service"]
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Environment") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner
return Service.of({ files: makeFiles(makeLocalDriver(spawner)), spawner })
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [CrossSpawnSpawner.node] })
export * as EnvironmentService from "./environment"
@@ -1,192 +0,0 @@
import { Effect, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import { collectStream } from "@opencode-ai/util/process"
import { Failed, NotFound, WrongKind, type FileInfo, type FileType, type FilesImpl } from "./files"
/**
* Files derived from spawning processes: one process per intent, "$1" is
* always the target path. Scripts report classification through an exit-code
* protocol (44/45/46) so failures never require parsing localized error text;
* LC_ALL=C pins the one stderr match that remains. Requires GNU coreutils and
* findutils in the target image BSD and busybox userlands will not work.
* Malformed output from these scripts is our own bug and dies as a defect.
*/
const MAX_DATA_BYTES = 64 * 1024 * 1024
const MAX_ERROR_BYTES = 64 * 1024
const NOT_FOUND = 44
const WRONG_KIND = 45
const FAILED = 46
const TAB = "\t"
const loadMetadata = (flags = "") => `
metadata=$(stat ${flags} -c '%F${TAB}%s${TAB}%Y' -- "$1" 2>&1) || {
case "$metadata" in
*'No such file or directory'*|*'Not a directory'*) exit ${NOT_FOUND} ;;
*) printf '%s' "$metadata" >&2; exit ${FAILED} ;;
esac
}
`
const statScript = `
${loadMetadata()}
printf '%s\n' "$metadata"
`
const readScript = `
${loadMetadata("-L")}
kind=\${metadata%%${TAB}*}
if [ "$kind" != 'regular file' ] && [ "$kind" != 'regular empty file' ]; then
printf '%s' "$kind" >&2
exit ${WRONG_KIND}
fi
printf '%s\n' "$metadata"
if [ "$2" = range ]; then
dd if="$1" iflag=skip_bytes,count_bytes skip="$3" count="$4" status=none
else
cat -- "$1"
fi
`
const listScript = `
${loadMetadata("-L")}
kind=\${metadata%%${TAB}*}
if [ "$kind" != directory ]; then
printf '%s' "$kind" >&2
exit ${WRONG_KIND}
fi
find -H "$1" -mindepth 1 -maxdepth 1 -printf '%y\\0%f\\0'
`
const moveScript = `
${loadMetadata()}
mv -- "$1" "$2"
`
interface Result {
readonly exitCode: number
readonly stdout: Uint8Array
readonly stderr: Uint8Array
}
export const execDefaults = (spawner: ChildProcessSpawner["Service"]): FilesImpl => {
const run = (
path: string,
script: string,
args: ReadonlyArray<string> = [],
stdin?: Uint8Array,
): Effect.Effect<Result, Failed> =>
Effect.scoped(
Effect.gen(function* () {
const command = ChildProcess.make("sh", ["-c", script, "sh", path, ...args], {
env: { LC_ALL: "C" },
extendEnv: true,
stdin: stdin === undefined ? undefined : Stream.make(stdin),
})
const handle = yield* spawner.spawn(command).pipe(Effect.mapError((cause) => new Failed({ path, cause })))
const [stdout, stderr, exitCode] = yield* Effect.all(
[
collectStream(handle.stdout, MAX_DATA_BYTES),
collectStream(handle.stderr, MAX_ERROR_BYTES),
handle.exitCode,
],
{ concurrency: "unbounded" },
).pipe(Effect.mapError((cause) => new Failed({ path, cause })))
if (stdout.truncated || stderr.truncated) {
return yield* new Failed({ path, cause: new Error("Process output exceeded its collection limit") })
}
return { exitCode, stdout: stdout.buffer, stderr: stderr.buffer }
}),
)
const classify = <A>(
path: string,
result: Result,
success: (stdout: Uint8Array) => A,
): Effect.Effect<A, NotFound | WrongKind | Failed> => {
if (result.exitCode === 0) return Effect.sync(() => success(result.stdout))
if (result.exitCode === NOT_FOUND) return Effect.fail(new NotFound({ path }))
if (result.exitCode === WRONG_KIND) {
return Effect.fail(new WrongKind({ path, actual: parseType(new TextDecoder().decode(result.stderr)) }))
}
return Effect.fail(processFailure(path, result))
}
const complete = (path: string, result: Result) =>
result.exitCode === 0 ? Effect.void : Effect.fail(processFailure(path, result))
return {
stat: (path) => run(path, statScript).pipe(Effect.flatMap((result) => classifyPlain(path, result, parseInfo))),
read: (path, range) =>
run(
path,
readScript,
range === undefined ? ["whole"] : ["range", String(range.offset), String(range.length)],
).pipe(
Effect.flatMap((result) =>
classify(path, result, (stdout) => {
const newline = stdout.indexOf(10)
if (newline < 0) throw new Error("Missing read metadata header")
return {
info: parseInfo(stdout.slice(0, newline)),
bytes: stdout.slice(newline + 1),
}
}),
),
),
write: (path, bytes) =>
run(path, `mkdir -p "$(dirname "$1")" && cat > "$1"`, [], bytes).pipe(
Effect.flatMap((result) => complete(path, result)),
),
list: (path) => run(path, listScript).pipe(Effect.flatMap((result) => classify(path, result, parseList))),
remove: (path) => run(path, `rm -rf -- "$1"`).pipe(Effect.flatMap((result) => complete(path, result))),
move: (from, to) =>
run(from, moveScript, [to]).pipe(Effect.flatMap((result) => classifyPlain(from, result, () => undefined))),
mkdir: (path) => run(path, `mkdir -p -- "$1"`).pipe(Effect.flatMap((result) => complete(path, result))),
}
}
/** `classify` for scripts whose protocol never reports WrongKind. */
const classifyPlain = <A>(
path: string,
result: Result,
success: (stdout: Uint8Array) => A,
): Effect.Effect<A, NotFound | Failed> => {
if (result.exitCode === 0) return Effect.sync(() => success(result.stdout))
if (result.exitCode === NOT_FOUND) return Effect.fail(new NotFound({ path }))
return Effect.fail(processFailure(path, result))
}
const processFailure = (path: string, result: Result) =>
new Failed({
path,
cause: new Error(new TextDecoder().decode(result.stderr).trim() || `Process exited with code ${result.exitCode}`),
})
const parseInfo = (bytes: Uint8Array): FileInfo => {
const [rawType, rawSize, rawMtime] = new TextDecoder().decode(bytes).trim().split(TAB)
const size = Number(rawSize)
const mtimeMs = Number(rawMtime) * 1_000
if (!rawType || !Number.isFinite(size) || !Number.isFinite(mtimeMs)) throw new Error("Invalid stat output")
return { type: parseType(rawType), size, mtimeMs }
}
const parseType = (value: string): FileType => {
if (value === "regular file" || value === "regular empty file" || value === "f") return "file"
if (value === "directory" || value === "d") return "directory"
if (value === "symbolic link" || value === "l") return "symlink"
return "other"
}
const parseList = (bytes: Uint8Array) => {
const fields = new TextDecoder().decode(bytes).split("\0")
fields.pop()
if (fields.length % 2 !== 0) throw new Error("Invalid find output")
return Array.from({ length: fields.length / 2 }, (_, index) => ({
name: fields[index * 2 + 1],
type: parseType(fields[index * 2]),
}))
}
export * as EnvironmentExecDefaults from "./exec-defaults"
-70
View File
@@ -1,70 +0,0 @@
import { Effect, Schema } from "effect"
export const FileType = Schema.Literals(["file", "directory", "symlink", "other"])
export type FileType = typeof FileType.Type
export interface FileInfo {
readonly type: FileType
readonly size: number
readonly mtimeMs: number
}
export interface DirEntry {
readonly name: string
readonly type: FileType
}
export class NotFound extends Schema.TaggedErrorClass<NotFound>()("Environment.NotFound", {
path: Schema.String,
}) {}
export class WrongKind extends Schema.TaggedErrorClass<WrongKind>()("Environment.WrongKind", {
path: Schema.String,
actual: FileType,
}) {}
export class Failed extends Schema.TaggedErrorClass<Failed>()("Environment.Failed", {
path: Schema.String,
cause: Schema.Defect(),
}) {}
export interface FilesImpl {
/**
* Content operations (`read`, `list`) follow final symlinks; metadata operations (`stat` and entry
* tags returned by `list`) do not. `info` describes the target file whose bytes are returned.
* The process-backed default caps collected output at 64 MiB; larger whole-file reads fail with
* `Failed`, so callers must use ranges for larger files.
*/
readonly read: (
path: string,
range?: { readonly offset: number; readonly length: number },
) => Effect.Effect<{ readonly info: FileInfo; readonly bytes: Uint8Array }, NotFound | WrongKind | Failed>
readonly write: (path: string, bytes: Uint8Array) => Effect.Effect<void, Failed>
/** Describes the path entry itself, so a final symlink is reported as `symlink` rather than followed. */
readonly stat: (path: string) => Effect.Effect<FileInfo, NotFound | Failed>
/** Follows a final symlink to the listed directory while preserving each returned entry's own type. */
readonly list: (path: string) => Effect.Effect<ReadonlyArray<DirEntry>, NotFound | WrongKind | Failed>
readonly remove: (path: string) => Effect.Effect<void, Failed>
readonly move: (from: string, to: string) => Effect.Effect<void, NotFound | Failed>
readonly mkdir: (path: string) => Effect.Effect<void, Failed>
}
export interface Files extends FilesImpl {}
/**
* Derives a follow-stat kind from the lstat-like Files contract. A dangling
* symlink fails with `NotFound`.
*/
export const typeFollowing = (files: Files, path: string) =>
files.stat(path).pipe(
Effect.flatMap((info) =>
info.type === "symlink"
? files.read(path, { offset: 0, length: 0 }).pipe(
Effect.map((result) => result.info.type),
Effect.catchTag("Environment.WrongKind", (error) => Effect.succeed(error.actual)),
)
: Effect.succeed(info.type),
),
)
export * as EnvironmentFiles from "./files"
-27
View File
@@ -1,27 +0,0 @@
export * as Environment from "./index"
export { type Driver } from "./driver"
export {
type DirEntry,
Failed,
type FileInfo,
type Files,
type FilesImpl,
type FileType,
NotFound,
typeFollowing,
WrongKind,
} from "./files"
export { execDefaults } from "./exec-defaults"
export { makeLocalDriver } from "./local"
export { makeMemoryDriver, type MemoryDriver } from "./memory"
export { type Interface, node, Service } from "./environment"
import type { Driver } from "./driver"
import { execDefaults } from "./exec-defaults"
import type { Files } from "./files"
export const makeFiles = (driver: Driver): Files => ({
...execDefaults(driver.spawner),
...driver.overrides,
})
-103
View File
@@ -1,103 +0,0 @@
import fs from "node:fs/promises"
import path from "node:path"
import { Effect } from "effect"
import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import type { Driver } from "./driver"
import { Failed, NotFound, WrongKind, type FileInfo, type FilesImpl, type FileType } from "./files"
/**
* The host filesystem binding. Deliberately raw node:fs rather than effect's
* FileSystem service or FSUtil: the contract needs lstat semantics (stat
* reports "symlink") and typed directory entries, and effect's node
* FileSystem provides neither its stat always follows symlinks and
* readDirectory returns names only. FSUtil hits the same gap and its
* readDirectoryEntries already bypasses to raw node readdir internally.
* Nothing above the environment seam touches node:fs.
*/
export const makeLocalDriver = (spawner: ChildProcessSpawner["Service"]): Driver => {
const overrides: FilesImpl = {
read: (value, range) =>
Effect.gen(function* () {
const info = yield* stat(value, true)
if (info.type !== "file") return yield* new WrongKind({ path: value, actual: info.type })
if (range === undefined) {
const bytes = yield* attempt(value, () => fs.readFile(value), true)
return { info, bytes }
}
const bytes = yield* attempt(
value,
async () => {
const handle = await fs.open(value, "r")
try {
const buffer = new Uint8Array(range.length)
const result = await handle.read(buffer, 0, range.length, range.offset)
return buffer.subarray(0, result.bytesRead)
} finally {
await handle.close()
}
},
true,
)
return { info, bytes }
}),
stat: (value) => stat(value, false),
list: (value) =>
Effect.gen(function* () {
const info = yield* stat(value, true)
if (info.type !== "directory") return yield* new WrongKind({ path: value, actual: info.type })
const entries = yield* attempt(value, () => fs.readdir(value, { withFileTypes: true }), true)
return entries.map((entry) => ({ name: entry.name, type: fileType(entry) }))
}),
write: (value, bytes) =>
attempt(value, async () => {
await fs.mkdir(path.dirname(value), { recursive: true })
await fs.writeFile(value, bytes)
}),
remove: (value) => attempt(value, () => fs.rm(value, { recursive: true, force: true })),
move: (from, to) =>
Effect.gen(function* () {
yield* stat(from, false)
const destination = yield* stat(to, false).pipe(
Effect.map((info) => (info.type === "directory" ? path.join(to, path.basename(from)) : to)),
Effect.catchIf(
(error) => error instanceof NotFound,
() => Effect.succeed(to),
),
)
yield* attempt(from, () => fs.rename(from, destination))
}),
mkdir: (value) => attempt(value, () => fs.mkdir(value, { recursive: true }).then(() => undefined)),
}
return { spawner, overrides }
}
const stat = (value: string, follow: boolean) =>
attempt(value, () => (follow ? fs.stat(value) : fs.lstat(value)), true).pipe(
Effect.map((stats): FileInfo => ({ type: fileType(stats), size: stats.size, mtimeMs: stats.mtimeMs })),
)
const fileType = (entry: { isFile(): boolean; isDirectory(): boolean; isSymbolicLink(): boolean }): FileType => {
if (entry.isFile()) return "file"
if (entry.isDirectory()) return "directory"
if (entry.isSymbolicLink()) return "symlink"
return "other"
}
function attempt<A>(value: string, run: () => Promise<A>): Effect.Effect<A, Failed>
function attempt<A>(value: string, run: () => Promise<A>, missing: true): Effect.Effect<A, NotFound | Failed>
function attempt<A>(value: string, run: () => Promise<A>, missing = false) {
return Effect.tryPromise({
try: run,
catch: (cause) =>
missing && isMissing(cause) ? new NotFound({ path: value }) : new Failed({ path: value, cause }),
})
}
const isMissing = (cause: unknown) =>
cause !== null &&
typeof cause === "object" &&
"code" in cause &&
(cause.code === "ENOENT" || cause.code === "ENOTDIR")
export * as EnvironmentLocal from "./local"
-168
View File
@@ -1,168 +0,0 @@
import path from "node:path"
import { Effect, PlatformError } from "effect"
import { make } from "effect/unstable/process/ChildProcessSpawner"
import type { Driver } from "./driver"
import { Failed, NotFound, WrongKind, type FileInfo, type FilesImpl, type FileType } from "./files"
type Node =
| { readonly type: "file"; readonly bytes: Uint8Array; readonly mtimeMs: number }
| { readonly type: "directory"; readonly mtimeMs: number }
| { readonly type: "symlink"; readonly target: string; readonly mtimeMs: number }
export interface MemoryDriver extends Driver {
readonly symlink: (target: string, path: string) => Effect.Effect<void, Failed>
}
export const makeMemoryDriver = (): MemoryDriver => {
const nodes = new Map<string, Node>([["/", { type: "directory", mtimeMs: Date.now() }]])
const key = (value: string) => path.posix.resolve("/", value)
const info = (node: Node): FileInfo => ({
type: node.type,
size:
node.type === "file"
? node.bytes.length
: node.type === "symlink"
? new TextEncoder().encode(node.target).length
: 0,
mtimeMs: node.mtimeMs,
})
const resolveKey = (value: string, followFinal: boolean, seen = new Set<string>()): string | undefined => {
const normalized = key(value)
const parts = normalized.split("/").filter(Boolean)
const base = "/"
const walk = (current: string, index: number): string | undefined => {
if (index === parts.length) return current
const part = parts[index]
const candidate = path.posix.join(current, part)
const node = nodes.get(candidate)
if (node?.type !== "symlink" || (!followFinal && index === parts.length - 1)) return walk(candidate, index + 1)
if (seen.has(candidate)) return undefined
seen.add(candidate)
const target = path.posix.resolve(path.posix.dirname(candidate), node.target)
return resolveKey(path.posix.join(target, ...parts.slice(index + 1)), followFinal, seen)
}
return walk(base, 0)
}
const lookup = (value: string) => nodes.get(resolveKey(value, false) ?? key(value))
const requireParent = (value: string) => {
const parentPath = path.posix.dirname(key(value))
const parent = nodes.get(resolveKey(parentPath, true) ?? parentPath)
if (!parent) throw new Error(`Parent directory does not exist: ${path.posix.dirname(value)}`)
if (parent.type !== "directory") throw new Error(`Parent is not a directory: ${path.posix.dirname(value)}`)
}
const mkdirSync = (value: string) => {
const target = resolveKey(value, false) ?? key(value)
const existing = nodes.get(target)
if (existing?.type === "directory") return
if (existing) throw new Error(`Path is not a directory: ${value}`)
const parent = path.posix.dirname(target)
if (parent !== target) mkdirSync(parent)
nodes.set(target, { type: "directory", mtimeMs: Date.now() })
}
const failed = (value: string, cause: unknown) => new Failed({ path: value, cause })
const overrides: FilesImpl = {
stat: (value) => {
const node = lookup(value)
return node ? Effect.succeed(info(node)) : Effect.fail(new NotFound({ path: value }))
},
read: (value, range) => {
const original = lookup(value)
if (!original) return Effect.fail(new NotFound({ path: value }))
if (original.type === "directory") return Effect.fail(new WrongKind({ path: value, actual: "directory" }))
const resolved = resolveKey(value, true)
const node = resolved === undefined ? undefined : nodes.get(resolved)
if (!node) return Effect.fail(new NotFound({ path: value }))
if (node.type !== "file") return Effect.fail(new WrongKind({ path: value, actual: node.type }))
const bytes = range === undefined ? node.bytes : node.bytes.subarray(range.offset, range.offset + range.length)
return Effect.succeed({ info: info(node), bytes: bytes.slice() })
},
write: (value, bytes) =>
Effect.try({
try: () => {
mkdirSync(path.posix.dirname(key(value)))
const existing = lookup(value)
if (existing?.type === "directory") throw new Error(`Path is a directory: ${value}`)
const target = existing?.type === "symlink" ? resolveKey(value, true) : resolveKey(value, false)
if (!target) throw new Error(`Cannot resolve symlink: ${value}`)
requireParent(target)
nodes.set(target, { type: "file", bytes: bytes.slice(), mtimeMs: Date.now() })
},
catch: (cause) => failed(value, cause),
}),
list: (value) => {
const target = resolveKey(value, true) ?? key(value)
const node = nodes.get(target)
if (!node) return Effect.fail(new NotFound({ path: value }))
if (node.type !== "directory") return Effect.fail(new WrongKind({ path: value, actual: node.type }))
const entries = [...nodes.entries()]
.filter(([entry]) => entry !== target && path.posix.dirname(entry) === target)
.map(([entry, child]) => ({ name: path.posix.basename(entry), type: child.type satisfies FileType }))
.sort((a, b) => a.name.localeCompare(b.name))
return Effect.succeed(entries)
},
remove: (value) =>
Effect.sync(() => {
const target = resolveKey(value, false) ?? key(value)
for (const entry of nodes.keys()) {
if (entry === target || entry.startsWith(`${target}/`)) nodes.delete(entry)
}
}),
move: (from, to) => {
const source = resolveKey(from, false) ?? key(from)
const node = nodes.get(source)
if (!node) return Effect.fail(new NotFound({ path: from }))
return Effect.try({
try: () => {
const requested = resolveKey(to, false) ?? key(to)
const destination =
nodes.get(requested)?.type === "directory"
? path.posix.join(requested, path.posix.basename(source))
: requested
if (node.type === "directory" && destination.startsWith(`${source}/`)) {
throw new Error(`Cannot move a directory into itself: ${from}`)
}
const existing = nodes.get(destination)
if (node.type === "directory" && existing && existing.type !== "directory") {
throw new Error(`Cannot overwrite a non-directory with a directory: ${to}`)
}
requireParent(destination)
const moved = [...nodes.entries()].filter(([entry]) => entry === source || entry.startsWith(`${source}/`))
for (const [entry] of moved) nodes.delete(entry)
for (const [entry, child] of moved) nodes.set(`${destination}${entry.slice(source.length)}`, child)
},
catch: (cause) => failed(from, cause),
})
},
mkdir: (value) => Effect.try({ try: () => mkdirSync(value), catch: (cause) => failed(value, cause) }),
}
const spawner = make((command) =>
Effect.suspend(() => {
const description = command._tag === "StandardCommand" ? command.command : "pipeline"
return Effect.fail(
PlatformError.systemError({
_tag: "Unknown",
module: "EnvironmentMemory",
method: "spawn",
pathOrDescriptor: description,
cause: failed(description, new Error("The memory driver cannot spawn processes")),
}),
)
}),
)
return {
spawner,
overrides,
symlink: (target, value) =>
Effect.try({
try: () => {
requireParent(value)
nodes.set(resolveKey(value, false) ?? key(value), { type: "symlink", target, mtimeMs: Date.now() })
},
catch: (cause) => failed(value, cause),
}),
}
}
export * as EnvironmentMemory from "./memory"
+12 -48
View File
@@ -5,8 +5,6 @@ import { Context, Effect, Layer } from "effect"
import { KeyedMutex } from "./effect/keyed-mutex"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Bom } from "@opencode-ai/util/bom"
import { Environment } from "./environment"
import type { Files } from "./environment"
export interface Target {
readonly absolute: string
@@ -31,36 +29,13 @@ export interface WriteResult {
}
export interface Interface {
/** Serialize a complete read/prepare/write mutation transaction by resolved path. */
readonly withLock: (
targets: ReadonlyArray<string>,
) => <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
readonly write: (input: WriteInput) => Effect.Effect<WriteResult, Environment.Failed>
readonly write: (input: WriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
/** Write text while retaining an existing UTF-8 BOM and emitting at most one BOM. */
readonly writeTextPreservingBom: (
input: TextWriteInput,
) => Effect.Effect<WriteResult, Environment.WrongKind | Environment.Failed>
readonly writeTextPreservingBom: (input: TextWriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/FileMutation") {}
export const readText = Effect.fn("FileMutation.readText")(function* (files: Files, target: string) {
return Bom.decodeBytes((yield* files.read(target)).bytes)
})
export const syncTextBom = Effect.fn("FileMutation.syncTextBom")(function* (
files: Files,
target: string,
bom: boolean,
) {
const synced = Bom.syncBytes((yield* files.read(target)).bytes, bom)
if (synced.bytes) yield* files.write(target, synced.bytes)
return synced.text
})
/** Share transaction locks across Location graphs that address the same file. */
const transactionLocks = KeyedMutex.makeUnsafe<string>()
/**
* Serialize file changes by absolute target. Conditional writes compare and
* write under the same process-local lock so cooperating OpenCode mutations do
@@ -69,12 +44,8 @@ const transactionLocks = KeyedMutex.makeUnsafe<string>()
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const environment = yield* Environment.Service
const fs = yield* FSUtil.Service
const locks = KeyedMutex.makeUnsafe<string>()
const withLock: Interface["withLock"] = (targets) => (effect) =>
[...new Set(targets.map(FSUtil.resolve))]
.sort()
.reduceRight((result, target) => transactionLocks.withLock(target)(result), effect)
const withTargetLock =
(target: Target) =>
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
@@ -90,14 +61,8 @@ const layer = Layer.effect(
const write = Effect.fn("FileMutation.write")((input: WriteInput) =>
withTargetLock(input.target)(
Effect.gen(function* () {
const existed = yield* environment.files.stat(input.target.absolute).pipe(
Effect.as(true),
Effect.catchTag("Environment.NotFound", () => Effect.succeed(false)),
)
yield* environment.files.write(
input.target.absolute,
typeof input.content === "string" ? new TextEncoder().encode(input.content) : input.content,
)
const existed = yield* fs.exists(input.target.absolute)
yield* fs.writeWithDirs(input.target.absolute, input.content)
return writeResult(input.target, existed)
}),
),
@@ -107,24 +72,23 @@ const layer = Layer.effect(
withTargetLock(input.target)(
Effect.gen(function* () {
const next = Bom.split(input.content)
const current = yield* environment.files.read(input.target.absolute, { offset: 0, length: 3 }).pipe(
Effect.map((result) => result.bytes),
Effect.catchTag("Environment.NotFound", () => Effect.succeed(undefined)),
)
yield* environment.files.write(
const current = yield* fs
.readFile(input.target.absolute)
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
yield* fs.writeWithDirs(
input.target.absolute,
new TextEncoder().encode(Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom)),
Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom),
)
return writeResult(input.target, current !== undefined)
}),
),
)
return Service.of({ withLock, write, writeTextPreservingBom })
return Service.of({ write, writeTextPreservingBom })
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [Environment.node] })
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] })
/**
* Deferred until the corresponding integrations exist.
@@ -11,6 +11,15 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Git } from "../git"
import { Location } from "../location"
import { Watcher } from "./watcher"
import { Ignore } from "./ignore"
import { Protected } from "./protected"
function protecteds(dir: string) {
return Protected.paths().filter((item) => {
const relative = path.relative(dir, item)
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative)
})
}
export interface Interface {}
@@ -35,6 +44,19 @@ const layer = Layer.effect(
const config = (yield* configService.entries())
.filter((entry): entry is Document => entry.type === "document")
.flatMap((item) => item.info.watcher?.ignore ?? [])
const home = Protected.isHome(location.directory)
if (!home && location.vcs) {
const updates = yield* watcher.subscribe({
path: location.directory,
type: "directory",
ignore: [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)],
})
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped)
}
if (home) {
yield* Effect.logInfo("location watcher skipped home directory", { directory: location.directory })
}
if (location.vcs?.type === "git") {
const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory
@@ -42,7 +64,10 @@ const layer = Layer.effect(
? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved)))
: undefined
if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) {
const updates = yield* watcher.subscribe({ path: path.join(vcs, "HEAD"), type: "file" })
const ignore = (yield* fs.readDirectoryEntries(vcs).pipe(Effect.catch(() => Effect.succeed([])))).flatMap(
(entry) => (entry.name === "HEAD" ? [] : [entry.name]),
)
const updates = yield* watcher.subscribe({ path: vcs, type: "directory", ignore })
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped)
}
}
-2
View File
@@ -8,7 +8,6 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Node } from "@opencode-ai/util/effect/app-node"
import { Bus } from "./bus"
import { FileMutation } from "./file-mutation"
import { Environment } from "./environment"
import { Formatter } from "./formatter"
import { FileSystem } from "./filesystem"
import { FileSystemSearch } from "./filesystem/search"
@@ -54,7 +53,6 @@ export { LocationServiceMap } from "./location-service-map"
const locationServiceNodes = [
Location.node,
Environment.node,
Config.node,
Agent.node,
Command.node,
-3
View File
@@ -16,7 +16,6 @@ import { ConfigReferencePlugin } from "../config/plugin/reference"
import { ConfigSkillPlugin } from "../config/plugin/skill"
import { ConfigWebSearchPlugin } from "../config/plugin/websearch"
import { Bus } from "../bus"
import { Environment } from "../environment"
import { FileMutation } from "../file-mutation"
import { Formatter } from "../formatter"
import { Form } from "../form"
@@ -71,7 +70,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const config = yield* Config.Service
const credential = yield* Credential.Service
const bus = yield* Bus.Service
const environment = yield* Environment.Service
const mutation = yield* FileMutation.Service
const formatter = yield* Formatter.Service
const filesystem = yield* FileSystem.Service
@@ -104,7 +102,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(Config.Service, config),
Context.make(Credential.Service, credential),
Context.make(Bus.Service, bus),
Context.make(Environment.Service, environment),
Context.make(FileMutation.Service, mutation),
Context.make(Formatter.Service, formatter),
Context.make(FileSystem.Service, filesystem),
+1 -5
View File
@@ -14,7 +14,6 @@ import { Credential } from "../credential"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
import { Bus } from "../bus"
import { Environment } from "../environment"
import { FileMutation } from "../file-mutation"
import { Formatter } from "../formatter"
import { FileSystem } from "../filesystem"
@@ -283,9 +282,7 @@ const layer = Layer.effect(
})
const updates = Stream.merge(
config.changes().pipe(
Stream.filterEffect((update) =>
Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path)),
),
Stream.filterEffect((update) => Effect.map(config.entries(), (entries) => isPluginSource(entries, update.path))),
Stream.merge(Stream.fromPubSub(configuredChanges)),
),
bus.subscribe([Event.Updated, SdkPlugins.Updated]),
@@ -323,7 +320,6 @@ export const node = makeLocationNode({
Config.node,
Credential.node,
Bus.node,
Environment.node,
FileMutation.node,
Formatter.node,
FileSystem.node,
@@ -1,84 +0,0 @@
export * as WebSearchFirecrawl from "./firecrawl"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Option, Schema, Scope } from "effect"
import { HttpClient } from "effect/unstable/http"
import { App } from "../../app"
import { WebSearchMcp } from "./mcp"
export const endpoint = "https://mcp.firecrawl.dev/v2/mcp"
const McpInput = Schema.Struct({
query: Schema.String,
limit: Schema.Number.pipe(Schema.optional),
})
const McpOutput = Schema.Struct({
content: Schema.Array(Schema.Struct({ type: Schema.Literal("text"), text: Schema.String })),
})
const SearchResponse = Schema.fromJsonString(
Schema.Struct({
success: Schema.Boolean,
data: Schema.Struct({
web: Schema.Array(
Schema.Struct({
url: Schema.String,
title: Schema.NullOr(Schema.String).pipe(Schema.optional),
description: Schema.NullOr(Schema.String).pipe(Schema.optional),
}),
),
}),
}),
)
const decodeSearchResponse = Schema.decodeUnknownOption(SearchResponse)
export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
id: "opencode.websearch.firecrawl",
effect: Effect.fn("WebSearchFirecrawl.Plugin")(function* (ctx) {
const http = yield* HttpClient.HttpClient
yield* ctx.integration.transform((draft) => {
draft.update("firecrawl", (integration) => (integration.name = "Firecrawl"))
draft.method.update({
integrationID: "firecrawl",
method: { type: "key", label: "API key (optional)" },
})
draft.method.update({
integrationID: "firecrawl",
method: { type: "env", names: ["FIRECRAWL_API_KEY"] },
})
})
yield* ctx.websearch.transform((draft) => {
draft.add({
id: "firecrawl",
name: "Firecrawl",
execute: (input) =>
Effect.gen(function* () {
const connection = yield* ctx.integration.connection.active("firecrawl")
const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined
const result = yield* WebSearchMcp.call(
http,
endpoint,
"firecrawl_search",
{ input: McpInput, output: McpOutput },
{ query: input.query, limit: 8 },
{
"User-Agent": App.useragent(ctx.app),
...(credential?.type === "key" ? { Authorization: `Bearer ${credential.key}` } : {}),
},
)
const content = result?.content.find((item) => item.text)
const response = content ? Option.getOrUndefined(decodeSearchResponse(content.text)) : undefined
return (
response?.data.web.map((item) => ({
url: item.url,
...(item.title ? { title: item.title } : {}),
...(item.description ? { content: item.description } : {}),
time: {},
})) ?? []
)
}),
})
})
}),
})
+1 -2
View File
@@ -1,5 +1,4 @@
import { WebSearchExa } from "./exa"
import { WebSearchFirecrawl } from "./firecrawl"
import { WebSearchParallel } from "./parallel"
export const WebSearchPlugins = [WebSearchExa.Plugin, WebSearchFirecrawl.Plugin, WebSearchParallel.Plugin] as const
export const WebSearchPlugins = [WebSearchExa.Plugin, WebSearchParallel.Plugin] as const
+5 -7
View File
@@ -3,9 +3,8 @@ export * as Ripgrep from "./ripgrep"
import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { Entry, Match } from "@opencode-ai/schema/filesystem"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { collectStream, waitForAbort } from "@opencode-ai/util/process"
import { Environment } from "./environment"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { AppProcess, collectStream, waitForAbort } from "@opencode-ai/util/process"
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
import { RipgrepBinary } from "./ripgrep/binary"
@@ -94,7 +93,7 @@ const isInvalidPattern = (stderr: string) =>
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const environment = yield* Environment.Service
const process = yield* AppProcess.Service
const binary = yield* RipgrepBinary.Service
const run = <A>(input: {
@@ -108,8 +107,7 @@ const layer = Layer.effect(
}) => {
const program = Effect.scoped(
Effect.gen(function* () {
// Hosted environments will resolve rg through their driver image; the spawner is the execution seam.
const handle = yield* environment.spawner.spawn(
const handle = yield* process.spawn(
ChildProcess.make(yield* binary.filepath, input.args, { cwd: input.cwd, extendEnv: true, stdin: "ignore" }),
)
const stderrFiber = yield* collectStream(handle.stderr, ERROR_BYTES).pipe(
@@ -277,4 +275,4 @@ const layer = Layer.effect(
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [Environment.node, RipgrepBinary.node] })
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [RipgrepBinary.node, AppProcess.node] })
-39
View File
@@ -133,14 +133,6 @@ export class CompactionConflictError extends Schema.TaggedErrorClass<CompactionC
export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.BusyError", {
sessionID: SessionSchema.ID,
}) {}
export class PendingInputConflictError extends Schema.TaggedErrorClass<PendingInputConflictError>()(
"Session.PendingInputConflictError",
{
sessionID: SessionSchema.ID,
inputID: SessionMessage.ID,
},
) {}
type PendingInputRef = { readonly sessionID: SessionSchema.ID; readonly inputID: SessionMessage.ID }
export class SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundError>()("Session.SkillNotFoundError", {
skill: Skill.ID,
}) {}
@@ -189,9 +181,6 @@ export interface Interface {
* unhandled compaction barriers.
*/
readonly pending: (sessionID: SessionSchema.ID) => Effect.Effect<SessionPending.Info[], NotFoundError>
readonly cancelPending: (input: PendingInputRef) => Effect.Effect<void, NotFoundError | PendingInputConflictError>
readonly steerPending: (input: PendingInputRef) => Effect.Effect<void, NotFoundError | PendingInputConflictError>
readonly queuePending: (input: PendingInputRef) => Effect.Effect<void, NotFoundError | PendingInputConflictError>
/**
* Durable, ordered session log read. Replays durable session bus after
* the exclusive `after` cursor, emits a `Synced` marker at the captured
@@ -329,31 +318,6 @@ const layer = Layer.effect(
),
)
const pendingConflict = Effect.fn("Session.pendingConflict")(function* (input: PendingInputRef) {
yield* result.get(input.sessionID)
return yield* new PendingInputConflictError(input)
})
const mutatePending = (
input: PendingInputRef,
mutation: (
bus: Bus.Interface,
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID },
) => Effect.Effect<unknown>,
wake = false,
) =>
Effect.uninterruptible(
Effect.gen(function* () {
yield* mutation(bus, { sessionID: input.sessionID, id: input.inputID }).pipe(
Effect.catchDefect((defect) =>
defect instanceof SessionPending.LifecycleConflict
? pendingConflict(input)
: Effect.die(defect),
),
)
if (wake) yield* execution.wake(input.sessionID)
}),
)
const result = Service.of({
create: Effect.fn("Session.create")(function* (input) {
const sessionID = input.id ?? SessionSchema.ID.create()
@@ -543,9 +507,6 @@ const layer = Layer.effect(
yield* result.get(sessionID)
return yield* SessionPending.list(db, sessionID)
}),
cancelPending: Effect.fn("Session.cancelPending")((input) => mutatePending(input, SessionPending.cancel)),
steerPending: Effect.fn("Session.steerPending")((input) => mutatePending(input, SessionPending.steer, true)),
queuePending: Effect.fn("Session.queuePending")((input) => mutatePending(input, SessionPending.queue)),
log: (input) =>
Stream.unwrap(
result
+1 -16
View File
@@ -2,14 +2,9 @@ export * as SessionRestart from "./restart"
import { Context, Effect, Layer } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "../../bus"
import { SessionEvent } from "../event"
import { SessionExecution } from "../execution"
import { SessionStore } from "../store"
const CONTINUE_AFTER_SERVER_RESTART =
"The server restarted while you were working. Continue from where you left off without repeating completed work."
export interface Interface {
/**
* Marks every execution active in this process for resumption by the next server start.
@@ -31,7 +26,6 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const store = yield* SessionStore.Service
const execution = yield* SessionExecution.Service
const bus = yield* Bus.Service
return Service.of({
suspendActiveSessions: Effect.gen(function* () {
yield* store.suspend(yield* execution.active)
@@ -43,11 +37,6 @@ export const layer = Layer.effect(
(sessionID) =>
Effect.gen(function* () {
if (!(yield* store.consumeSuspended(sessionID))) return
yield* bus.publish(SessionEvent.Synthetic, {
sessionID,
text: CONTINUE_AFTER_SERVER_RESTART,
description: "Continuing after restart",
})
// Drain failures are already logged and durably recorded by the execution layer.
yield* Effect.ignore(execution.resume(sessionID))
}),
@@ -58,8 +47,4 @@ export const layer = Layer.effect(
}),
)
export const node = makeGlobalNode({
service: Service,
layer,
deps: [SessionStore.node, SessionExecution.node, Bus.node],
})
export const node = makeGlobalNode({ service: Service, layer, deps: [SessionStore.node, SessionExecution.node] })
@@ -90,9 +90,6 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
"session.forked": () => Effect.void,
"session.input.promoted": () => Effect.void,
"session.input.admitted": () => Effect.void,
"session.input.cancelled": () => Effect.void,
"session.input.steered": () => Effect.void,
"session.input.queued": () => Effect.void,
"session.execution.started": () => Effect.void,
"session.execution.succeeded": () => clearCurrentRetry,
"session.execution.failed": () => clearCurrentRetry,
+4 -84
View File
@@ -37,7 +37,6 @@ const decodeSynthetic = Schema.decodeUnknownSync(SyntheticData)
const encodeSynthetic = Schema.encodeSync(SyntheticData)
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
const inboxLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
type PendingRef = { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID }
export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()(
"SessionPending.LifecycleConflict",
@@ -295,7 +294,10 @@ export const projectCompactionAdmitted = Effect.fn("SessionPending.projectCompac
*/
export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(function* (
db: DatabaseService,
input: PendingRef,
input: {
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
},
) {
if (yield* compaction(db, input.sessionID)) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
const deleted = yield* db
@@ -310,55 +312,6 @@ export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(funct
return stored
})
export const projectCancelled = Effect.fn("SessionPending.projectCancelled")(function* (
db: DatabaseService,
input: PendingRef,
) {
const deleted = yield* db
.delete(SessionPendingTable)
.where(
and(
eq(SessionPendingTable.id, input.id),
eq(SessionPendingTable.session_id, input.sessionID),
or(eq(SessionPendingTable.delivery, "queue"), eq(SessionPendingTable.delivery, "steer")),
),
)
.returning({ id: SessionPendingTable.id })
.get()
.pipe(Effect.orDie)
if (!deleted) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
})
const projectDelivery = Effect.fn("SessionPending.projectDelivery")(function* (
db: DatabaseService,
input: PendingRef & { readonly from: Delivery; readonly to: Delivery },
) {
const updated = yield* db
.update(SessionPendingTable)
.set({ delivery: input.to })
.where(
and(
eq(SessionPendingTable.id, input.id),
eq(SessionPendingTable.session_id, input.sessionID),
eq(SessionPendingTable.delivery, input.from),
),
)
.returning({ id: SessionPendingTable.id })
.get()
.pipe(Effect.orDie)
if (!updated) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
})
export const projectSteered = Effect.fn("SessionPending.projectSteered")(
(db: DatabaseService, input: PendingRef) =>
projectDelivery(db, { ...input, from: "queue", to: "steer" }),
)
export const projectQueued = Effect.fn("SessionPending.projectQueued")(
(db: DatabaseService, input: PendingRef) =>
projectDelivery(db, { ...input, from: "steer", to: "queue" }),
)
export const settleCompaction = Effect.fn("SessionPending.settleCompaction")(function* (
db: DatabaseService,
input: { readonly sessionID: SessionSchema.ID },
@@ -436,39 +389,6 @@ export const equivalent = (
return false
}
const publishMutation = <A, E, R>(input: PendingRef, effect: Effect.Effect<A, E, R>) =>
inboxLocks.withLock(input.sessionID)(effect).pipe(Effect.asVoid)
export const cancel = Effect.fn("SessionPending.cancel")((bus: Bus.Interface, input: PendingRef) =>
publishMutation(
input,
bus.publish(SessionEvent.InputCancelled, {
sessionID: input.sessionID,
inputID: input.id,
}),
),
)
export const steer = Effect.fn("SessionPending.steer")((bus: Bus.Interface, input: PendingRef) =>
publishMutation(
input,
bus.publish(SessionEvent.InputSteered, {
sessionID: input.sessionID,
inputID: input.id,
}),
),
)
export const queue = Effect.fn("SessionPending.queue")((bus: Bus.Interface, input: PendingRef) =>
publishMutation(
input,
bus.publish(SessionEvent.InputQueued, {
sessionID: input.sessionID,
inputID: input.id,
}),
),
)
const publish = Effect.fn("SessionPending.publish")(function* (
db: DatabaseService,
bus: Bus.Interface,
-18
View File
@@ -485,24 +485,6 @@ const layer = Layer.effectDiscard(
.pipe(Effect.orDie)
}),
)
yield* bus.project(SessionEvent.InputCancelled, (event) =>
SessionPending.projectCancelled(db, {
id: event.data.inputID,
sessionID: event.data.sessionID,
}),
)
yield* bus.project(SessionEvent.InputSteered, (event) =>
SessionPending.projectSteered(db, {
id: event.data.inputID,
sessionID: event.data.sessionID,
}),
)
yield* bus.project(SessionEvent.InputQueued, (event) =>
SessionPending.projectQueued(db, {
id: event.data.inputID,
sessionID: event.data.sessionID,
}),
)
yield* bus.project(SessionEvent.Compaction.Admitted, (event) =>
Effect.gen(function* () {
if (event.durable === undefined)
@@ -116,11 +116,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
})
const currentAssistantMessageID = () =>
stepStarted ? Effect.succeed(assistantMessageID) : Effect.die(new Error("Tool event before assistant step start"))
const providerState = (metadata: ProviderMetadata | undefined, itemId?: string) => {
const state = metadata?.[input.providerMetadataKey]
if (itemId === undefined) return state
return { ...(typeof state === "object" && state !== null && !Array.isArray(state) ? state : {}), itemId }
}
const providerState = (metadata: ProviderMetadata | undefined) => metadata?.[input.providerMetadataKey]
const fragments = (
name: string,
ended: (id: string, value: string, ordinal: number, state?: Record<string, unknown>) => Effect.Effect<void>,
@@ -344,7 +340,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
return
case "text-start":
outputStarted = true
const startedTextOrdinal = yield* text.start(event.id, providerState(event.providerMetadata, event.itemId))
const startedTextOrdinal = yield* text.start(event.id, providerState(event.providerMetadata))
yield* bus.publish(SessionEvent.Text.Started, {
sessionID: input.sessionID,
assistantMessageID: yield* startAssistant(),
@@ -352,11 +348,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
})
return
case "text-delta":
const deltaTextOrdinal = yield* text.append(
event.id,
event.text,
providerState(event.providerMetadata, event.itemId),
)
const deltaTextOrdinal = yield* text.append(event.id, event.text, providerState(event.providerMetadata))
yield* bus.publish(SessionEvent.Text.Delta, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
@@ -365,26 +357,23 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
})
return
case "text-end":
yield* text.end(event.id, providerState(event.providerMetadata, event.itemId))
yield* text.end(event.id, providerState(event.providerMetadata))
return
case "reasoning-start":
outputStarted = true
const startedReasoningOrdinal = yield* reasoning.start(
event.id,
providerState(event.providerMetadata, event.itemId),
)
const startedReasoningOrdinal = yield* reasoning.start(event.id, providerState(event.providerMetadata))
yield* bus.publish(SessionEvent.Reasoning.Started, {
sessionID: input.sessionID,
assistantMessageID: yield* startAssistant(),
ordinal: startedReasoningOrdinal,
state: providerState(event.providerMetadata, event.itemId),
state: providerState(event.providerMetadata),
})
return
case "reasoning-delta":
const deltaReasoningOrdinal = yield* reasoning.append(
event.id,
event.text,
providerState(event.providerMetadata, event.itemId),
providerState(event.providerMetadata),
)
yield* bus.publish(SessionEvent.Reasoning.Delta, {
sessionID: input.sessionID,
@@ -394,7 +383,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
})
return
case "reasoning-end":
yield* reasoning.end(event.id, providerState(event.providerMetadata, event.itemId))
yield* reasoning.end(event.id, providerState(event.providerMetadata))
return
case "tool-input-start":
outputStarted = true
@@ -438,7 +427,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
id: event.id,
input: asRecord(event.input),
executed: tool.providerExecuted,
state: providerState(event.providerMetadata, event.itemId),
state: providerState(event.providerMetadata),
})
return
}
@@ -456,7 +445,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
}
tool.settled = true
const executed = event.providerExecuted === true || tool.providerExecuted
const resultState = providerState(event.providerMetadata, event.itemId)
const resultState = providerState(event.providerMetadata)
if (event.result.type === "error") {
yield* bus.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID,
@@ -496,7 +485,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
: { type: "tool.execution", message: event.message },
...failureSnapshot(tool),
executed: tool.providerExecuted,
resultState: providerState(event.providerMetadata, event.itemId),
resultState: providerState(event.providerMetadata),
})
return
}
@@ -521,7 +510,8 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
const progress = Effect.fnUntraced(function* (id: string, update: Tool.Metadata) {
const tool = tools.get(id)
if (!tool?.called || tool.settled) return yield* Effect.die(new Error(`Tool progress outside running call: ${id}`))
if (!tool?.called || tool.settled)
return yield* Effect.die(new Error(`Tool progress outside running call: ${id}`))
tool.progress = update
yield* bus.publish(SessionEvent.Tool.Progress, {
sessionID: input.sessionID,
@@ -532,7 +522,11 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
})
/** Publishes one canonical terminal event for a locally executed tool call. */
const toolExecution = Effect.fnUntraced(function* (id: string, name: string, result: Tool.Result) {
const toolExecution = Effect.fnUntraced(function* (
id: string,
name: string,
result: Tool.Result,
) {
const tool = tools.get(id)
if (!tool?.called) return yield* Effect.die(new Error(`Tool execution before call: ${id}`))
if (tool.name !== name)
@@ -1,10 +1,4 @@
import {
Message,
ToolCallPart,
ToolResultPart,
type ContentPart,
type ProviderMetadata,
} from "@opencode-ai/ai"
import { Message, ToolCallPart, ToolResultPart, type ContentPart, type ProviderMetadata } from "@opencode-ai/ai"
import { Option, Schema } from "effect"
import type { Model } from "../../model"
import { SessionMessage } from "../message"
@@ -72,46 +66,27 @@ const providerMetadata = (
state: Record<string, unknown> | undefined,
): ProviderMetadata | undefined => (state === undefined ? undefined : { [provider]: state })
const responseItemID = (state: Record<string, unknown> | undefined) =>
typeof state?.itemId === "string" ? state.itemId : undefined
const portableProviderState = (state: Record<string, unknown> | undefined) => {
if (state === undefined || !("itemId" in state)) return state
const { itemId: _itemId, ...portable } = state
return portable
}
const toolInput = (tool: SessionMessage.AssistantTool) =>
tool.state.status === "streaming"
? Option.getOrElse(decodeToolInput(tool.state.input), () => tool.state.input)
: tool.state.input
const toolCall = (
tool: SessionMessage.AssistantTool,
itemId: string | undefined,
providerMetadata: ProviderMetadata | undefined,
): ContentPart =>
const toolCall = (tool: SessionMessage.AssistantTool, providerMetadata: ProviderMetadata | undefined): ContentPart =>
ToolCallPart.make({
id: tool.id,
...(itemId === undefined ? {} : { itemId }),
name: tool.name,
input: toolInput(tool),
providerExecuted: tool.executed,
providerMetadata,
})
const toolResult = (
tool: SessionMessage.AssistantTool,
itemId: string | undefined,
providerMetadata: ProviderMetadata | undefined,
) => {
const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: ProviderMetadata | undefined) => {
if (tool.state.status === "completed") {
// TODO: Materialize remote and managed URIs before provider-history lowering.
const content = tool.state.content
const single = content.length === 1 ? content[0] : undefined
return ToolResultPart.make({
id: tool.id,
...(itemId === undefined ? {} : { itemId }),
name: tool.name,
result:
single?.type === "text"
@@ -124,7 +99,6 @@ const toolResult = (
if (tool.state.status === "error") {
return ToolResultPart.make({
id: tool.id,
...(itemId === undefined ? {} : { itemId }),
name: tool.name,
result: { error: tool.state.error, content: tool.state.content ?? [] },
resultType: "error",
@@ -144,13 +118,7 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
{
type: "text",
text: item.text,
itemId: reuseProviderMetadata ? responseItemID(item.state) : undefined,
providerMetadata: sameProvider
? providerMetadata(
providerMetadataKey,
reuseProviderMetadata ? item.state : portableProviderState(item.state),
)
: undefined,
providerMetadata: sameProvider ? providerMetadata(providerMetadataKey, item.state) : undefined,
},
]
if (item.type === "reasoning")
@@ -159,7 +127,6 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
{
type: "reasoning",
text: item.text,
itemId: responseItemID(item.state),
providerMetadata: providerMetadata(providerMetadataKey, item.state),
},
]
@@ -171,7 +138,6 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
(sameModel && item.executed === true && (item.state.status === "completed" || item.state.status === "error"))
const call = toolCall(
item,
reuseToolProviderMetadata ? responseItemID(item.providerState) : undefined,
reuseToolProviderMetadata ? providerMetadata(providerMetadataKey, item.providerState) : undefined,
)
if (item.executed !== true) return [call]
@@ -179,11 +145,6 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
// replay must survive a model switch within the same provider.
const result = toolResult(
item,
reuseToolProviderMetadata
? responseItemID(item.providerResultState ?? (item.executed === true ? item.providerState : undefined))
: sameProvider && item.executed === true
? responseItemID(item.providerResultState)
: undefined,
reuseToolProviderMetadata
? providerMetadata(providerMetadataKey, item.providerResultState ?? item.providerState)
: sameProvider && item.executed === true && item.providerResultState !== undefined
@@ -202,8 +163,9 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
.map((item) =>
toolResult(
item,
responseItemID(item.providerResultState) ?? `fco_${item.id}`,
reuseProviderMetadata ? providerMetadata(providerMetadataKey, item.providerResultState) : undefined,
reuseProviderMetadata
? providerMetadata(providerMetadataKey, item.providerResultState ?? item.providerState)
: undefined,
),
)
.filter((message) => message !== undefined)
@@ -242,7 +204,7 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
case "skill":
return [Message.make({ id: message.id, role: "user", content: message.text, metadata: message.metadata })]
case "system":
return [Message.make({ id: message.id, role: "system", content: message.text })]
return [Message.system(message.text)]
case "shell":
return [
Message.make({
+270 -269
View File
@@ -6,9 +6,9 @@ import { ChildProcess } from "effect/unstable/process"
import { produce } from "immer"
import { Shell } from "@opencode-ai/schema/shell"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { AppProcess } from "@opencode-ai/util/process"
import { Config } from "./config"
import { Bus } from "./bus"
import { Environment } from "./environment"
import { Location } from "./location"
import { Global } from "@opencode-ai/util/global"
import { ShellSelect } from "./shell/select"
@@ -65,284 +65,285 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Shell") {}
export const layer = (options?: ShellSelect.Options) =>
Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const location = yield* Location.Service
const config = yield* Config.Service
const global = yield* Global.Service
const environment = yield* Environment.Service
const hooks = yield* PluginHooks.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const sessions = new Map<string, Active>()
const exitOrder: string[] = []
export const layer = (options?: ShellSelect.Options) => Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const location = yield* Location.Service
const config = yield* Config.Service
const global = yield* Global.Service
const appProcess = yield* AppProcess.Service
const hooks = yield* PluginHooks.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const sessions = new Map<string, Active>()
const exitOrder: string[] = []
const outputDir = path.join(global.data, "shell", location.project.id)
const { mkdir, unlink } = yield* Effect.promise(() => import("fs/promises"))
const { createWriteStream, createReadStream } = yield* Effect.promise(() => import("fs"))
yield* Effect.promise(() => mkdir(outputDir, { recursive: true }))
const outputDir = path.join(global.data, "shell", location.project.id)
const { mkdir, unlink } = yield* Effect.promise(() => import("fs/promises"))
const { createWriteStream, createReadStream } = yield* Effect.promise(() => import("fs"))
yield* Effect.promise(() => mkdir(outputDir, { recursive: true }))
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
for (const session of sessions.values()) {
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
// Unblock waiters still pending at teardown; succeed is a no-op once already resolved.
yield* Deferred.fail(session.done, new NotFoundError({ id: Shell.ID.make(session.info.id) }))
}
sessions.clear()
exitOrder.length = 0
}),
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
for (const session of sessions.values()) {
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
// Unblock waiters still pending at teardown; succeed is a no-op once already resolved.
yield* Deferred.fail(session.done, new NotFoundError({ id: Shell.ID.make(session.info.id) }))
}
sessions.clear()
exitOrder.length = 0
}),
)
const require = Effect.fn("Shell.require")(function* (id: Shell.ID) {
const session = sessions.get(id)
if (!session) return yield* new NotFoundError({ id })
return session
})
const removeSession = Effect.fnUntraced(function* (id: Shell.ID) {
const session = sessions.get(id)
if (!session) return
sessions.delete(id)
const index = exitOrder.indexOf(id)
if (index !== -1) exitOrder.splice(index, 1)
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
// Unblock any wait still pending when the command is removed before it terminated.
yield* Deferred.fail(session.done, new NotFoundError({ id }))
yield* Effect.promise(() => unlink(session.file).catch(() => {}))
yield* bus.publish(Shell.Event.Deleted, { id })
})
const remove = Effect.fn("Shell.remove")(function* (id: Shell.ID) {
yield* require(id)
yield* removeSession(id)
})
const list = Effect.fn("Shell.list")(function* () {
return Array.from(sessions.values())
.filter((session) => session.info.status === "running")
.map((session) => session.info)
})
const get = Effect.fn("Shell.get")(function* (id: Shell.ID) {
return (yield* require(id)).info
})
const wait = Effect.fn("Shell.wait")(function* (id: Shell.ID) {
return yield* Deferred.await((yield* require(id)).done)
})
const timeout = Effect.fn("Shell.timeout")(function* (id: Shell.ID, duration: number) {
const session = yield* require(id)
if (session.info.status !== "running" || !session.timeout) return session.info
yield* session.timeout(duration)
return session.info
})
const resolve = () =>
config
.entries()
.pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options)))
const name = () => resolve().pipe(Effect.map(ShellSelect.name))
const output = Effect.fn("Shell.output")(function* (id: Shell.ID, input?: Shell.OutputInput) {
const session = yield* require(id)
const cursor = input?.cursor ?? 0
const limit = input?.limit ?? 65536
if (cursor >= session.size) return { output: "", cursor: session.size, size: session.size, truncated: false }
const start = Math.max(0, cursor)
const length = Math.min(limit, session.size - start)
const buffer = Buffer.alloc(length)
const bytesRead = yield* Effect.promise(
() =>
new Promise<number>((resolve) => {
const stream = createReadStream(session.file, { start, end: start + length - 1 })
let offset = 0
stream.on("data", (chunk: string | Buffer) => {
const bytes = Buffer.from(chunk)
bytes.copy(buffer, offset)
offset += bytes.length
})
stream.on("end", () => resolve(offset))
stream.on("error", () => resolve(0))
}),
)
return {
output: buffer.subarray(0, bytesRead).toString("utf8"),
cursor: start + bytesRead,
size: session.size,
truncated: false,
}
})
const create = Effect.fn("Shell.create")(function* <E = never, R = never>(
input: Shell.CreateInput,
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
) {
const invocation: ShellCreateBefore = {
command: input.command,
cwd: input.cwd ?? location.directory,
timeout: input.timeout,
shell: yield* resolve(),
env: {
...process.env,
TERM: "xterm-256color",
OPENCODE_TERMINAL: "1",
},
}
yield* hooks.trigger("shell", "create.before", invocation)
if (before) yield* before(invocation)
const id = Shell.ID.ascending()
const args = ShellSelect.args(invocation.shell, invocation.command)
const file = path.join(outputDir, `${id}.out`)
const info: Info = {
id,
status: "running",
command: invocation.command,
cwd: invocation.cwd,
shell: invocation.shell,
file,
metadata: input.metadata ?? {},
time: { started: Date.now() },
}
// Spawn via AppProcess and stream combined output to the file. The handle is scope-bound, so
// the managing fiber keeps its scope open until the command terminates (it awaits `done` at the
// end). `create` returns once `ready` resolves with the registered session.
const ready = Deferred.makeUnsafe<Active>()
runFork(
Effect.scoped(
Effect.gen(function* () {
const handle = yield* appProcess.spawn(
ChildProcess.make(invocation.shell, args, {
cwd: invocation.cwd,
env: invocation.env,
stdin: "ignore",
detached: process.platform !== "win32",
forceKillAfter: Duration.seconds(3),
}),
)
const session: Active = {
info: produce(info, (draft) => {
draft.pid = handle.pid
}),
file,
size: 0,
done: Deferred.makeUnsafe<Info, NotFoundError>(),
}
sessions.set(id, session)
const stream = createWriteStream(file)
const outputDone = Deferred.makeUnsafe<void>()
const pump = handle.all.pipe(
Stream.runForEach((chunk: Uint8Array) =>
Effect.sync(() => {
stream.write(chunk)
session.size += chunk.length
}),
),
)
runFork(
Effect.gen(function* () {
yield* pump.pipe(Effect.catch(() => Effect.void))
yield* Effect.promise(
() =>
new Promise<void>((resolve) => {
stream.end(() => resolve())
}),
)
yield* Deferred.succeed(outputDone, undefined)
}).pipe(Effect.catch(() => Deferred.succeed(outputDone, undefined))),
)
yield* Effect.promise(
() =>
new Promise<void>((resolve) => {
stream.once("open", () => resolve())
stream.once("error", () => resolve())
}),
)
const finish = (status: Info["status"], exit?: number, beforeWait = Effect.void) =>
Effect.gen(function* () {
if (session.info.status !== "running") return
session.info = produce(session.info, (draft) => {
draft.status = status
if (exit !== undefined) draft.exit = exit
draft.time.completed = Date.now()
})
yield* beforeWait
yield* Deferred.await(outputDone)
// Resolve waiters with the terminal Info before any retention eviction, so an evicted
// session still reports success rather than the removal NotFoundError. This runs before
// the timeout-fiber interrupt below, which on the timeout path would otherwise cancel
// this very fiber (finish is invoked by the timeout fiber) before waiters are resolved.
yield* Deferred.succeed(session.done, session.info)
yield* bus.publish(Shell.Event.Exited, {
id,
...(exit !== undefined ? { exit } : {}),
status,
})
exitOrder.push(id)
while (exitOrder.length > EXITED_LIMIT) {
const oldest = exitOrder[0]
if (!oldest) break
yield* removeSession(Shell.ID.make(oldest))
}
// Cancel a pending timeout once the command exits on its own. Interrupting last avoids
// aborting finish when finish itself runs on the timeout fiber.
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
})
session.timeout = (duration) =>
Effect.gen(function* () {
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
session.timeoutFiber = undefined
if (duration === 0 || session.info.status !== "running") return
session.timeoutFiber = runFork(
Effect.sleep(Duration.millis(duration)).pipe(
Effect.flatMap(() =>
finish("timeout", undefined, handle.kill().pipe(Effect.catch(() => Effect.void))),
),
Effect.catch(() => Effect.void),
),
)
})
yield* session.timeout(invocation.timeout)
runFork(
handle.exitCode.pipe(
Effect.flatMap((code) => finish("exited", code)),
Effect.catch(() => Effect.void),
),
)
yield* bus.publish(Shell.Event.Created, { info })
yield* Deferred.succeed(ready, session)
// Hold the handle's scope open until the command terminates; closing it earlier would
// release (kill) the process before its exit is observed.
yield* Deferred.await(session.done).pipe(Effect.catch(() => Effect.void))
}),
).pipe(Effect.catch(() => Effect.void)),
)
const require = Effect.fn("Shell.require")(function* (id: Shell.ID) {
const session = sessions.get(id)
if (!session) return yield* new NotFoundError({ id })
return session
})
const session = yield* Deferred.await(ready)
return session.info
})
const removeSession = Effect.fnUntraced(function* (id: Shell.ID) {
const session = sessions.get(id)
if (!session) return
sessions.delete(id)
const index = exitOrder.indexOf(id)
if (index !== -1) exitOrder.splice(index, 1)
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
// Unblock any wait still pending when the command is removed before it terminated.
yield* Deferred.fail(session.done, new NotFoundError({ id }))
yield* Effect.promise(() => unlink(session.file).catch(() => {}))
yield* bus.publish(Shell.Event.Deleted, { id })
})
const remove = Effect.fn("Shell.remove")(function* (id: Shell.ID) {
yield* require(id)
yield* removeSession(id)
})
const list = Effect.fn("Shell.list")(function* () {
return Array.from(sessions.values())
.filter((session) => session.info.status === "running")
.map((session) => session.info)
})
const get = Effect.fn("Shell.get")(function* (id: Shell.ID) {
return (yield* require(id)).info
})
const wait = Effect.fn("Shell.wait")(function* (id: Shell.ID) {
return yield* Deferred.await((yield* require(id)).done)
})
const timeout = Effect.fn("Shell.timeout")(function* (id: Shell.ID, duration: number) {
const session = yield* require(id)
if (session.info.status !== "running" || !session.timeout) return session.info
yield* session.timeout(duration)
return session.info
})
const resolve = () =>
config.entries().pipe(Effect.map((entries) => ShellSelect.preferred(Config.latest(entries, "shell"), options)))
const name = () => resolve().pipe(Effect.map(ShellSelect.name))
const output = Effect.fn("Shell.output")(function* (id: Shell.ID, input?: Shell.OutputInput) {
const session = yield* require(id)
const cursor = input?.cursor ?? 0
const limit = input?.limit ?? 65536
if (cursor >= session.size) return { output: "", cursor: session.size, size: session.size, truncated: false }
const start = Math.max(0, cursor)
const length = Math.min(limit, session.size - start)
const buffer = Buffer.alloc(length)
const bytesRead = yield* Effect.promise(
() =>
new Promise<number>((resolve) => {
const stream = createReadStream(session.file, { start, end: start + length - 1 })
let offset = 0
stream.on("data", (chunk: string | Buffer) => {
const bytes = Buffer.from(chunk)
bytes.copy(buffer, offset)
offset += bytes.length
})
stream.on("end", () => resolve(offset))
stream.on("error", () => resolve(0))
}),
)
return {
output: buffer.subarray(0, bytesRead).toString("utf8"),
cursor: start + bytesRead,
size: session.size,
truncated: false,
}
})
const create = Effect.fn("Shell.create")(function* <E = never, R = never>(
input: Shell.CreateInput,
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
) {
const invocation: ShellCreateBefore = {
command: input.command,
cwd: input.cwd ?? location.directory,
timeout: input.timeout,
shell: yield* resolve(),
env: {
...process.env,
TERM: "xterm-256color",
OPENCODE_TERMINAL: "1",
},
}
yield* hooks.trigger("shell", "create.before", invocation)
if (before) yield* before(invocation)
const id = Shell.ID.ascending()
const args = ShellSelect.args(invocation.shell, invocation.command)
const file = path.join(outputDir, `${id}.out`)
const info: Info = {
id,
status: "running",
command: invocation.command,
cwd: invocation.cwd,
shell: invocation.shell,
file,
metadata: input.metadata ?? {},
time: { started: Date.now() },
}
// Spawn through the Environment and stream combined output to the file. The handle is scope-bound, so
// the managing fiber keeps its scope open until the command terminates (it awaits `done` at the
// end). `create` returns once `ready` resolves with the registered session.
const ready = Deferred.makeUnsafe<Active>()
runFork(
Effect.scoped(
Effect.gen(function* () {
const handle = yield* environment.spawner.spawn(
ChildProcess.make(invocation.shell, args, {
cwd: invocation.cwd,
env: invocation.env,
stdin: "ignore",
detached: process.platform !== "win32",
forceKillAfter: Duration.seconds(3),
}),
)
const session: Active = {
info: produce(info, (draft) => {
draft.pid = handle.pid
}),
file,
size: 0,
done: Deferred.makeUnsafe<Info, NotFoundError>(),
}
sessions.set(id, session)
const stream = createWriteStream(file)
const outputDone = Deferred.makeUnsafe<void>()
const pump = handle.all.pipe(
Stream.runForEach((chunk: Uint8Array) =>
Effect.sync(() => {
stream.write(chunk)
session.size += chunk.length
}),
),
)
runFork(
Effect.gen(function* () {
yield* pump.pipe(Effect.catch(() => Effect.void))
yield* Effect.promise(
() =>
new Promise<void>((resolve) => {
stream.end(() => resolve())
}),
)
yield* Deferred.succeed(outputDone, undefined)
}).pipe(Effect.catch(() => Deferred.succeed(outputDone, undefined))),
)
yield* Effect.promise(
() =>
new Promise<void>((resolve) => {
stream.once("open", () => resolve())
stream.once("error", () => resolve())
}),
)
const finish = (status: Info["status"], exit?: number, beforeWait = Effect.void) =>
Effect.gen(function* () {
if (session.info.status !== "running") return
session.info = produce(session.info, (draft) => {
draft.status = status
if (exit !== undefined) draft.exit = exit
draft.time.completed = Date.now()
})
yield* beforeWait
yield* Deferred.await(outputDone)
// Resolve waiters with the terminal Info before any retention eviction, so an evicted
// session still reports success rather than the removal NotFoundError. This runs before
// the timeout-fiber interrupt below, which on the timeout path would otherwise cancel
// this very fiber (finish is invoked by the timeout fiber) before waiters are resolved.
yield* Deferred.succeed(session.done, session.info)
yield* bus.publish(Shell.Event.Exited, {
id,
...(exit !== undefined ? { exit } : {}),
status,
})
exitOrder.push(id)
while (exitOrder.length > EXITED_LIMIT) {
const oldest = exitOrder[0]
if (!oldest) break
yield* removeSession(Shell.ID.make(oldest))
}
// Cancel a pending timeout once the command exits on its own. Interrupting last avoids
// aborting finish when finish itself runs on the timeout fiber.
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
})
session.timeout = (duration) =>
Effect.gen(function* () {
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
session.timeoutFiber = undefined
if (duration === 0 || session.info.status !== "running") return
session.timeoutFiber = runFork(
Effect.sleep(Duration.millis(duration)).pipe(
Effect.flatMap(() =>
finish("timeout", undefined, handle.kill().pipe(Effect.catch(() => Effect.void))),
),
Effect.catch(() => Effect.void),
),
)
})
yield* session.timeout(invocation.timeout)
runFork(
handle.exitCode.pipe(
Effect.flatMap((code) => finish("exited", code)),
Effect.catch(() => Effect.void),
),
)
yield* bus.publish(Shell.Event.Created, { info })
yield* Deferred.succeed(ready, session)
// Hold the handle's scope open until the command terminates; closing it earlier would
// release (kill) the process before its exit is observed.
yield* Deferred.await(session.done).pipe(Effect.catch(() => Effect.void))
}),
).pipe(Effect.catch(() => Effect.void)),
)
const session = yield* Deferred.await(ready)
return session.info
})
return Service.of({ name, create, list, get, wait, timeout, output, remove })
}),
)
return Service.of({ name, create, list, get, wait, timeout, output, remove })
}),
)
export function configured(options?: ShellSelect.Options) {
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [Bus.node, Location.node, Config.node, Global.node, Environment.node, PluginHooks.node],
deps: [Bus.node, Location.node, Config.node, Global.node, AppProcess.node, PluginHooks.node],
})
}
+34 -105
View File
@@ -2,7 +2,8 @@ export * as Skill from "./skill"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import path from "path"
import { Context, Effect, FiberMap, Layer, PubSub, Schema, Semaphore, Stream, Types } from "effect"
import { Context, Effect, Layer, Schema, Stream, Types } from "effect"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { Skill } from "@opencode-ai/schema/skill"
import { Agent } from "./agent"
import { ConfigMarkdown } from "./config/markdown"
@@ -12,7 +13,6 @@ import { Permission } from "./permission"
import { AbsolutePath } from "./schema"
import { SkillDiscovery } from "./skill/discovery"
import { State } from "./state"
import { Watcher } from "./filesystem/watcher"
export const DirectorySource = Skill.DirectorySource
export type DirectorySource = Skill.DirectorySource
@@ -81,82 +81,6 @@ const layer = Layer.effect(
const discovery = yield* SkillDiscovery.Service
const fs = yield* FSUtil.Service
const bus = yield* Bus.Service
const watcher = yield* Watcher.Service
const cache = new Map<string, { skills: Info[]; paths: readonly string[] }>()
const watches = yield* FiberMap.make<string>()
const lock = Semaphore.makeUnsafe(1)
const changes = yield* PubSub.unbounded<string>()
const invalidate = Effect.fn("Skill.invalidateFromWatcher")(function* (file: string) {
const changed = yield* lock.withPermit(
Effect.gen(function* () {
const invalidated = Array.from(cache.entries()).filter(([, loaded]) =>
loaded.paths.some((item) => FSUtil.overlaps(item, file)),
)
if (invalidated.length === 0) return false
cache.clear()
yield* FiberMap.clear(watches)
yield* Effect.logInfo("skill cache invalidated", {
file,
sources: invalidated.map(([key]) => key),
skills: invalidated.flatMap(([, loaded]) => loaded.skills.map((skill) => skill.id)),
})
return true
}),
)
if (!changed) return
yield* bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid)
})
yield* Stream.fromPubSub(changes).pipe(Stream.runForEach(invalidate), Effect.forkScoped({ startImmediately: true }))
const watch = Effect.fn("Skill.watch")(function* (directory: string, type: Watcher.WatchInput["type"]) {
const target = path.resolve(directory)
const updates = yield* watcher.subscribe(
type === "file" ? { path: target, type: "file" } : { path: target, type: "directory" },
)
yield* FiberMap.run(
watches,
`${type}:${target}`,
updates.pipe(Stream.runForEach((update) => PubSub.publish(changes, update.path).pipe(Effect.asVoid))),
{
onlyIfMissing: true,
startImmediately: true,
},
)
})
function firstMissing(target: string): Effect.Effect<string | undefined> {
const parent = path.dirname(target)
if (parent === target) return Effect.succeed(undefined)
return fs.isDir(parent).pipe(Effect.flatMap((exists) => (exists ? Effect.succeed(target) : firstMissing(parent))))
}
const watchDirectory: (directory: string) => Effect.Effect<string[]> = Effect.fn("Skill.watchDirectory")(function* (
directory: string,
) {
const target = path.resolve(directory)
const resolved = yield* fs.realPath(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (resolved) {
yield* watch(resolved, "directory")
if (resolved !== target) {
yield* watch(target, "file")
}
return resolved === target ? [target] : [target, resolved]
}
const missing = yield* firstMissing(target)
if (missing) yield* watch(missing, "file")
if (
yield* fs.realPath(directory).pipe(
Effect.as(true),
Effect.catch(() => Effect.succeed(false)),
)
) {
if (missing) yield* FiberMap.remove(watches, `file:${path.resolve(missing)}`)
return yield* watchDirectory(directory)
}
return [target]
})
const state = State.create<Data, Draft>({
name: "skill",
@@ -168,10 +92,7 @@ const layer = Layer.effect(
},
list: () => draft.sources as Source[],
}),
finalize: () =>
lock
.withPermit(FiberMap.clear(watches).pipe(Effect.andThen(Effect.sync(() => cache.clear())), Effect.asVoid))
.pipe(Effect.andThen(bus.publish(Skill.Event.Updated, {})), Effect.asVoid),
finalize: () => bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid),
})
const load = Effect.fn("Skill.load")(function* (source: Source) {
@@ -183,22 +104,14 @@ const layer = Layer.effect(
directories: [],
skills: [source.skill.id],
})
return { skills: [source.skill], paths: [] }
return { skills: [source.skill], directories: [] }
}
const directories = source.type === "directory" ? [source.path] : yield* discovery.pull(source.url)
const roots = (yield* Effect.forEach(directories, watchDirectory)).flat()
const paths = [...roots]
for (const directory of directories) {
const files = yield* fs
.scan("{*.md,**/SKILL.md}", { cwd: directory, absolute: true, include: "file", symlink: true, dot: true })
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
for (const filepath of files.toSorted()) {
const resolved = yield* fs.realPath(filepath).pipe(Effect.catch(() => Effect.succeed(filepath)))
if (!roots.some((root) => FSUtil.contains(root, resolved))) {
const external = path.dirname(resolved)
paths.push(external)
yield* watch(external, "directory")
}
const content = yield* fs.readFileStringSafe(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!content) continue
const markdown = ConfigMarkdown.parseOption(content)
@@ -226,22 +139,38 @@ const layer = Layer.effect(
directories,
skills: skills.map((skill) => skill.id),
})
return { skills, paths }
return { skills, directories }
})
const list = Effect.fn("Skill.list")(function* () {
return yield* lock.withPermit(
Effect.gen(function* () {
const skills = new Map<ID, Info>()
for (const source of state.get().sources) {
const key = Source.key(source)
const loaded = cache.get(key) ?? (yield* load(source))
cache.set(key, loaded)
for (const skill of loaded.skills) skills.set(skill.id, skill)
}
return Array.from(skills.values())
}),
const cache = new Map<string, { skills: Info[]; directories: readonly string[] }>()
const invalidate = Effect.fn("Skill.invalidateFromWatcher")(function* (file: string) {
const invalidated = Array.from(cache.entries()).filter(([, loaded]) =>
loaded.directories.some((directory) => FSUtil.contains(directory, file)),
)
if (invalidated.length === 0) return
for (const [key] of invalidated) cache.delete(key)
yield* Effect.logInfo("skill cache invalidated", {
file,
sources: invalidated.map(([key]) => key),
skills: invalidated.flatMap(([, loaded]) => loaded.skills.map((skill) => skill.id)),
})
yield* bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid)
})
yield* bus.subscribe(FileSystem.Event.Changed).pipe(
Stream.runForEach((event) => invalidate(event.data.file)),
Effect.forkScoped({ startImmediately: true }),
)
const list = Effect.fn("Skill.list")(function* () {
const skills = new Map<ID, Info>()
for (const source of state.get().sources) {
const key = Source.key(source)
const loaded = cache.get(key) ?? (yield* load(source))
cache.set(key, loaded)
for (const skill of loaded.skills) skills.set(skill.id, skill)
}
return Array.from(skills.values())
})
return Service.of({
@@ -258,5 +187,5 @@ const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [SkillDiscovery.node, FSUtil.node, Bus.node, Watcher.node],
deps: [SkillDiscovery.node, FSUtil.node, Bus.node],
})
+2 -1
View File
@@ -118,12 +118,13 @@ const layer = Layer.effect(
yield* hooks.trigger("tool", "execute.after", afterEvent)
return yield* afterEvent.error
}
const content = yield* normalizeImages(execution.value.content)
const afterEvent: PluginHooks.Domains["tool"]["execute.after"] = {
...base,
status: "completed",
result: {
...(execution.value.output === undefined ? {} : { output: execution.value.output }),
content: execution.value.content,
content: content.length > 0 ? content : execution.value.content,
...(execution.value.metadata === undefined ? {} : { metadata: execution.value.metadata }),
},
}
+17 -20
View File
@@ -11,11 +11,9 @@ import { ToolFailure } from "@opencode-ai/ai"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { Bom } from "@opencode-ai/util/bom"
import { Effect, Schema } from "effect"
import path from "path"
import { Environment } from "../../environment"
import { FileMutation } from "../../file-mutation"
import { Formatter } from "../../formatter"
import { Location } from "../../location"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { LocationMutation } from "../../location-mutation"
import { Permission } from "../../permission"
import { fileDiff } from "./file-diff"
@@ -111,10 +109,9 @@ export const Plugin = {
id: "opencode.tool.edit",
effect: Effect.fn("EditTool.Plugin")(function* (ctx: PluginContext) {
const mutation = yield* LocationMutation.Service
const fileMutation = yield* FileMutation.Service
const environment = yield* Environment.Service
const files = yield* FileMutation.Service
const formatter = yield* Formatter.Service
const location = yield* Location.Service
const fs = yield* FSUtil.Service
const permission = yield* Permission.Service
yield* ctx.tool
@@ -155,16 +152,17 @@ export const Plugin = {
})
}
const original = yield* FileMutation.readText(environment.files, target.absolute).pipe(
Effect.catchTag("Environment.NotFound", () =>
Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })),
),
Effect.catchTag("Environment.WrongKind", (error) =>
error.actual === "directory"
? Effect.fail(new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` }))
: Effect.fail(new ToolFailure({ message: `Unable to edit ${input.path}`, error })),
),
)
const info = yield* fs
.stat(target.absolute)
.pipe(
Effect.catchReason("PlatformError", "NotFound", () =>
Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })),
),
)
if (info.type === "Directory") {
return yield* new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` })
}
const original = yield* Bom.readFile(fs, target.absolute)
const source = original.text
const ending = source.includes(crlf) ? crlf : "\n"
const oldString = input.oldString.replaceAll(crlf, "\n").replaceAll("\n", ending)
@@ -206,20 +204,19 @@ export const Plugin = {
})
}
const replacementBom = replaced.startsWith("\uFEFF")
const result = yield* fileMutation.write({
const result = yield* files.write({
target,
content: Bom.join(replaced, original.bom || replacementBom),
})
const bom = original.bom || replacementBom
const formatted = (yield* formatter.file(target.absolute))
? yield* FileMutation.syncTextBom(environment.files, target.absolute, bom)
: (yield* FileMutation.readText(environment.files, target.absolute)).text
? yield* Bom.syncFile(fs, target.absolute, bom)
: (yield* Bom.readFile(fs, target.absolute)).text
return {
files: [fileDiff(result.resource, source, formatted)],
replacements,
} satisfies Output
}).pipe(
fileMutation.withLock([path.resolve(location.directory, input.path)]),
Effect.map((output) => ({
output,
content: `Edited ${output.files[0]?.file} (${output.replacements} replacement${output.replacements === 1 ? "" : "s"})`,
+12 -10
View File
@@ -4,8 +4,8 @@ import { ToolFailure } from "@opencode-ai/ai"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Schema } from "effect"
import path from "path"
import { Environment } from "../../environment"
import { FileSystem } from "../../filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "../../location"
import { LocationMutation } from "../../location-mutation"
import { Ripgrep } from "../../ripgrep"
@@ -42,7 +42,7 @@ export const toModelContent = (entries: EncodedOutput, truncated = false) => {
export const Plugin = {
id: "opencode.tool.glob",
effect: Effect.fn("GlobTool.Plugin")(function* (ctx: PluginContext) {
const environment = yield* Environment.Service
const fs = yield* FSUtil.Service
const ripgrep = yield* Ripgrep.Service
const location = yield* Location.Service
const mutation = yield* LocationMutation.Service
@@ -82,20 +82,22 @@ export const Plugin = {
agent: context.agent,
source,
})
const type = yield* Environment.typeFollowing(environment.files, target.absolute).pipe(
Effect.catchTag("Environment.NotFound", () =>
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${searchPath ?? "."}` })),
),
)
if (type !== "directory")
const info = yield* fs
.stat(target.absolute)
.pipe(
Effect.catchReason("PlatformError", "NotFound", () =>
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${searchPath ?? "."}` })),
),
)
if (info.type !== "Directory")
return yield* Effect.fail(
new ToolFailure({ message: `Search path is not a directory: ${searchPath ?? "."}` }),
)
const root = target.absolute
const root = path.resolve(location.directory, searchPath ?? ".")
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
const entries = yield* ripgrep
.glob({
cwd: root,
cwd: target.absolute,
pattern: input.pattern,
limit: limit + 1,
})
+94 -90
View File
@@ -4,8 +4,8 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
import path from "path"
import { Environment } from "../../environment"
import { FileSystem } from "../../filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "../../location"
import { LocationMutation } from "../../location-mutation"
import { Permission } from "../../permission"
@@ -15,11 +15,11 @@ import { RelativePath } from "../../schema"
export const name = "grep"
export const Input = Schema.Struct({
pattern: FileSystem.GrepInput.fields.pattern
.check(Schema.isMinLength(1, { message: "Pattern must not be empty" }))
.annotate({
description: "Regular expression to search for in file contents (ripgrep syntax)",
}),
pattern: FileSystem.GrepInput.fields.pattern.check(
Schema.isMinLength(1, { message: "Pattern must not be empty" }),
).annotate({
description: "Regular expression to search for in file contents (ripgrep syntax)",
}),
path: Schema.optionalKey(RelativePath).annotate({
description: "File or directory to search. Defaults to the current working directory.",
}),
@@ -58,7 +58,7 @@ export const toModelContent = (matches: EncodedOutput, truncated = false) => {
export const Plugin = {
id: "opencode.tool.grep",
effect: Effect.fn("GrepTool.Plugin")(function* (ctx: PluginContext) {
const environment = yield* Environment.Service
const fs = yield* FSUtil.Service
const ripgrep = yield* Ripgrep.Service
const location = yield* Location.Service
const mutation = yield* LocationMutation.Service
@@ -66,100 +66,104 @@ export const Plugin = {
yield* ctx.tool
.transform((draft) =>
draft.add({
name,
options: { codemode: false },
description:
"Search file contents using regular expressions. Use it to locate specific code, symbols, or text patterns, and narrow searches with `path` or `include`. Returns matching file paths, line numbers, and line previews.",
input: Input,
output: Output,
execute: (input, context) =>
Effect.gen(function* () {
const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
const target = yield* mutation.resolve({ path: input.path ?? "." })
if (target.externalDirectory)
draft.add(
({
name,
options: { codemode: false },
description:
"Search file contents using regular expressions. Use it to locate specific code, symbols, or text patterns, and narrow searches with `path` or `include`. Returns matching file paths, line numbers, and line previews.",
input: Input,
output: Output,
execute: (input, context) =>
Effect.gen(function* () {
const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
const target = yield* mutation.resolve({ path: input.path ?? "." })
if (target.externalDirectory)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
sessionID: context.sessionID,
agent: context.agent,
source,
})
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
action: name,
resources: [input.pattern],
save: ["*"],
metadata: {
root: ".",
path: input.path,
include: input.include,
limit: input.limit,
},
sessionID: context.sessionID,
agent: context.agent,
source,
})
yield* permission.assert({
action: name,
resources: [input.pattern],
save: ["*"],
metadata: {
root: ".",
path: input.path,
include: input.include,
limit: input.limit,
},
sessionID: context.sessionID,
agent: context.agent,
source,
})
const root = target.absolute
const type = yield* Environment.typeFollowing(environment.files, root).pipe(
Effect.catchTag("Environment.NotFound", () =>
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })),
),
)
const cwd = type === "directory" ? root : path.dirname(root)
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
const matches = yield* ripgrep
.grep({
cwd,
pattern: input.pattern,
file: type === "file" ? path.basename(root) : undefined,
include: input.include,
limit: limit + 1,
})
.pipe(
Effect.timeoutOrElse({
duration: FileSystem.DEFAULT_SEARCH_TIMEOUT_MS,
orElse: () =>
Effect.fail(
new ToolFailure({
message: `Search timed out after ${FileSystem.DEFAULT_SEARCH_TIMEOUT_MS / 1_000} seconds. Consider using a more specific path or pattern.`,
const root = path.resolve(location.directory, input.path ?? ".")
const info = yield* fs
.stat(root)
.pipe(
Effect.catchReason("PlatformError", "NotFound", () =>
Effect.fail(new ToolFailure({ message: `Search path does not exist: ${input.path ?? "."}` })),
),
)
const cwd = info?.type === "Directory" ? root : path.dirname(root)
const limit = input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT
const matches = yield* ripgrep
.grep({
cwd,
pattern: input.pattern,
file: info?.type === "File" ? path.basename(root) : undefined,
include: input.include,
limit: limit + 1,
})
.pipe(
Effect.timeoutOrElse({
duration: FileSystem.DEFAULT_SEARCH_TIMEOUT_MS,
orElse: () =>
Effect.fail(
new ToolFailure({
message: `Search timed out after ${FileSystem.DEFAULT_SEARCH_TIMEOUT_MS / 1_000} seconds. Consider using a more specific path or pattern.`,
}),
),
}),
Effect.map((result) =>
result.map((match) =>
FileSystem.Match.make({
...match,
entry: FileSystem.Entry.make({
...match.entry,
path: RelativePath.make(
path.relative(location.directory, path.resolve(cwd, match.entry.path)),
),
}),
}),
),
}),
Effect.map((result) =>
result.map((match) =>
FileSystem.Match.make({
...match,
entry: FileSystem.Entry.make({
...match.entry,
path: RelativePath.make(
path.relative(location.directory, path.resolve(cwd, match.entry.path)),
),
}),
}),
),
)
return { matches: matches.slice(0, limit), truncated: matches.length > limit }
}).pipe(
Effect.map((result) => ({
output: result.matches,
content: toModelContent(
result.matches.map((match) => ({
...match,
entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) },
})),
result.truncated,
),
)
return { matches: matches.slice(0, limit), truncated: matches.length > limit }
}).pipe(
Effect.map((result) => ({
output: result.matches,
content: toModelContent(
result.matches.map((match) => ({
...match,
entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) },
})),
result.truncated,
),
metadata: { matches: result.matches.length, truncated: result.truncated },
})),
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: error instanceof Ripgrep.InvalidPatternError
? new ToolFailure({ message: `Invalid regex pattern: ${error.message}` })
metadata: { matches: result.matches.length, truncated: result.truncated },
})),
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: error instanceof Ripgrep.InvalidPatternError
? new ToolFailure({ message: `Invalid regex pattern: ${error.message}` })
: new ToolFailure({ message: `Unable to grep for ${input.pattern}`, error }),
),
),
),
}),
}),
),
)
.pipe(Effect.orDie)
}),
+42 -41
View File
@@ -4,13 +4,12 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
import { ToolFailure } from "@opencode-ai/ai"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { createTwoFilesPatch, diffLines } from "diff"
import { Effect, Result, Schema } from "effect"
import { Effect, Schema } from "effect"
import { PlatformError } from "effect/PlatformError"
import path from "path"
import { Bom } from "@opencode-ai/util/bom"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Environment } from "../../environment"
import { Formatter } from "../../formatter"
import { FileMutation } from "../../file-mutation"
import { Location } from "../../location"
import { Patch } from "@opencode-ai/util/patch"
import { Permission } from "../../permission"
@@ -45,13 +44,7 @@ export const toModelOutput = (output: Output) =>
].join("\n")
type Prepared =
| (Extract<Patch.Hunk, { readonly type: "add" }> & {
readonly target: Target
readonly content: string
readonly before: string
readonly after: string
})
| (Extract<Patch.Hunk, { readonly type: "delete" }> & {
| (Extract<Patch.Hunk, { readonly type: "add" | "delete" }> & {
readonly target: Target
readonly before: string
readonly after: string
@@ -76,8 +69,7 @@ interface Target {
export const Plugin = {
id: "opencode.tool.patch",
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
const environment = yield* Environment.Service
const mutation = yield* FileMutation.Service
const fs = yield* FSUtil.Service
const formatter = yield* Formatter.Service
const location = yield* Location.Service
const permission = yield* Permission.Service
@@ -92,13 +84,6 @@ export const Plugin = {
output: Output,
execute: (input, context) => {
const applied: Array<typeof Applied.Type> = []
const parsed = Patch.parse(input.patchText)
const lockTargets = Result.isSuccess(parsed)
? parsed.success.flatMap((hunk) => [
path.resolve(location.directory, hunk.path),
...(hunk.type === "update" && hunk.movePath ? [path.resolve(location.directory, hunk.movePath)] : []),
])
: []
const fail = (operation: string, error: unknown) => {
const completed = applied.map((item) => item.resource).join(", ")
return new ToolFailure({
@@ -112,7 +97,7 @@ export const Plugin = {
id: context.id,
}
if (!input.patchText) return yield* new ToolFailure({ message: "patchText is required" })
const hunks = yield* Effect.fromResult(parsed).pipe(
const hunks = yield* Effect.fromResult(Patch.parse(input.patchText)).pipe(
Effect.mapError((error) => new ToolFailure({ message: `patch verification failed: ${error.message}` })),
)
if (hunks.length === 0) {
@@ -140,19 +125,18 @@ export const Plugin = {
})
}
if (hunk.type === "add") {
const content =
hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`
prepared.push({
...hunk,
target,
content,
before: "",
after: Bom.split(content).text,
after: Bom.split(
hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`,
).text,
})
return
}
if (hunk.type === "delete") {
const content = yield* FileMutation.readText(environment.files, target.absolute).pipe(
const content = yield* Bom.readFile(fs, target.absolute).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
@@ -167,7 +151,20 @@ export const Plugin = {
const original =
previous ??
(yield* Effect.gen(function* () {
const content = yield* FileMutation.readText(environment.files, target.absolute).pipe(
const stats = yield* fs.stat(target.absolute).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
message: `patch verification failed: Failed to read file to update ${target.absolute}: ${errorMessage(error)}`,
}),
),
)
if (stats.type === "Directory") {
return yield* new ToolFailure({
message: `patch verification failed: Failed to read file to update ${target.absolute}: path is a directory`,
})
}
const content = yield* Bom.readFile(fs, target.absolute).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
@@ -236,8 +233,13 @@ export const Plugin = {
(change) =>
Effect.gen(function* () {
if (change.type === "add") {
yield* environment.files
.write(change.target.absolute, new TextEncoder().encode(change.content))
yield* fs
.writeWithDirs(
change.target.absolute,
change.contents.endsWith("\n") || change.contents === ""
? change.contents
: `${change.contents}\n`,
)
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
applied.push({
type: change.type,
@@ -247,7 +249,7 @@ export const Plugin = {
return
}
if (change.type === "delete") {
yield* environment.files
yield* fs
.remove(change.target.absolute)
.pipe(Effect.mapError((error) => fail(`Failed to delete ${change.target.resource}`, error)))
applied.push({
@@ -259,10 +261,10 @@ export const Plugin = {
}
if (change.moveTarget) {
const moveTarget = change.moveTarget
yield* environment.files
.write(moveTarget.absolute, new TextEncoder().encode(change.content))
yield* fs
.writeWithDirs(moveTarget.absolute, change.content)
.pipe(Effect.mapError((error) => fail(`Failed to write ${moveTarget.resource}`, error)))
yield* environment.files
yield* fs
.remove(change.target.absolute)
.pipe(
Effect.mapError((error) =>
@@ -276,8 +278,8 @@ export const Plugin = {
})
return
}
yield* environment.files
.write(change.target.absolute, new TextEncoder().encode(change.content))
yield* fs
.writeWithDirs(change.target.absolute, change.content)
.pipe(Effect.mapError((error) => fail(`Failed to write ${change.target.resource}`, error)))
applied.push({
type: change.type,
@@ -292,13 +294,13 @@ export const Plugin = {
[...new Set(applied.filter((item) => item.type !== "delete").map((item) => item.target))],
(target) =>
Effect.gen(function* () {
const current = yield* FileMutation.readText(environment.files, target).pipe(
const current = yield* Bom.readFile(fs, target).pipe(
Effect.mapError((error) => fail(`Failed to read ${target}`, error)),
)
formatted.set(
target,
(yield* formatter.file(target))
? yield* FileMutation.syncTextBom(environment.files, target, current.bom).pipe(
? yield* Bom.syncFile(fs, target, current.bom).pipe(
Effect.mapError((error) => fail(`Failed to sync ${target}`, error)),
)
: current.text,
@@ -313,7 +315,6 @@ export const Plugin = {
})
return { applied, files }
}).pipe(
mutation.withLock(lockTargets),
Effect.map((output) => ({
output,
content: toModelOutput(output),
@@ -344,10 +345,10 @@ export const Plugin = {
}
function errorMessage(error: unknown) {
if (error instanceof Environment.NotFound) return "file does not exist"
if (error instanceof Environment.WrongKind)
return error.actual === "directory" ? "path is a directory" : `path is ${error.actual}`
if (error instanceof Environment.Failed) return errorMessage(error.cause)
if (error instanceof PlatformError) {
if (error.reason._tag === "NotFound") return "file does not exist"
return error.reason.description ?? error.reason.message
}
return error instanceof Error ? error.message : String(error)
}
+11 -8
View File
@@ -11,7 +11,6 @@ import { Permission } from "../../permission"
import { SessionInstructions } from "../../session/instructions"
import { AbsolutePath } from "../../schema"
import { ReadToolFileSystem } from "../read-filesystem"
import { Environment } from "../../environment"
export const name = "read"
const FILENAME = "AGENTS.md"
@@ -73,12 +72,16 @@ export const Plugin = {
agent: context.agent,
source,
})
const content = yield* reader.read(absolute, resource, { offset: input.offset, limit: input.limit }).pipe(
Effect.catchIf(
(error) => error instanceof Environment.NotFound,
() => missing(input.path, target.absolute),
),
)
const type = yield* reader
.inspect(absolute)
.pipe(Effect.catchReason("PlatformError", "NotFound", () => missing(input.path, target.absolute)))
const content =
type === "directory"
? yield* reader.list(absolute, { offset: input.offset, limit: input.limit })
: yield* reader.read(absolute, resource, {
offset: input.offset,
limit: input.limit,
})
// After a successful read, discover nearby AGENTS.md walking up to the Location
// root exclusive and inject them as durable synthetic instructions. For a
// directory listing the walk starts at the directory itself (so its own AGENTS.md
@@ -92,7 +95,7 @@ export const Plugin = {
// supplied by core initial instructions) is dropped by the dirname filter.
const discovered = yield* fs.up({
targets: [FILENAME],
start: content.type === "list-page" ? resolved : dirname(resolved),
start: type === "directory" ? resolved : dirname(resolved),
stop: root,
})
const candidates = (yield* Effect.forEach(discovered, fs.resolve)).filter(
+10 -8
View File
@@ -5,8 +5,8 @@ import { ToolFailure } from "@opencode-ai/ai"
import type { Content } from "@opencode-ai/schema/tool"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { Deferred, Effect, Schema, Scope } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Config } from "../../config"
import { Environment } from "../../environment"
import { LocationMutation } from "../../location-mutation"
import { Permission } from "../../permission"
import { PluginRuntime } from "../../plugin/runtime"
@@ -83,7 +83,7 @@ export const Plugin = {
effect: Effect.fn("ShellTool.Plugin")(function* (ctx: PluginContext) {
const runtime = yield* PluginRuntime.Service
const scope = yield* Scope.Scope
const environment = yield* Environment.Service
const fsUtil = yield* FSUtil.Service
const mutation = yield* LocationMutation.Service
const shell = yield* Shell.Service
const permission = yield* Permission.Service
@@ -179,12 +179,14 @@ export const Plugin = {
agent: context.agent,
source,
})
const workdir = yield* Environment.typeFollowing(environment.files, target.absolute).pipe(
Effect.catchTag("Environment.NotFound", () =>
Effect.fail(new Error(`Working directory does not exist: ${target.absolute}`)),
),
)
if (workdir !== "directory")
const workdir = yield* fsUtil
.stat(target.absolute)
.pipe(
Effect.catchReason("PlatformError", "NotFound", () =>
Effect.fail(new Error(`Working directory does not exist: ${target.absolute}`)),
),
)
if (workdir.type !== "Directory")
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.absolute}`))
}),
)
+8 -10
View File
@@ -10,7 +10,7 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect"
import { Bom } from "@opencode-ai/util/bom"
import { Environment } from "../../environment"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { FileMutation } from "../../file-mutation"
import { Formatter } from "../../formatter"
import { LocationMutation } from "../../location-mutation"
@@ -47,9 +47,9 @@ export const Plugin = {
id: "opencode.tool.write",
effect: Effect.fn("WriteTool.Plugin")(function* (ctx: PluginContext) {
const mutation = yield* LocationMutation.Service
const fileMutation = yield* FileMutation.Service
const environment = yield* Environment.Service
const files = yield* FileMutation.Service
const formatter = yield* Formatter.Service
const fs = yield* FSUtil.Service
const permission = yield* Permission.Service
yield* ctx.tool
@@ -77,8 +77,8 @@ export const Plugin = {
agent: context.agent,
source,
})
const current = yield* FileMutation.readText(environment.files, target.absolute).pipe(
Effect.catchTag("Environment.NotFound", () => Effect.succeed(undefined)),
const current = yield* Bom.readFile(fs, target.absolute).pipe(
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)),
)
const next = Bom.split(input.content)
const preview = fileDiff(target.resource, current?.text ?? "", next.text, current ? "modified" : "added")
@@ -91,11 +91,9 @@ export const Plugin = {
agent: context.agent,
source,
})
const result = yield* fileMutation.writeTextPreservingBom({ target, content: input.content })
const bom = (yield* FileMutation.readText(environment.files, target.absolute)).bom
if (yield* formatter.file(target.absolute)) {
yield* FileMutation.syncTextBom(environment.files, target.absolute, bom)
}
const result = yield* files.writeTextPreservingBom({ target, content: input.content })
const bom = (yield* Bom.readFile(fs, target.absolute)).bom
if (yield* formatter.file(target.absolute)) yield* Bom.syncFile(fs, target.absolute, bom)
return result
}).pipe(
Effect.map((output) => ({ output, content: toModelOutput(output) })),
+191 -152
View File
@@ -2,22 +2,17 @@ export * as ReadToolFileSystem from "./read-filesystem"
import path from "path"
import { pathToFileURL } from "url"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Schema } from "effect"
import { lookup } from "mime-types"
import { Environment } from "../environment"
import type { Files } from "../environment"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { FileSystem } from "../filesystem"
import { Mime } from "../mime"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath } from "../schema"
export const MAX_READ_LINES = 2_000
export const MAX_READ_BYTES = 50 * 1024
export const MAX_MEDIA_INGEST_BYTES = 20 * 1024 * 1024
const FIRST_CHUNK = 256 * 1024
const MAX_LINE_LENGTH = 2_000
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
const MEDIA_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf"])
export class BinaryFileError extends Schema.TaggedErrorClass<BinaryFileError>()("ReadTool.BinaryFileError", {
resource: Schema.String,
@@ -57,13 +52,8 @@ export class PathKindError extends Schema.TaggedErrorClass<PathKindError>()("Rea
}
}
export type ReadError =
| Environment.NotFound
| Environment.Failed
| BinaryFileError
| MediaIngestLimitError
| OffsetOutOfRangeError
| PathKindError
export type InspectError = FSUtil.Error | PathKindError
export type ReadError = FSUtil.Error | BinaryFileError | MediaIngestLimitError | OffsetOutOfRangeError | PathKindError
export const PageInput = Schema.Struct({
offset: Schema.optionalKey(NonNegativeInt),
@@ -100,113 +90,202 @@ export class ListPage extends Schema.Class<ListPage>("ReadTool.ListPage")({
}) {}
export interface Interface {
readonly inspect: (path: AbsolutePath) => Effect.Effect<"file" | "directory", InspectError>
readonly read: (
path: AbsolutePath,
resource: string,
page?: PageInput,
) => Effect.Effect<FileContent | TextPage | ListPage, ReadError>
) => Effect.Effect<FileContent | TextPage, ReadError>
readonly list: (path: AbsolutePath, page?: PageInput) => Effect.Effect<ListPage, FSUtil.Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ReadToolFileSystem") {}
const mimeType = (value: string) => lookup(value) || "application/octet-stream"
const startsWith = (bytes: Uint8Array, prefix: number[]) => prefix.every((value, index) => bytes[index] === value)
const mediaMime = (bytes: Uint8Array) => {
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png"
if (startsWith(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg"
if (startsWith(bytes, [0x47, 0x49, 0x46, 0x38])) return "image/gif"
if (startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) && startsWith(bytes.subarray(8), [0x57, 0x45, 0x42, 0x50]))
return "image/webp"
if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d])) return "application/pdf"
}
const binary = (bytes: Uint8Array) => {
if (bytes.length === 0) return false
let nonPrintable = 0
for (const byte of bytes) {
if (byte === 0) return true
if (byte < 9 || (byte > 13 && byte < 32)) nonPrintable++
}
return nonPrintable / bytes.length > 0.3
}
const decodeUtf8 = (decoder: TextDecoder, bytes?: Uint8Array) => decoder.decode(bytes, { stream: bytes !== undefined })
const decodeChunk = (resource: string, decoder: TextDecoder, bytes: Uint8Array) =>
bytes.includes(0) ? Effect.fail(new BinaryFileError({ resource })) : Effect.succeed(decodeUtf8(decoder, bytes))
export const inspect = Effect.fn("ReadTool.inspect")(function* (fs: FSUtil.Interface, input: string) {
const info = yield* fs.stat(input)
const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined
if (!type) return yield* Effect.fail(new PathKindError({ resource: input, expected: "a file or directory" }))
return type
})
export const read = Effect.fn("ReadTool.read")(function* (
files: Files,
input: AbsolutePath,
fs: FSUtil.Interface,
input: string,
resource: string,
page: PageInput = {},
) {
const first = yield* files.read(input, { offset: 0, length: FIRST_CHUNK }).pipe(
Effect.catchTag("Environment.WrongKind", (error) => {
if (error.actual !== "directory")
return Effect.fail(new PathKindError({ resource, expected: "a file or directory" }))
return files.list(input).pipe(
Effect.map((entries) => list(entries, page)),
Effect.catchTag("Environment.WrongKind", () =>
Effect.fail(new PathKindError({ resource, expected: "a file or directory" })),
),
const real = yield* fs.realPath(input)
return yield* Effect.scoped(
Effect.gen(function* () {
const file = yield* fs.open(real, { flag: "r" })
const info = yield* file.stat
if (info.type !== "File") return yield* Effect.fail(new PathKindError({ resource, expected: "a file" }))
const first = Option.getOrElse(
yield* file.readAlloc(Math.min(64 * 1024, Number(info.size) || 4 * 1024)),
() => new Uint8Array(),
)
const mime = mediaMime(first)
if (mime) {
if (info.size > MAX_MEDIA_INGEST_BYTES)
return yield* Effect.fail(new MediaIngestLimitError({ resource, maximumBytes: MAX_MEDIA_INGEST_BYTES }))
const chunks = [first]
let total = first.length
while (total <= MAX_MEDIA_INGEST_BYTES) {
const chunk = yield* file.readAlloc(Math.min(64 * 1024, MAX_MEDIA_INGEST_BYTES + 1 - total))
if (Option.isNone(chunk)) break
chunks.push(chunk.value)
total += chunk.value.length
}
if (total > MAX_MEDIA_INGEST_BYTES)
return yield* Effect.fail(new MediaIngestLimitError({ resource, maximumBytes: MAX_MEDIA_INGEST_BYTES }))
return {
type: "file" as const,
uri: pathToFileURL(real).href,
name: path.basename(real),
content: Buffer.concat(
chunks.map((chunk) => Buffer.from(chunk)),
total,
).toString("base64"),
encoding: "base64" as const,
mime,
}
}
const paged = info.size > MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined
if (!paged) {
if (binary(first)) return yield* Effect.fail(new BinaryFileError({ resource }))
const decoder = new TextDecoder()
const text = [decodeUtf8(decoder, first)]
while (true) {
const chunk = yield* file.readAlloc(64 * 1024)
if (Option.isNone(chunk)) break
text.push(yield* decodeChunk(resource, decoder, chunk.value))
}
text.push(decodeUtf8(decoder))
return {
type: "file" as const,
uri: pathToFileURL(real).href,
name: path.basename(real),
content: text.join(""),
encoding: "utf8" as const,
mime: FSUtil.mimeType(real),
}
}
const offset = page.offset || 1
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
const lines: string[] = []
const decoder = new TextDecoder()
let pending = ""
let discard = false
let line = 1
let bytes = 0
let next: number | undefined
const append = (input: string) => {
if (line < offset) {
line++
return true
}
if (lines.length >= limit || bytes >= MAX_READ_BYTES) {
next = line
return false
}
const text = input.length > MAX_LINE_LENGTH ? input.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : input
const size = Buffer.byteLength(text, "utf-8") + (lines.length > 0 ? 1 : 0)
if (bytes + size > MAX_READ_BYTES) {
next = line
return false
}
lines.push(text)
bytes += size
line++
return true
}
const consume = (input: string) => {
let text = input
while (true) {
const index = text.indexOf("\n")
if (index === -1) {
if (!discard) {
pending += text
if (pending.length > MAX_LINE_LENGTH) {
pending = pending.slice(0, MAX_LINE_LENGTH + 1)
discard = true
}
}
break
}
const current = pending + (discard ? "" : text.slice(0, index))
pending = ""
discard = false
text = text.slice(index + 1)
if (!append(current.endsWith("\r") ? current.slice(0, -1) : current)) return false
}
return true
}
const consumeChunk = Effect.fnUntraced(function* (chunk: Uint8Array) {
let start = 0
while (start < chunk.length) {
if (lines.length >= limit || bytes >= MAX_READ_BYTES) {
next = line
return false
}
const newline = chunk.indexOf(10, start)
const end = newline === -1 ? chunk.length : newline + 1
const segment = chunk.subarray(start, end)
if (binary(segment)) return yield* Effect.fail(new BinaryFileError({ resource }))
if (!consume(decodeUtf8(decoder, segment))) return false
start = end
}
return true
})
let done = !(yield* consumeChunk(first))
while (!done) {
const chunk = yield* file.readAlloc(64 * 1024)
if (Option.isNone(chunk)) break
done = !(yield* consumeChunk(chunk.value))
}
if (!done) {
const tail = decodeUtf8(decoder)
if (!discard) pending += tail
if (pending) append(pending.endsWith("\r") ? pending.slice(0, -1) : pending)
}
if (lines.length === 0 && offset !== 1) return yield* Effect.fail(new OffsetOutOfRangeError({ offset }))
return new TextPage({
type: "text-page",
content: lines.join("\n"),
mime: FSUtil.mimeType(real),
offset,
truncated: next !== undefined,
...(next === undefined ? {} : { next }),
})
}),
)
if (first instanceof ListPage) return first
const media = Mime.detect(first.bytes)
if (MEDIA_MIMES.has(media)) {
if (first.info.size > MAX_MEDIA_INGEST_BYTES)
return yield* new MediaIngestLimitError({ resource, maximumBytes: MAX_MEDIA_INGEST_BYTES })
const whole = yield* readFile(files, input, resource)
return {
type: "file" as const,
uri: pathToFileURL(input).href,
name: path.basename(input),
content: Buffer.from(whole.bytes).toString("base64"),
encoding: "base64" as const,
mime: media,
}
}
const paged = first.info.size > MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined
if (!paged) {
if (first.bytes.includes(0)) return yield* new BinaryFileError({ resource })
return {
type: "file" as const,
uri: pathToFileURL(input).href,
name: path.basename(input),
content: new TextDecoder().decode(first.bytes),
encoding: "utf8" as const,
mime: mimeType(input),
}
}
const chunks = [first.bytes]
while (true) {
const bytes = Buffer.concat(chunks)
const eof = bytes.length >= first.info.size
const result = textPage(bytes, eof, page)
if (result !== undefined) return yield* makeTextPage(bytes, input, resource, result)
const next = yield* readFile(files, input, resource, { offset: bytes.length, length: FIRST_CHUNK })
if (next.bytes.length === 0) {
const result = textPage(bytes, true, page)
if (result === undefined) return yield* Effect.die("Read page did not settle at EOF")
return yield* makeTextPage(bytes, input, resource, result)
}
chunks.push(next.bytes)
}
})
const readFile = (
files: Files,
input: AbsolutePath,
resource: string,
range?: { readonly offset: number; readonly length: number },
) =>
files
.read(input, range)
.pipe(
Effect.catchTag("Environment.WrongKind", () => Effect.fail(new PathKindError({ resource, expected: "a file" }))),
)
const makeTextPage = Effect.fnUntraced(function* (
bytes: Uint8Array,
input: AbsolutePath,
resource: string,
result: NonNullable<ReturnType<typeof textPage>>,
) {
if (bytes.subarray(0, result.consumed).includes(0)) return yield* new BinaryFileError({ resource })
if (result.entries.length === 0 && result.offset !== 1)
return yield* new OffsetOutOfRangeError({ offset: result.offset })
return new TextPage({
type: "text-page",
content: result.entries.join("\n"),
mime: mimeType(input),
offset: result.offset,
truncated: result.next !== undefined,
...(result.next === undefined ? {} : { next: result.next }),
})
})
const list = (items: ReadonlyArray<Environment.DirEntry>, page: PageInput) => {
export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface, input: string, page: PageInput = {}) {
const real = yield* fs.realPath(input)
const items = yield* fs.readDirectoryEntries(real)
const offset = page.offset || 1
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
const visible = items
@@ -237,58 +316,18 @@ const list = (items: ReadonlyArray<Environment.DirEntry>, page: PageInput) => {
truncated,
...(truncated ? { next: offset + selected.length } : {}),
})
}
const textPage = (bytes: Uint8Array, eof: boolean, page: PageInput) => {
const offset = page.offset || 1
const limit = Math.min(page.limit || MAX_READ_LINES, MAX_READ_LINES)
const decoded = new TextDecoder().decode(bytes)
const split = decoded.split("\n")
const complete = eof ? (split.at(-1) === "" ? split.slice(0, -1) : split) : split.slice(0, -1)
const available = complete.map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line))
const entries: string[] = []
let size = 0
let next: number | undefined
for (const [index, value] of available.slice(offset - 1).entries()) {
const line = offset + index
if (entries.length >= limit || size >= MAX_READ_BYTES) {
next = line
break
}
const text = value.length > MAX_LINE_LENGTH ? value.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : value
const lineSize = Buffer.byteLength(text, "utf-8") + (entries.length > 0 ? 1 : 0)
if (size + lineSize > MAX_READ_BYTES) {
next = line
break
}
entries.push(text)
size += lineSize
}
if (next === undefined && entries.length >= limit && (!eof || offset - 1 + entries.length < available.length))
next = offset + entries.length
if (!eof && next === undefined) return
const consumedLines = next === undefined ? available.length : next - 1
const consumed = consumedLines === 0 ? 0 : (nthNewline(bytes, consumedLines) ?? bytes.length)
return { entries, offset, next, consumed }
}
const nthNewline = (bytes: Uint8Array, count: number) => {
let found = 0
for (const [index, byte] of bytes.entries()) {
if (byte !== 10) continue
found++
if (found === count) return index + 1
}
}
})
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const environment = yield* Environment.Service
return Service.of({ read: (path, resource, page) => read(environment.files, path, resource, page) })
const fs = yield* FSUtil.Service
return Service.of({
inspect: (path) => inspect(fs, path),
read: (path, resource, page) => read(fs, path, resource, page),
list: (path, page) => list(fs, path, page),
})
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [Environment.node] })
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] })
@@ -50,33 +50,6 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () =
const decode = Schema.decodeUnknownSync(Info)
describe("ConfigProviderPlugin.Plugin", () => {
it.effect("adds key auth for custom providers without env credentials", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const entries = [
new Document({
type: "document",
info: decode({
providers: {
litellm: {
package: "aisdk:@ai-sdk/openai-compatible",
models: { chat: {} },
},
},
}),
}),
]
yield* addPlugin(entries)
expect(yield* integrations.get(Integration.ID.make("litellm"))).toMatchObject({
id: "litellm",
name: "litellm",
methods: [{ type: "key", label: "Manually enter API Key" }],
})
}),
)
it.effect("defaults custom models to agent capabilities", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
-88
View File
@@ -1,88 +0,0 @@
import fs from "node:fs/promises"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { ChildProcessSpawner } from "effect/unstable/process"
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import {
execDefaults,
Failed,
makeFiles,
makeLocalDriver,
makeMemoryDriver,
NotFound,
typeFollowing,
} from "../src/environment/index"
import { tmpdir } from "./fixture/tmpdir"
import { environmentConformance } from "./lib/environment-conformance"
import { it } from "./lib/effect"
describe("typeFollowing", () => {
it.effect("follows symlinks without changing stat semantics", () =>
Effect.gen(function* () {
const driver = makeMemoryDriver()
const files = makeFiles(driver)
yield* files.mkdir("/directory")
yield* files.write("/file", new Uint8Array())
yield* driver.symlink("/directory", "/directory-link")
yield* driver.symlink("/file", "/file-link")
yield* driver.symlink("/missing", "/dangling-link")
expect(yield* typeFollowing(files, "/directory-link")).toBe("directory")
expect(yield* typeFollowing(files, "/file-link")).toBe("file")
expect(yield* typeFollowing(files, "/dangling-link").pipe(Effect.flip)).toBeInstanceOf(NotFound)
}),
)
})
environmentConformance("memory environment", () =>
Effect.sync(() => {
const driver = makeMemoryDriver()
return {
files: makeFiles(driver),
root: `/workspace-${crypto.randomUUID()}`,
symlink: driver.symlink,
}
}),
)
environmentConformance("local environment", () =>
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const tmp = yield* Effect.promise(() => tmpdir("opencode-local-environment-"))
return {
files: makeFiles(makeLocalDriver(spawner)),
root: tmp.path,
...(process.platform === "win32"
? {}
: {
symlink: (target: string, link: string) =>
Effect.tryPromise({
try: () => fs.symlink(target, link),
catch: (cause) => new Failed({ path: link, cause }),
}),
}),
dispose: Effect.promise(() => tmp[Symbol.asyncDispose]()),
}
}).pipe(Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))),
)
environmentConformance(
"GNU exec environment",
() =>
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const tmp = yield* Effect.promise(() => tmpdir("opencode-environment-"))
return {
files: execDefaults(spawner),
root: tmp.path,
symlink: (target: string, link: string) =>
Effect.tryPromise({
try: () => fs.symlink(target, link),
catch: (cause) => new Failed({ path: link, cause }),
}),
dispose: Effect.promise(() => tmp[Symbol.asyncDispose]()),
}
}).pipe(Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))),
process.platform !== "linux",
)
+12 -63
View File
@@ -5,7 +5,7 @@ import { Deferred, Effect, Fiber, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Environment } from "@opencode-ai/core/environment"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -13,7 +13,7 @@ import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { it } from "./lib/effect"
function provide(directory: string, environmentLayer = LayerNode.compile(Environment.node)) {
function provide(directory: string, filesystemLayer = LayerNode.compile(FSUtil.node)) {
const activeLocation = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
@@ -21,7 +21,7 @@ function provide(directory: string, environmentLayer = LayerNode.compile(Environ
return Effect.provide(
AppNodeBuilder.build(LayerNode.group([LocationMutation.node, FileMutation.node]), [
[Location.node, activeLocation],
[Environment.node, environmentLayer],
[FSUtil.node, filesystemLayer],
]),
)
}
@@ -152,57 +152,6 @@ describe("FileMutation", () => {
),
)
it.live("shares transaction locks across Location service instances", () =>
withTmp((directory) =>
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const releaseFirst = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
const target = path.join(directory, "shared.txt")
const first = yield* Effect.gen(function* () {
const files = yield* FileMutation.Service
yield* files.withLock([target])(
Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))),
)
}).pipe(provide(directory), Effect.forkChild)
yield* Deferred.await(firstStarted)
const second = yield* Effect.gen(function* () {
const files = yield* FileMutation.Service
yield* files.withLock([target])(Deferred.succeed(secondStarted, undefined))
}).pipe(provide(directory), Effect.forkChild)
yield* Effect.yieldNow
expect(yield* Deferred.isDone(secondStarted)).toBe(false)
yield* Deferred.succeed(releaseFirst, undefined)
yield* Deferred.await(secondStarted)
yield* Fiber.join(first)
yield* Fiber.join(second)
}),
),
)
it.live("allows transaction locks for distinct resolved paths to proceed independently", () =>
withTmp((directory) =>
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const releaseFirst = yield* Deferred.make<void>()
const secondFinished = yield* Deferred.make<void>()
const files = yield* FileMutation.Service
const first = yield* files
.withLock([path.join(directory, "first.txt")])(
Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))),
)
.pipe(Effect.forkChild)
yield* Deferred.await(firstStarted)
yield* files.withLock([path.join(directory, "second.txt")])(Deferred.succeed(secondFinished, undefined))
expect(yield* Deferred.isDone(secondFinished)).toBe(true)
yield* Deferred.succeed(releaseFirst, undefined)
yield* Fiber.join(first)
}).pipe(provide(directory)),
),
)
it.live("allows distinct absolute targets to proceed independently", () =>
withTmp((directory) =>
Effect.gen(function* () {
@@ -242,16 +191,16 @@ describe("FileMutation", () => {
function instrumentWrites(run: <E>(write: Effect.Effect<void, E>, target: string) => Effect.Effect<void, E>) {
return Layer.effect(
Environment.Service,
FSUtil.Service,
Effect.gen(function* () {
const environment = yield* Environment.Service
return Environment.Service.of({
...environment,
files: {
...environment.files,
write: (target, content) => run(environment.files.write(target, content), target),
},
const filesystem = yield* FSUtil.Service
return FSUtil.Service.of({
...filesystem,
writeWithDirs: (target, content, mode) => run(filesystem.writeWithDirs(target, content, mode), target),
writeFile: (target, content, options) => run(filesystem.writeFile(target, content, options), target),
writeFileString: (target, content, options) =>
run(filesystem.writeFileString(target, content, options), target),
})
}),
).pipe(Layer.provide(LayerNode.compile(Environment.node)))
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
}
+126 -93
View File
@@ -17,8 +17,9 @@ import { location } from "../fixture/location"
import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
const describeWatcher = Watcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
const describeNative = process.env.CI ? describe.skip : describe
const it = testEffect(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Bus.node])))
@@ -74,9 +75,10 @@ describe("Watcher lifecycle", () => {
const interrupted = yield* Deferred.make<void>()
yield* Effect.gen(function* () {
const watcher = yield* Watcher.Service
const consumer = yield* watcher
.subscribe({ path: "/pending", type: "directory" })
.pipe(Effect.flatMap(Stream.runDrain), Effect.forkScoped({ startImmediately: true }))
const consumer = yield* watcher.subscribe({ path: "/pending", type: "directory" }).pipe(
Effect.flatMap(Stream.runDrain),
Effect.forkScoped({ startImmediately: true }),
)
yield* Deferred.await(started)
yield* Fiber.interrupt(consumer)
expect(yield* Deferred.isDone(interrupted)).toBe(true)
@@ -97,9 +99,10 @@ describe("Watcher lifecycle", () => {
return Effect.gen(function* () {
const watcher = yield* Watcher.Service
const consume = () =>
watcher
.subscribe({ path: "/shared", type: "directory" })
.pipe(Effect.flatMap(Stream.runDrain), Effect.forkScoped({ startImmediately: true }))
watcher.subscribe({ path: "/shared", type: "directory" }).pipe(
Effect.flatMap(Stream.runDrain),
Effect.forkScoped({ startImmediately: true }),
)
const first = yield* consume()
const second = yield* consume()
yield* Effect.yieldNow
@@ -135,26 +138,22 @@ describe("Watcher lifecycle", () => {
})
})
function provide(directory: string, vcs?: Location.Interface["vcs"], watcher?: Layer.Layer<Watcher.Service>) {
function provide(directory: string, vcs?: Location.Interface["vcs"]) {
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory) }, { vcs })),
)
const built = AppNodeBuilder.build(LocationWatcher.node, [
[Config.node, configLayer],
[Location.node, locationLayer],
...(watcher ? ([[Watcher.node, watcher]] as const) : []),
])
return Effect.provide(built)
return Effect.provide(
AppNodeBuilder.build(LocationWatcher.node, [
[Config.node, configLayer],
[Location.node, locationLayer],
]),
)
}
function withTmp<A, E, R>(
f: (directory: string, vcs?: Location.Interface["vcs"]) => Effect.Effect<A, E, R>,
options?: {
vcs?: "git" | "hg"
init?: (directory: string) => Promise<void>
watcher?: Layer.Layer<Watcher.Service>
},
options?: { vcs?: "git" | "hg"; init?: (directory: string) => Promise<void> },
) {
return Effect.acquireRelease(
Effect.promise(async () => {
@@ -174,57 +173,9 @@ function withTmp<A, E, R>(
return { tmp, vcs: { type: "git" as const, store: AbsolutePath.make(path.join(tmp.path, ".git")) } }
}),
({ tmp }) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.flatMap(({ tmp, vcs }) => f(tmp.path, vcs).pipe(provide(tmp.path, vcs, options?.watcher))))
).pipe(Effect.flatMap(({ tmp, vcs }) => f(tmp.path, vcs).pipe(provide(tmp.path, vcs))))
}
describe("LocationWatcher subscriptions", () => {
it.live("watches only exact Git branch metadata", () => {
const subscriptions: Watcher.WatchInput[] = []
const watcher = Layer.succeed(
Watcher.Service,
Watcher.Service.of({
subscribe: (input) => Effect.sync(() => subscriptions.push(input)).pipe(Effect.as(Stream.empty)),
}),
)
return withTmp(
(directory) =>
Effect.gen(function* () {
yield* LocationWatcher.Service
yield* Effect.sync(() => subscriptions.length).pipe(
Effect.filterOrFail((count) => count > 0),
Effect.retry(Schedule.spaced("10 millis")),
)
yield* Effect.sleep("10 millis")
expect(subscriptions).toEqual([{ path: path.join(directory, ".git", "HEAD"), type: "file" }])
}),
{ vcs: "git", watcher },
)
})
it.live("watches only exact Hg branch metadata", () => {
const subscriptions: Watcher.WatchInput[] = []
const watcher = Layer.succeed(
Watcher.Service,
Watcher.Service.of({
subscribe: (input) => Effect.sync(() => subscriptions.push(input)).pipe(Effect.as(Stream.empty)),
}),
)
return withTmp(
(directory) =>
Effect.gen(function* () {
yield* LocationWatcher.Service
yield* Effect.sync(() => subscriptions.length).pipe(
Effect.filterOrFail((count) => count > 0),
Effect.retry(Schedule.spaced("10 millis")),
)
yield* Effect.sleep("10 millis")
expect(subscriptions).toEqual([{ path: path.join(directory, ".hg", "branch"), type: "file" }])
}),
{ vcs: "hg", watcher },
)
})
})
function wait(check: (event: WatcherEvent) => boolean) {
return Effect.gen(function* () {
const bus = yield* Bus.Service
@@ -275,18 +226,31 @@ function eventuallyUpdate<E>(check: (event: WatcherEvent) => boolean, trigger: (
)
}
function ready(file: string, eventFile = file) {
function noUpdate<E>(check: (event: WatcherEvent) => boolean, trigger: Effect.Effect<void, E>, timeout = 500) {
return Effect.acquireUseRelease(
wait(check),
({ deferred }) =>
trigger.pipe(
Effect.andThen(Deferred.await(deferred)),
Effect.timeoutOption(`${timeout} millis`),
Effect.tap((result) => Effect.sync(() => expect(result).toEqual(Option.none()))),
),
({ fiber }) => Fiber.interrupt(fiber),
)
}
function ready(directory: string) {
const file = path.join(directory, `.watcher-${Math.random().toString(36).slice(2)}`)
return Effect.gen(function* () {
const fs = yield* FSUtil.Service
const content = (yield* fs.readFileStringSafe(file)) ?? `ready-${Math.random()}`
yield* eventuallyUpdate(
(event) => event.file === eventFile,
() => fs.writeFileString(file, content),
).pipe(Effect.asVoid)
(event) => event.file === file,
() => fs.writeFileString(file, `ready-${Math.random()}`),
).pipe(Effect.ensuring(fs.remove(file, { force: true }).pipe(Effect.ignore)), Effect.asVoid)
})
}
describeNative("LocationWatcher", () => {
describeWatcher("LocationWatcher", () => {
it.live("limits file watches to the exact target", () =>
withTmp((directory) =>
Effect.gen(function* () {
@@ -312,25 +276,94 @@ describeNative("LocationWatcher", () => {
),
)
it.live("detects creation of a missing directory target", () =>
it.live("publishes root create, update, and delete events", () =>
withTmp(
(directory) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const file = path.join(directory, "watch.txt")
yield* ready(directory)
for (const item of [
{ event: "add" as const, trigger: fs.writeFileString(file, "a") },
{ event: "change" as const, trigger: fs.writeFileString(file, "b") },
{ event: "unlink" as const, trigger: fs.remove(file) },
]) {
expect(
yield* nextUpdate((event) => event.file === file && event.event === item.event, item.trigger),
).toEqual({
file,
event: item.event,
})
}
}),
{ vcs: "git" },
),
)
it.live("skips non-git roots", () =>
withTmp((directory) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const watcher = yield* Watcher.Service
const target = path.join(directory, "generated")
const updates = yield* watcher.subscribe({ path: target, type: "file" })
const update = yield* updates.pipe(
Stream.take(1),
Stream.runHead,
Effect.forkScoped({ startImmediately: true }),
)
const creates = yield* Effect.suspend(() =>
fs.remove(target, { recursive: true, force: true }).pipe(Effect.andThen(fs.ensureDir(target))),
).pipe(Effect.repeat(Schedule.spaced("10 millis")), Effect.forkScoped)
const event = yield* Fiber.join(update).pipe(Effect.ensuring(Fiber.interrupt(creates)))
const file = path.join(directory, "plain.txt")
yield* noUpdate((event) => event.file === file, fs.writeFileString(file, "plain"))
}),
),
)
expect(event.valueOrUndefined?.path).toBe(target)
}).pipe(Effect.provide(AppNodeBuilder.build(Watcher.node))),
it.live("ignores dependency, VCS, and build directories at any depth", () =>
withTmp(
(directory) =>
Effect.gen(function* () {
const afs = yield* FSUtil.Service
yield* ready(directory)
const roots = ["node_modules", ".git", "dist"].map((name) => path.join(directory, "nested", name))
const files = roots.map((root) => path.join(root, "package", "index.js"))
yield* noUpdate(
(event) => roots.some((root) => event.file === root || event.file.startsWith(`${root}${path.sep}`)),
Effect.forEach(files, (file) => afs.writeWithDirs(file, "ignored"), {
concurrency: "unbounded",
discard: true,
}),
)
}),
{ vcs: "git" },
),
)
it.live("cleanup stops publishing events", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const fs = yield* FSUtil.Service
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* ready(tmp.path).pipe(
provide(tmp.path, { type: "git", store: AbsolutePath.make(path.join(tmp.path, ".git")) }),
Effect.scoped,
)
const file = path.join(tmp.path, "after-dispose.txt")
yield* noUpdate((event) => event.file === file, fs.writeFileString(file, "gone")).pipe(
Effect.provideService(Bus.Service, bus),
)
}).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Bus.node])))),
)
it.live("ignores .git/index changes", () =>
withTmp(
(directory) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const index = path.join(directory, ".git", "index")
yield* ready(directory)
yield* noUpdate(
(event) => event.file === index,
fs
.writeFileString(path.join(directory, "tracked.txt"), "a")
.pipe(Effect.andThen(Effect.promise(() => $`git add .`.cwd(directory).quiet())), Effect.asVoid),
)
}),
{ vcs: "git" },
),
)
@@ -341,11 +374,11 @@ describeNative("LocationWatcher", () => {
const fs = yield* FSUtil.Service
const head = path.join(directory, ".git", "HEAD")
const branch = `watch-${Math.random().toString(36).slice(2)}`
yield* ready(head)
yield* ready(directory)
yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet())
expect(
yield* nextUpdate((event) => event.file === head, fs.writeFileString(head, `ref: refs/heads/${branch}\n`)),
).toEqual({ file: head, event: "change" })
).toMatchObject({ file: head })
}),
{ vcs: "git" },
),
@@ -360,8 +393,8 @@ describeNative("LocationWatcher", () => {
const afs = yield* FSUtil.Service
const actual = path.join(directory, "..", `actual_${path.basename(directory)}`)
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(actual, { recursive: true, force: true })))
yield* ready(directory)
const head = path.join(directory, ".git", "HEAD")
yield* ready(head, path.join(actual, "HEAD"))
const branch = `watch-${Math.random().toString(36).slice(2)}`
yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet())
expect(
@@ -389,7 +422,7 @@ describeNative("LocationWatcher", () => {
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const branch = path.join(directory, ".hg", "branch")
yield* ready(branch)
yield* ready(directory)
expect(
yield* nextUpdate((event) => event.file === branch, fs.writeFileString(branch, "feature\n")),
).toMatchObject({ file: branch })
@@ -1,169 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Failed, NotFound, WrongKind, type Files } from "../../src/environment/index"
import { it } from "./effect"
export interface EnvironmentHarness {
readonly files: Files
readonly root: string
readonly symlink?: (target: string, path: string) => Effect.Effect<void, Failed>
readonly dispose?: Effect.Effect<void>
}
export const environmentConformance = <E>(
name: string,
makeHarness: () => Effect.Effect<EnvironmentHarness, E>,
skip = false,
) => {
const check = <A, E2>(title: string, body: (harness: EnvironmentHarness) => Effect.Effect<A, E2>) =>
it.live(title, () =>
Effect.gen(function* () {
const harness = yield* Effect.acquireRelease(makeHarness(), (harness) =>
Effect.gen(function* () {
yield* Effect.ignore(harness.files.remove(harness.root))
if (harness.dispose) yield* harness.dispose
}),
)
yield* harness.files.mkdir(harness.root)
return yield* body(harness)
}),
)
const bytes = (value: string) => new TextEncoder().encode(value)
const text = (value: Uint8Array) => new TextDecoder().decode(value)
const suite = skip ? describe.skip : describe
suite(name, () => {
check("writes, stats, and reads a file with its info", (harness) =>
Effect.gen(function* () {
const target = `${harness.root}/hello.txt`
yield* harness.files.write(target, bytes("hello"))
const result = yield* harness.files.read(target)
expect(text(result.bytes)).toBe("hello")
expect(result.info.type).toBe("file")
expect(result.info.size).toBe(5)
expect(yield* harness.files.stat(target)).toEqual(result.info)
}),
)
check("reports missing paths", (harness) =>
Effect.gen(function* () {
const target = `${harness.root}/missing`
expect(yield* Effect.flip(harness.files.read(target))).toBeInstanceOf(NotFound)
expect(yield* Effect.flip(harness.files.stat(target))).toBeInstanceOf(NotFound)
expect(yield* Effect.flip(harness.files.list(target))).toBeInstanceOf(NotFound)
expect(yield* Effect.flip(harness.files.move(target, `${harness.root}/other`))).toBeInstanceOf(NotFound)
}),
)
check("reports the actual kind", (harness) =>
Effect.gen(function* () {
const directory = `${harness.root}/directory`
const file = `${harness.root}/file`
yield* harness.files.mkdir(directory)
yield* harness.files.write(file, bytes("data"))
const readError = yield* Effect.flip(harness.files.read(directory))
const listError = yield* Effect.flip(harness.files.list(file))
expect(readError).toBeInstanceOf(WrongKind)
expect((readError as WrongKind).actual).toBe("directory")
expect(listError).toBeInstanceOf(WrongKind)
expect((listError as WrongKind).actual).toBe("file")
}),
)
check("write creates parent directories", (harness) =>
Effect.gen(function* () {
const target = `${harness.root}/one/two/file`
yield* harness.files.write(target, bytes("nested"))
yield* harness.files.write(`${harness.root}/empty`, new Uint8Array())
expect((yield* harness.files.stat(`${harness.root}/one/two`)).type).toBe("directory")
expect(yield* harness.files.stat(`${harness.root}/empty`)).toMatchObject({ type: "file", size: 0 })
expect(text((yield* harness.files.read(target)).bytes)).toBe("nested")
}),
)
check("reads byte ranges", (harness) =>
Effect.gen(function* () {
const target = `${harness.root}/range`
yield* harness.files.write(target, bytes("0123456789"))
expect(text((yield* harness.files.read(target, { offset: 2, length: 4 })).bytes)).toBe("2345")
expect(text((yield* harness.files.read(target, { offset: 8, length: 8 })).bytes)).toBe("89")
expect(text((yield* harness.files.read(target, { offset: 20, length: 4 })).bytes)).toBe("")
}),
)
check("lists immediate entries with their kinds", (harness) =>
Effect.gen(function* () {
yield* harness.files.write(`${harness.root}/file name`, bytes("data"))
yield* harness.files.mkdir(`${harness.root}/directory`)
yield* harness.files.write(`${harness.root}/directory/nested`, bytes("nested"))
const entries = yield* harness.files.list(harness.root)
expect(entries.toSorted((a, b) => a.name.localeCompare(b.name))).toEqual([
{ name: "directory", type: "directory" },
{ name: "file name", type: "file" },
])
}),
)
check("preserves symlink metadata while following symlinks for content", (harness) =>
Effect.gen(function* () {
if (!harness.symlink) return
yield* harness.files.write(`${harness.root}/target`, bytes("target"))
yield* harness.files.write(`${harness.root}/target-dir/file`, bytes("through link"))
yield* harness.symlink("../target", `${harness.root}/target-dir/entry-link`)
yield* harness.symlink("target", `${harness.root}/link`)
yield* harness.symlink("target-dir", `${harness.root}/link-dir`)
yield* harness.symlink("missing", `${harness.root}/dangling-link`)
expect((yield* harness.files.stat(`${harness.root}/link`)).type).toBe("symlink")
expect(yield* harness.files.list(harness.root)).toContainEqual({ name: "link", type: "symlink" })
expect(text((yield* harness.files.read(`${harness.root}/link-dir/file`)).bytes)).toBe("through link")
expect(
(yield* harness.files.list(`${harness.root}/link-dir`)).toSorted((a, b) => a.name.localeCompare(b.name)),
).toEqual([
{ name: "entry-link", type: "symlink" },
{ name: "file", type: "file" },
])
const fileError = yield* Effect.flip(harness.files.list(`${harness.root}/link`))
expect(fileError).toBeInstanceOf(WrongKind)
expect((fileError as WrongKind).actual).toBe("file")
expect(yield* Effect.flip(harness.files.list(`${harness.root}/dangling-link`))).toBeInstanceOf(NotFound)
}),
)
check("follows symlinks when reading", (harness) =>
Effect.gen(function* () {
if (!harness.symlink) return
yield* harness.files.write(`${harness.root}/target`, bytes("target content"))
yield* harness.files.mkdir(`${harness.root}/directory`)
yield* harness.symlink("target", `${harness.root}/file-link`)
yield* harness.symlink("directory", `${harness.root}/directory-link`)
yield* harness.symlink("missing", `${harness.root}/dangling-link`)
const result = yield* harness.files.read(`${harness.root}/file-link`)
expect(text(result.bytes)).toBe("target content")
expect(result.info.type).toBe("file")
expect(result.info.size).toBe(bytes("target content").length)
const directoryError = yield* Effect.flip(harness.files.read(`${harness.root}/directory-link`))
expect(directoryError).toBeInstanceOf(WrongKind)
expect((directoryError as WrongKind).actual).toBe("directory")
expect(yield* Effect.flip(harness.files.read(`${harness.root}/dangling-link`))).toBeInstanceOf(NotFound)
}),
)
check("moves files and removes trees idempotently", (harness) =>
Effect.gen(function* () {
const source = `${harness.root}/source/file`
const destination = `${harness.root}/destination`
yield* harness.files.write(source, bytes("moved"))
yield* harness.files.move(source, destination)
expect(text((yield* harness.files.read(destination)).bytes)).toBe("moved")
expect(yield* Effect.flip(harness.files.stat(source))).toBeInstanceOf(NotFound)
yield* harness.files.remove(`${harness.root}/source`)
yield* harness.files.remove(`${harness.root}/source`)
expect(yield* Effect.flip(harness.files.stat(`${harness.root}/source`))).toBeInstanceOf(NotFound)
}),
)
})
}
@@ -13,7 +13,6 @@ import { Session } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
import { UserInterruptedError } from "@opencode-ai/core/session/error"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionRunner } from "@opencode-ai/core/session/runner"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
@@ -128,34 +127,23 @@ describe("SessionExecution lifecycle", () => {
it.effect("resumes each suspended Session at most once", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const bus = yield* Bus.Service
const first = Session.ID.make("ses_resume_first")
const second = Session.ID.make("ses_resume_second")
yield* seedSessions(database, [first, second], { time_suspended: Date.now() })
const drained: string[] = []
const continued: SessionEvent.Synthetic[] = []
const scope = yield* Scope.make()
const context = yield* buildExecution(scope, ({ sessionID }) => Effect.sync(() => void drained.push(sessionID)))
const execution = Context.get(context, SessionExecution.Service)
const restart = Context.get(context, SessionRestart.Service)
yield* bus.project(SessionEvent.Synthetic, (event) => Effect.sync(() => void continued.push(event)))
yield* restart.resumeSuspendedSessions
yield* Effect.forEach([first, second], execution.awaitIdle, { discard: true })
expect(drained.toSorted()).toEqual([first, second])
expect(continued.map((event) => event.data).toSorted((a, b) => a.sessionID.localeCompare(b.sessionID))).toEqual(
[first, second].map((sessionID) => ({
sessionID,
text: "The server restarted while you were working. Continue from where you left off without repeating completed work.",
description: "Continuing after restart",
})),
)
expect(yield* suspensions(database)).toEqual({ [first]: false, [second]: false })
yield* restart.resumeSuspendedSessions
expect(drained.length).toBe(2)
expect(continued.length).toBe(2)
yield* Scope.close(scope, Exit.void)
}),
)
-74
View File
@@ -1086,78 +1086,4 @@ describe("Session.pending", () => {
expect(yield* session.pending(sessionID)).toEqual([])
}),
)
it.effect("cancels pending input and allows its ID to be admitted again", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
const inputID = SessionMessage.ID.make("msg_cancelled_queue")
yield* session.prompt({
id: inputID,
sessionID,
text: "Queue this",
delivery: "queue",
resume: false,
})
yield* session.cancelPending({ sessionID, inputID })
expect(yield* session.pending(sessionID)).toEqual([])
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1)
expect(
yield* session.cancelPending({ sessionID, inputID }).pipe(Effect.flip),
).toMatchObject({ _tag: "Session.PendingInputConflictError", sessionID, inputID })
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1)
const retried = yield* session.prompt({
id: inputID,
sessionID,
text: "Queue this",
delivery: "queue",
resume: false,
})
expect(retried).toMatchObject({ id: inputID, delivery: "queue" })
}),
)
it.effect("moves pending input between steer and queue delivery", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
const queued = yield* session.synthetic({
sessionID,
text: "Steer this",
delivery: "queue",
resume: false,
})
const alreadySteered = yield* session.prompt({ sessionID, text: "Already steer", resume: false })
wakeCalls.length = 0
yield* session.steerPending({ sessionID, inputID: queued.id })
expect(yield* session.pending(sessionID)).toMatchObject([
{ id: queued.id, delivery: "steer" },
{ id: alreadySteered.id, delivery: "steer" },
])
expect(wakeCalls).toEqual([sessionID])
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputSteered.type, 1))).toBe(1)
wakeCalls.length = 0
yield* session.queuePending({ sessionID, inputID: queued.id })
expect(yield* session.pending(sessionID)).toMatchObject([
{ id: queued.id, delivery: "queue" },
{ id: alreadySteered.id, delivery: "steer" },
])
expect(wakeCalls).toEqual([])
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputQueued.type, 1))).toBe(1)
expect(
yield* session.steerPending({ sessionID, inputID: alreadySteered.id }).pipe(Effect.flip),
).toMatchObject({ _tag: "Session.PendingInputConflictError", sessionID, inputID: alreadySteered.id })
yield* session.cancelPending({ sessionID, inputID: alreadySteered.id })
expect(wakeCalls).toEqual([])
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputSteered.type, 1))).toBe(1)
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1)
}),
)
})
@@ -110,13 +110,7 @@ describe("toLLMMessages", () => {
)
expect(messages.map((message) => message.role)).toEqual(["system", "user", "user", "user", "user"])
expect(messages[0]).toEqual(
Message.make({
id: "msg_system",
role: "system",
content: [{ type: "text", text: "Updated context\n\nOther context" }],
}),
)
expect(messages[0]).toEqual(Message.system("Updated context\n\nOther context"))
expect(messages[1]).toEqual(
Message.make({
id: id("user"),
@@ -482,7 +476,6 @@ Recent work
{
type: "tool-result",
id: "completed",
itemId: "fco_completed",
name: "read",
result: {
type: "content",
@@ -520,7 +513,6 @@ Recent work
{
type: "reasoning",
text: "Think",
itemId: "rs_1",
providerMetadata: { provider: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
])
@@ -610,7 +602,6 @@ Recent work
{
type: "tool-call",
id: "hosted-completed",
itemId: "call_completed",
name: "web_search",
input: { query: "Effect" },
providerExecuted: true,
@@ -619,7 +610,6 @@ Recent work
{
type: "tool-result",
id: "hosted-completed",
itemId: "result_completed",
name: "web_search",
result: { type: "text", value: '{"found":true}' },
providerExecuted: true,
@@ -630,7 +620,6 @@ Recent work
{
type: "tool-call",
id: "hosted-failed",
itemId: "call_failed",
name: "web_search",
input: { query: "Effect" },
providerExecuted: true,
@@ -639,7 +628,6 @@ Recent work
{
type: "tool-result",
id: "hosted-failed",
itemId: "result_failed",
name: "web_search",
result: {
type: "error",
@@ -710,7 +698,6 @@ Recent work
{
type: "tool-call",
id: "hosted-old-model",
itemId: undefined,
name: "web_search",
input: { query: "Effect" },
providerExecuted: true,
@@ -719,7 +706,6 @@ Recent work
{
type: "tool-result",
id: "hosted-old-model",
itemId: "hosted-old-model",
name: "web_search",
result: { type: "text", value: '{"status":"completed"}' },
providerExecuted: true,
@@ -732,7 +718,6 @@ Recent work
{
type: "tool-call",
id: "local-old-model",
itemId: undefined,
name: "read",
input: { path: "README.md" },
providerExecuted: false,
@@ -743,7 +728,6 @@ Recent work
{
type: "tool-result",
id: "local-old-model",
itemId: "fco_local-old-model",
name: "read",
result: { type: "text", value: "Hello" },
providerExecuted: false,
@@ -24,7 +24,9 @@ const capture = (providerMetadataKey = "anthropic", options?: { readonly interru
const publish = Effect.sync(() => {
const event = { id: Event.ID.create(), type: definition.type, data } as Event.Payload<typeof definition>
published.push({
type: definition.durable ? Bus.versionedType(definition.type, definition.durable.version) : definition.type,
type: definition.durable
? Bus.versionedType(definition.type, definition.durable.version)
: definition.type,
data,
})
return event
@@ -64,10 +66,9 @@ const hostedResult = LLMEvent.toolResult({
test("local tool success serializes media base64 once through canonical content", async () => {
const { published, publisher } = capture()
const localCall = LLMEvent.toolCall({ ...call, itemId: "fc_call-image" })
await Effect.runPromise(publisher.publish(localCall))
await Effect.runPromise(publisher.publish(call))
await Effect.runPromise(
publisher.toolExecution(localCall.id, localCall.name, {
publisher.toolExecution(call.id, call.name, {
output: { type: "media", mime: "image/png" },
content: [
{ type: "text", text: "Image read successfully" },
@@ -83,11 +84,6 @@ test("local tool success serializes media base64 once through canonical content"
expect(success?.data).not.toHaveProperty("result")
expect(success?.data).not.toHaveProperty("output")
const called = published.find((event) => event.type === "session.tool.called.1")?.data
expect(called).toMatchObject({ state: { itemId: "fc_call-image" } })
expect(success?.data).not.toHaveProperty("resultState")
expect(JSON.stringify(success?.data)).not.toContain('"itemId":"fc_call-image"')
expect(success?.data).toMatchObject({
content: [
{ type: "text", text: "Image read successfully" },
@@ -230,7 +226,9 @@ test("provider-executed tool metadata is flattened using the route key", async (
test("binary failure emits no success event", async () => {
const { published, publisher } = capture()
await Effect.runPromise(publisher.publish(call))
await Effect.runPromise(publisher.failTool(call.id, { type: "tool.execution", message: "Cannot read binary file" }))
await Effect.runPromise(
publisher.failTool(call.id, { type: "tool.execution", message: "Cannot read binary file" }),
)
expect(published.some((event) => event.type === "session.tool.success.2")).toBe(false)
expect(published.some((event) => event.type === "session.tool.failed.2")).toBe(true)
})
@@ -3,12 +3,10 @@ import { Agent } from "@opencode-ai/core/agent"
import type { Permission } from "@opencode-ai/core/permission"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Image } from "@opencode-ai/core/image"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { Session } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Tool } from "@opencode-ai/core/tool"
import type { Info } from "@opencode-ai/schema/tool"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { executeTool, toolDefinitions } from "./lib/tool"
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, SchemaGetter, SchemaIssue, Scope } from "effect"
import { testEffect } from "./lib/effect"
@@ -28,14 +26,10 @@ const imageStore = Layer.mock(Image.Service, {
maxBytes: 5,
}),
)
return Effect.succeed({
...content,
content: Buffer.from(`${Buffer.from(content.content, "base64").toString()} normalized`).toString("base64"),
mime: "image/jpeg",
})
return Effect.succeed({ ...content, content: "bm9ybWFsaXplZA==", mime: "image/jpeg" })
},
})
const registryLayer = AppNodeBuilder.build(LayerNode.group([Tool.node, PluginHooks.node]), [[Image.node, imageStore]])
const registryLayer = AppNodeBuilder.build(Tool.node, [[Image.node, imageStore]])
const it = testEffect(registryLayer)
const identity = {
agent: Agent.ID.make("build"),
@@ -350,7 +344,7 @@ describe("Tool", () => {
}),
)
it.effect("normalizes image tool output once and drops unresizable images", () =>
it.effect("normalizes image tool output at execution and drops unresizable images", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
yield* transform(service,
@@ -382,12 +376,7 @@ describe("Tool", () => {
const execution = yield* executeTool(service, call("snapshot"))
expect(execution.content).toEqual([
{
type: "file",
uri: "data:image/jpeg;base64,aW1hZ2Ugbm9ybWFsaXplZA==",
mime: "image/jpeg",
name: "frame.png",
},
{ type: "file", uri: "data:image/jpeg;base64,bm9ybWFsaXplZA==", mime: "image/jpeg", name: "frame.png" },
{ type: "text", text: "snapshot" },
{ type: "text", text: "[1 image omitted: could not be decoded.]" },
{ type: "text", text: "[1 image omitted: could not be resized below the image size limit.]" },
@@ -395,34 +384,6 @@ describe("Tool", () => {
}),
)
it.effect("normalizes image content added by an after hook", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
const hooks = yield* PluginHooks.Service
yield* transform(service, { hooked: constant("original") }, { codemode: false })
yield* hooks.register("tool", "execute.after", (event) =>
Effect.sync(() => {
if (event.status !== "completed") return
event.result = {
...event.result,
content: [
{ type: "file", uri: "data:image/png;base64,aW1hZ2U=", mime: "image/png", name: "hook.png" },
],
}
}),
)
expect((yield* executeTool(service, call("hooked"))).content).toEqual([
{
type: "file",
uri: "data:image/jpeg;base64,aW1hZ2Ugbm9ybWFsaXplZA==",
mime: "image/jpeg",
name: "hook.png",
},
])
}),
)
it.effect("publishes progress metadata unchanged", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
+9 -36
View File
@@ -558,15 +558,6 @@ const messageTexts = (request: LLMRequest, role: "user" | "system") =>
const userTexts = (request: LLMRequest) => messageTexts(request, "user")
const systemTexts = (request: LLMRequest) => messageTexts(request, "system")
const messageRoles = (request: LLMRequest | undefined) => request?.messages.map((message) => message.role)
const withoutItemIDs = (messages: LLMRequest["messages"]) =>
messages.map((message) => ({
role: message.role,
content: message.content.map((part) => {
if (!("itemId" in part)) return part
const { itemId: _itemId, ...content } = part
return content
}),
}))
const recordedEventTypes = (id: Session.ID) =>
Effect.gen(function* () {
@@ -865,8 +856,8 @@ describe("SessionRunnerLLM", () => {
yield* Fiber.join(renamed)
expect(requests).toHaveLength(5)
expect(withoutItemIDs(requests[2]!.messages)).toContainEqual(withoutItemIDs([Message.user("First prompt")])[0])
expect(withoutItemIDs(requests[4]!.messages)).toContainEqual(withoutItemIDs([Message.user("First prompt")])[0])
expect(requests[2]?.messages).toContainEqual(Message.user("First prompt"))
expect(requests[4]?.messages).toContainEqual(Message.user("First prompt"))
expect((yield* session.get(sessionID)).title).toBe("Generated title")
}),
)
@@ -891,7 +882,7 @@ describe("SessionRunnerLLM", () => {
// A hook-removed call fails independently and continues while step allowance remains.
expect(requests).toHaveLength(2)
expect(requests[0]?.system.map((part) => part.text)).toEqual(["Hooked system"])
expect(withoutItemIDs(requests[0]!.messages)).toEqual(withoutItemIDs([Message.user("Hooked message")]))
expect(requests[0]?.messages).toEqual([Message.user("Hooked message")])
expect(requests[0]?.tools.map((tool) => tool.name)).not.toContain("echo")
expect(requests[0]?.tools.map((tool) => tool.name)).not.toContain("unregistered")
expect(executions).toEqual([])
@@ -1316,14 +1307,12 @@ describe("SessionRunnerLLM", () => {
systemBaseline = "Changed context"
yield* runPrompt(session, "Second")
const firstSnapshot = PromptCacheDiagnostics.snapshot(requests[0]!)
const secondSnapshot = PromptCacheDiagnostics.snapshot(requests[1]!)
expect(PromptCacheDiagnostics.compare(firstSnapshot, secondSnapshot)).toEqual({
status: "append-only",
previousMessages: 1,
currentMessages: 3,
})
expect(secondSnapshot.messages[0]).toEqual(firstSnapshot.messages[0])
expect(
PromptCacheDiagnostics.compare(
PromptCacheDiagnostics.snapshot(requests[0]),
PromptCacheDiagnostics.snapshot(requests[1]),
),
).toEqual({ status: "append-only", previousMessages: 1, currentMessages: 3 })
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
[defaultSystem, "Initial context"],
[defaultSystem, "Initial context"],
@@ -2543,24 +2532,9 @@ describe("SessionRunnerLLM", () => {
{
type: "reasoning",
text: "Encrypted thought",
itemId: "rs_1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
])
yield* admit(session, "Continue again")
yield* TestLLM.push([])
yield* session.resume(sessionID)
expect(requests[2]?.messages[1]?.content.map((part) => ("itemId" in part ? part.itemId : undefined))).toEqual(
requests[1]?.messages[1]?.content.map((part) => ("itemId" in part ? part.itemId : undefined)),
)
expect(
PromptCacheDiagnostics.compare(
PromptCacheDiagnostics.snapshot(requests[1]!),
PromptCacheDiagnostics.snapshot(requests[2]!),
),
).toEqual({ status: "append-only", previousMessages: 3, currentMessages: 4 })
}),
)
@@ -2639,7 +2613,6 @@ describe("SessionRunnerLLM", () => {
{
type: "tool-call",
id: "hosted-search",
itemId: "hosted-search",
name: "web_search",
input: { query: "Effect" },
providerExecuted: true,
+17 -218
View File
@@ -6,10 +6,11 @@ import { Agent } from "@opencode-ai/core/agent"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Skill } from "@opencode-ai/core/skill"
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
@@ -24,15 +25,8 @@ const discovery = Layer.succeed(
},
}),
)
const watcherLayer = Watcher.testLayer
const it = testEffect(
Layer.mergeAll(
AppNodeBuilder.build(LayerNode.group([Skill.node, Agent.node, Bus.node]), [
[SkillDiscovery.node, discovery],
[Watcher.node, watcherLayer],
]),
watcherLayer,
),
AppNodeBuilder.build(LayerNode.group([Skill.node, Agent.node, Bus.node]), [[SkillDiscovery.node, discovery]]),
)
function write(directory: string, name: string, description: string) {
@@ -59,24 +53,6 @@ function waitForSkillUpdate() {
})
}
function expectSubscription(check: (input: Watcher.WatchInput) => boolean) {
return Effect.gen(function* () {
const watcher = yield* Watcher.Test
expect((yield* watcher.subscriptions()).some(check)).toBe(true)
})
}
function emitAndWait(update: Watcher.Update) {
return Effect.gen(function* () {
const watcher = yield* Watcher.Test
yield* Effect.acquireUseRelease(
waitForSkillUpdate(),
({ deferred }) => watcher.emit(update).pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeout("1 second")),
({ fiber }) => Fiber.interrupt(fiber),
)
})
}
describe("Skill", () => {
it.live("publishes updates when skill sources change", () =>
Effect.gen(function* () {
@@ -113,7 +89,6 @@ describe("Skill", () => {
})
const skill = yield* Skill.Service
const watcher = yield* Watcher.Test
yield* skill.transform((editor) => {
editor.source({ type: "directory", path: AbsolutePath.make(first) })
editor.source({ type: "directory", path: AbsolutePath.make(first) })
@@ -144,21 +119,6 @@ describe("Skill", () => {
content: "# review",
},
])
expect(yield* watcher.subscriptions()).toEqual([
{ path: first, type: "directory" },
{ path: second, type: "directory" },
])
yield* Effect.promise(() => write(second, "review", "Updated Second"))
yield* emitAndWait({ type: "update", path: path.join(second, "review", "SKILL.md") })
expect((yield* skill.list()).find((item) => item.id === "review")?.description).toBe("Updated Second")
expect(yield* watcher.subscriptions()).toEqual([
{ path: first, type: "directory" },
{ path: second, type: "directory" },
{ path: first, type: "directory" },
{ path: second, type: "directory" },
])
}),
),
),
@@ -238,7 +198,7 @@ metadata:
),
)
it.live("clears cached skills when sources reload", () =>
it.live("invalidates cached skills and publishes updates for watcher changes", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
@@ -250,187 +210,26 @@ metadata:
await write(tmp.path, "deploy", "Initial deploy")
})
const skill = yield* Skill.Service
const watcher = yield* Watcher.Test
const bus = yield* Bus.Service
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(tmp.path) }))
expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Initial deploy")
expect(yield* watcher.subscriptions()).toEqual([{ path: tmp.path, type: "directory" }])
let refreshed: Skill.Info[] = []
const unsubscribe = yield* bus.listen((event) => {
if (event.type !== Skill.Event.Updated.type) return Effect.void
return skill.list().pipe(
Effect.tap((items) => Effect.sync(() => (refreshed = items))),
Effect.asVoid,
)
})
yield* Effect.promise(() => write(tmp.path, "deploy", "Updated deploy"))
yield* skill.reload().pipe(Effect.timeout("1 second"))
yield* unsubscribe
expect(refreshed.find((item) => item.id === "deploy")?.description).toBe("Updated deploy")
expect(yield* watcher.subscriptions()).toEqual([
{ path: tmp.path, type: "directory" },
{ path: tmp.path, type: "directory" },
])
}),
),
),
)
it.live("reloads project sources created after their missing parent", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const source = path.join(tmp.path, "generated", "skills")
const file = path.join(source, "deploy", "SKILL.md")
const skill = yield* Skill.Service
const watcher = yield* Watcher.Test
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(source) }))
expect(yield* skill.list()).toEqual([])
expect(yield* watcher.subscriptions()).toEqual([{ path: path.join(tmp.path, "generated"), type: "file" }])
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "generated")))
yield* emitAndWait({ type: "create", path: path.join(tmp.path, "generated") })
expect(yield* skill.list()).toEqual([])
expect(yield* watcher.subscriptions()).toEqual([
{ path: path.join(tmp.path, "generated"), type: "file" },
{ path: source, type: "file" },
])
yield* Effect.promise(async () => {
await fs.mkdir(path.dirname(file), { recursive: true })
await write(source, "deploy", "Deploy production")
})
yield* emitAndWait({ type: "create", path: source })
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
expect(yield* watcher.subscriptions()).toEqual([
{ path: path.join(tmp.path, "generated"), type: "file" },
{ path: source, type: "file" },
{ path: source, type: "directory" },
])
}),
),
),
)
it.live("watches directory sources for added and changed skills", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, "deploy"), { recursive: true })
await write(tmp.path, "deploy", "Initial deploy")
})
const skill = yield* Skill.Service
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(tmp.path) }))
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
yield* expectSubscription((input) => input.type === "directory" && input.path === tmp.path)
const deploy = path.join(tmp.path, "deploy", "SKILL.md")
expect((yield* skill.list()).find((item) => item.name === "deploy")?.description).toBe("Initial deploy")
const file = path.join(tmp.path, "deploy", "SKILL.md")
yield* Effect.promise(() => write(tmp.path, "deploy", "Updated deploy"))
yield* emitAndWait({ type: "update", path: deploy })
expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Updated deploy")
expect((yield* skill.list()).find((item) => item.name === "deploy")?.description).toBe("Initial deploy")
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, "review"), { recursive: true })
await write(tmp.path, "review", "Review changes")
})
const review = path.join(tmp.path, "review", "SKILL.md")
yield* emitAndWait({ type: "create", path: review })
expect((yield* skill.list()).map((item) => item.id)).toEqual([
Skill.ID.make("deploy"),
Skill.ID.make("review"),
])
yield* Effect.acquireUseRelease(
waitForSkillUpdate(),
({ deferred }) =>
bus
.publish(FileSystem.Event.Changed, { file, event: "change" })
.pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeout("1 second")),
({ fiber }) => Fiber.interrupt(fiber),
)
yield* Effect.promise(() => fs.rm(path.join(tmp.path, "review"), { recursive: true }))
yield* emitAndWait({ type: "delete", path: review })
expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
}),
),
),
)
it.live("watches canonical directories behind symlinked skills", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const source = path.join(tmp.path, "source")
const target = path.join(tmp.path, "target", "bro")
const file = path.join(target, "SKILL.md")
yield* Effect.promise(async () => {
await fs.mkdir(source, { recursive: true })
await fs.mkdir(target, { recursive: true })
await fs.writeFile(file, "---\nname: bro\ndescription: Initial\n---\n# bro")
await fs.symlink(target, path.join(source, "bro"))
})
const skill = yield* Skill.Service
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(source) }))
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Initial")
yield* expectSubscription((input) => input.type === "directory" && input.path === target)
yield* Effect.promise(() => fs.writeFile(file, "---\nname: bro\ndescription: Updated\n---\n# bro"))
yield* emitAndWait({ type: "update", path: file })
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Updated")
}),
),
),
)
it.live("invalidates symlinked sources when their target changes", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const source = path.join(tmp.path, "source")
const first = path.join(tmp.path, "first")
const second = path.join(tmp.path, "second")
yield* Effect.promise(async () => {
await fs.mkdir(path.join(first, "bro"), { recursive: true })
await fs.mkdir(path.join(second, "bro"), { recursive: true })
await write(first, "bro", "First")
await write(second, "bro", "Second")
await fs.symlink(first, source)
})
const skill = yield* Skill.Service
const watcher = yield* Watcher.Test
yield* skill.transform((editor) => editor.source({ type: "directory", path: AbsolutePath.make(source) }))
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("First")
expect(yield* watcher.subscriptions()).toEqual([
{ path: first, type: "directory" },
{ path: source, type: "file" },
])
yield* Effect.promise(async () => {
await fs.unlink(source)
await fs.symlink(second, source)
})
yield* emitAndWait({ type: "update", path: source })
expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Second")
expect(yield* watcher.subscriptions()).toEqual([
{ path: first, type: "directory" },
{ path: source, type: "file" },
{ path: second, type: "directory" },
{ path: source, type: "file" },
])
expect((yield* skill.list()).find((item) => item.name === "deploy")?.description).toBe("Updated deploy")
}),
),
),
+33 -68
View File
@@ -4,9 +4,9 @@ import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Environment } from "@opencode-ai/core/environment"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Formatter } from "@opencode-ai/core/formatter"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { Permission } from "@opencode-ai/core/permission"
@@ -23,15 +23,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
const editToolNode = makeLocationNode({
name: "test/edit-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(EditTool.Plugin)),
deps: [
Tool.node,
LocationMutation.node,
FileMutation.node,
Environment.node,
Formatter.node,
Location.node,
Permission.node,
],
deps: [Tool.node, LocationMutation.node, FileMutation.node, Formatter.node, FSUtil.node, Permission.node],
})
const sessionID = Session.ID.make("ses_edit_tool_test")
@@ -80,28 +72,29 @@ const reset = () => {
formatFile = () => Effect.succeed(false)
}
const environment = Layer.effect(
Environment.Service,
const filesystem = Layer.effect(
FSUtil.Service,
Effect.gen(function* () {
const current = yield* Environment.Service
return Environment.Service.of({
...current,
files: {
...current.files,
read: (target, range) =>
current.files
.read(target, range)
.pipe(
Effect.tap((result) =>
Effect.sync(() => reads++).pipe(Effect.andThen(Effect.suspend(() => afterRead(target, result.bytes)))),
),
const fs = yield* FSUtil.Service
return FSUtil.Service.of({
...fs,
readFile: (target) =>
fs
.readFile(target)
.pipe(
Effect.tap((content) =>
Effect.sync(() => reads++).pipe(Effect.andThen(Effect.suspend(() => afterRead(target, content)))),
),
write: (target, content) =>
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(current.files.write(target, content))),
},
),
writeWithDirs: (target, content, mode) =>
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeWithDirs(target, content, mode))),
writeFile: (target, content, options) =>
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeFile(target, content, options))),
writeFileString: (target, content, options) =>
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeFileString(target, content, options))),
})
}),
).pipe(Layer.provide(LayerNode.compile(Environment.node)))
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) => Effect.Effect<A, E, R>) => {
const activeLocation = Layer.succeed(
@@ -113,9 +106,15 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
}).pipe(
Effect.provide(
AppNodeBuilder.build(
LayerNode.group([Tool.node, Tool.node, LocationMutation.node, FileMutation.node, editToolNode]),
LayerNode.group([
Tool.node,
Tool.node,
LocationMutation.node,
FileMutation.node,
editToolNode,
]),
[
[Environment.node, environment],
[FSUtil.node, filesystem],
[Location.node, activeLocation],
[Formatter.node, formatter],
[Permission.node, permission],
@@ -472,7 +471,10 @@ describe("EditTool", () => {
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect(
yield* executeTool(registry, call({ path: "missing.ts", oldString: "before", newString: "after" })),
yield* executeTool(
registry,
call({ path: "missing.ts", oldString: "before", newString: "after" }),
),
).toEqual({
status: "error",
error: { type: "tool.execution", message: "File not found: missing.ts" },
@@ -643,43 +645,6 @@ describe("EditTool", () => {
),
)
it.live("serializes concurrent edit transactions", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const target = path.join(tmp.path, "concurrent.txt")
afterRead = () => (reads === 1 ? Effect.sleep("50 millis") : Effect.void)
return Effect.promise(() => fs.writeFile(target, "one\ntwo\n")).pipe(
Effect.andThen(
withTool(tmp.path, (registry) =>
Effect.all(
[
executeTool(
registry,
call({ path: "concurrent.txt", oldString: "one", newString: "ONE" }, "call-edit-one"),
),
executeTool(
registry,
call({ path: "concurrent.txt", oldString: "two", newString: "TWO" }, "call-edit-two"),
),
],
{ concurrency: "unbounded" },
),
),
),
Effect.andThen((results) =>
Effect.gen(function* () {
expect(results.map((result) => result.status)).toEqual(["completed", "completed"])
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("ONE\nTWO\n")
}),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("applies the edit when content changes after matching", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
+42 -65
View File
@@ -2,12 +2,11 @@ import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Effect, Exit, Layer, Schema } from "effect"
import { systemError } from "effect/PlatformError"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Environment } from "@opencode-ai/core/environment"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Formatter } from "@opencode-ai/core/formatter"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Location } from "@opencode-ai/core/location"
import { Permission } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -23,7 +22,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
const patchToolNode = makeLocationNode({
name: "test/patch-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
deps: [Tool.node, FileMutation.node, Environment.node, Formatter.node, Location.node, Permission.node],
deps: [Tool.node, Formatter.node, FSUtil.node, Location.node, Permission.node],
})
const sessionID = Session.ID.make("ses_patch_tool_test")
@@ -82,33 +81,48 @@ const reset = () => {
formatFile = () => Effect.succeed(false)
}
const environment = Layer.effect(
Environment.Service,
const filesystem = Layer.effect(
FSUtil.Service,
Effect.gen(function* () {
const current = yield* Environment.Service
return Environment.Service.of({
...current,
files: {
...current.files,
read: (target, range) =>
Effect.sync(() => {
if (!editApproved) readsBeforeEditApproval++
}).pipe(Effect.andThen(current.files.read(target, range))),
remove: (target) => {
if (failRemoveTarget && path.basename(target) === failRemoveTarget) return Effect.die("forced remove failure")
if (failRemoveErrorTarget && path.basename(target) === failRemoveErrorTarget)
return Effect.fail(new Environment.Failed({ path: target, cause: new Error("forced remove failure") }))
return current.files.remove(target)
},
write: (target, content) => {
if (failWriteTarget && path.basename(target) === failWriteTarget)
return Effect.fail(new Environment.Failed({ path: target, cause: new Error("forced write failure") }))
return current.files.write(target, content)
},
const fs = yield* FSUtil.Service
return FSUtil.Service.of({
...fs,
readFile: (target) =>
Effect.sync(() => {
if (!editApproved) readsBeforeEditApproval++
}).pipe(Effect.andThen(fs.readFile(target))),
remove: (target, options) => {
if (failRemoveTarget && path.basename(target) === failRemoveTarget) return Effect.die("forced remove failure")
if (failRemoveErrorTarget && path.basename(target) === failRemoveErrorTarget) {
return Effect.fail(
systemError({
_tag: "Unknown",
module: "FileSystem",
method: "remove",
description: "forced remove failure",
pathOrDescriptor: target,
}),
)
}
return fs.remove(target, options)
},
writeWithDirs: (target, content, mode) => {
if (failWriteTarget && path.basename(target) === failWriteTarget) {
return Effect.fail(
systemError({
_tag: "Unknown",
module: "FileSystem",
method: "writeWithDirs",
description: "forced write failure",
pathOrDescriptor: target,
}),
)
}
return fs.writeWithDirs(target, content, mode)
},
})
}),
).pipe(Layer.provide(LayerNode.compile(Environment.node)))
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
const withTool = <A, E, R>(
directory: string,
@@ -125,8 +139,8 @@ const withTool = <A, E, R>(
return yield* body(yield* Tool.Service)
}).pipe(
Effect.provide(
AppNodeBuilder.build(LayerNode.group([Tool.node, FileMutation.node, patchToolNode]), [
[Environment.node, environment],
AppNodeBuilder.build(LayerNode.group([Tool.node, patchToolNode]), [
[FSUtil.node, filesystem],
[Location.node, activeLocation],
[Formatter.node, formatter],
[Permission.node, permission],
@@ -248,43 +262,6 @@ describe("PatchTool", () => {
),
)
it.live("serializes concurrent patch transactions", () =>
withTempTool((directory, registry) => {
const target = path.join(directory, "concurrent.txt")
afterEditApproval = () =>
assertions.filter((input) => input.action === "edit").length === 1 ? Effect.sleep("50 millis") : Effect.void
return Effect.promise(() => fs.writeFile(target, "one\ntwo\n")).pipe(
Effect.andThen(
Effect.all(
[
executeTool(
registry,
call(
"*** Begin Patch\n*** Update File: concurrent.txt\n@@\n-one\n+ONE\n*** End Patch",
"call-patch-one",
),
),
executeTool(
registry,
call(
"*** Begin Patch\n*** Update File: concurrent.txt\n@@\n-two\n+TWO\n*** End Patch",
"call-patch-two",
),
),
],
{ concurrency: "unbounded" },
),
),
Effect.andThen((results) =>
Effect.gen(function* () {
expect(results.map((result) => result.status)).toEqual(["completed", "completed"])
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("ONE\nTWO\n")
}),
),
)
}),
)
it.live("returns file diffs for final formatted content", () =>
withTempTool((directory, registry) => {
const target = path.join(directory, "formatted.txt")
+51 -161
View File
@@ -1,65 +1,66 @@
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Environment } from "@opencode-ai/core/environment"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
import { Effect, FileSystem } from "effect"
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Effect, FileSystem } from "effect"
import { ChildProcessSpawner } from "effect/unstable/process"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
import { testEffect } from "./lib/effect"
const it = testEffect(LayerNode.compile(LayerNode.group([CrossSpawnSpawner.node, LayerNodePlatform.filesystem])))
const it = testEffect(LayerNode.compile(LayerNode.group([FSUtil.node, LayerNodePlatform.filesystem])))
const fixture = Effect.gen(function* () {
const fs = yield* FSUtil.Service
const files = yield* FileSystem.FileSystem
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const directory = yield* files.makeTempDirectoryScoped()
return { environment: Environment.makeFiles(Environment.makeLocalDriver(spawner)), files, directory }
return { fs, files, directory }
})
const absolute = (value: string) => AbsolutePath.make(value)
describe("ReadToolFileSystem", () => {
it.effect("preserves the environment not-found error", () =>
it.effect("fails with a typed filesystem error when a resolved file disappears", () =>
Effect.gen(function* () {
const { environment, directory } = yield* fixture
const { fs, directory } = yield* fixture
const file = path.join(directory, "missing.txt")
const error = yield* ReadToolFileSystem.read(environment, absolute(file), "missing.txt").pipe(Effect.flip)
const error = yield* ReadToolFileSystem.read(fs, file, "missing.txt").pipe(Effect.flip)
expect(error).toBeInstanceOf(Environment.NotFound)
expect(error).toMatchObject({ _tag: "PlatformError" })
}),
)
it.effect("returns a listing when read reports a directory", () =>
it.effect("fails when a file becomes the wrong path kind", () =>
Effect.gen(function* () {
const { environment, files, directory } = yield* fixture
yield* files.makeDirectory(path.join(directory, "folder"))
yield* files.writeFileString(path.join(directory, "file.txt"), "hello")
const { fs, directory } = yield* fixture
const result = yield* ReadToolFileSystem.read(environment, absolute(directory), "folder")
const error = yield* ReadToolFileSystem.read(fs, directory, "folder").pipe(Effect.flip)
expect(result).toMatchObject({
type: "list-page",
entries: [
{ path: `folder${path.sep}`, type: "directory" },
{ path: "file.txt", type: "file" },
],
})
expect(error).toBeInstanceOf(ReadToolFileSystem.PathKindError)
}),
)
it.effect("fails with a typed filesystem error when directory listing fails", () =>
Effect.gen(function* () {
const { fs, files, directory } = yield* fixture
const file = path.join(directory, "file.txt")
yield* files.writeFileString(file, "hello")
const error = yield* ReadToolFileSystem.list(fs, file).pipe(Effect.flip)
expect(error).toBeInstanceOf(FSUtil.FileSystemError)
if (error instanceof FSUtil.FileSystemError) expect(error.method).toBe("readDirectoryEntries")
}),
)
it.effect("reads malformed UTF-8 lossily and still rejects null-byte binary content", () =>
Effect.gen(function* () {
const { environment, files, directory } = yield* fixture
const { fs, files, directory } = yield* fixture
const binary = path.join(directory, "archive.dat")
const malformed = path.join(directory, "malformed.txt")
yield* files.writeFile(binary, Uint8Array.of(0, 1, 2, 3))
yield* files.writeFile(malformed, Uint8Array.of(0x68, 0x69, 0x80))
const binaryError = yield* ReadToolFileSystem.read(environment, absolute(binary), "archive.dat").pipe(Effect.flip)
const malformedResult = yield* ReadToolFileSystem.read(environment, absolute(malformed), "malformed.txt")
const binaryError = yield* ReadToolFileSystem.read(fs, binary, "archive.dat").pipe(Effect.flip)
const malformedResult = yield* ReadToolFileSystem.read(fs, malformed, "malformed.txt")
expect(binaryError).toBeInstanceOf(ReadToolFileSystem.BinaryFileError)
expect(binaryError.message).toBe("Cannot read binary file: archive.dat")
@@ -69,11 +70,11 @@ describe("ReadToolFileSystem", () => {
it.effect("reads text despite a binary-associated extension", () =>
Effect.gen(function* () {
const { environment, files, directory } = yield* fixture
const { fs, files, directory } = yield* fixture
const file = path.join(directory, "notes.docx")
yield* files.writeFileString(file, "plain text")
const result = yield* ReadToolFileSystem.read(environment, absolute(file), "notes.docx")
const result = yield* ReadToolFileSystem.read(fs, file, "notes.docx")
expect(result).toMatchObject({ type: "file", content: "plain text", encoding: "utf8" })
}),
@@ -82,17 +83,15 @@ describe("ReadToolFileSystem", () => {
it.effect("lists unresolved symlinks, including broken and escaping links", () =>
Effect.gen(function* () {
if (process.platform === "win32") return
const { environment, files, directory } = yield* fixture
const { fs: service, files, directory } = yield* fixture
const outside = yield* files.makeTempDirectoryScoped()
yield* files.makeDirectory(path.join(directory, "folder"))
yield* files.writeFileString(path.join(directory, "file.txt"), "hello")
yield* Effect.promise(() => fs.symlink(path.join(outside, "target.txt"), path.join(directory, "escape")))
yield* Effect.promise(() => fs.symlink(path.join(directory, "missing.txt"), path.join(directory, "broken")))
const result = yield* ReadToolFileSystem.read(environment, absolute(directory), "folder")
const result = yield* ReadToolFileSystem.list(service, directory)
expect(result.type).toBe("list-page")
if (result.type !== "list-page") return
expect(result.entries.map((entry) => ({ ...entry, path: String(entry.path) }))).toEqual([
{ path: `folder${path.sep}`, type: "directory" },
{ path: "broken", type: "symlink" },
@@ -102,154 +101,45 @@ describe("ReadToolFileSystem", () => {
}),
)
it.effect("reads a symlinked directory as a listing", () =>
Effect.gen(function* () {
if (process.platform === "win32") return
const { environment, files, directory } = yield* fixture
const target = path.join(directory, "target")
const link = path.join(directory, "link")
yield* files.makeDirectory(target)
yield* files.writeFileString(path.join(target, "file.txt"), "hello")
yield* Effect.promise(() => fs.symlink(target, link))
const result = yield* ReadToolFileSystem.read(environment, absolute(link), "link")
expect(result).toMatchObject({
type: "list-page",
entries: [{ path: "file.txt", type: "file" }],
})
}),
)
it.effect("reports out-of-range pagination as a typed error", () =>
Effect.gen(function* () {
const { environment, files, directory } = yield* fixture
const { fs, files, directory } = yield* fixture
const file = path.join(directory, "short.txt")
yield* files.writeFileString(file, "one\n")
const error = yield* ReadToolFileSystem.read(environment, absolute(file), "short.txt", { offset: 2 }).pipe(
Effect.flip,
)
const error = yield* ReadToolFileSystem.read(fs, file, "short.txt", { offset: 2 }).pipe(Effect.flip)
expect(error).toBeInstanceOf(ReadToolFileSystem.OffsetOutOfRangeError)
expect(error.message).toBe("Offset 2 is out of range")
}),
)
it.effect("pages text with one-based offsets", () =>
it.effect("stops reading after the requested page is complete", () =>
Effect.gen(function* () {
const { environment, files, directory } = yield* fixture
const file = path.join(directory, "lines.txt")
yield* files.writeFileString(file, "one\r\ntwo\nthree")
const { fs, files, directory } = yield* fixture
const prefix = new TextEncoder().encode("one\n")
for (const [name, trailing] of [
["malformed.txt", 0x80],
["nul.txt", 0],
] as const) {
const file = path.join(directory, name)
yield* files.writeFile(file, Uint8Array.from([...prefix, trailing]))
const result = yield* ReadToolFileSystem.read(environment, absolute(file), "lines.txt", {
offset: 2,
limit: 1,
})
const result = yield* ReadToolFileSystem.read(fs, file, name, { limit: 1 })
expect(result).toMatchObject({ type: "text-page", content: "two", offset: 2, truncated: true, next: 3 })
}),
)
it.effect("truncates long lines", () =>
Effect.gen(function* () {
const { environment, files, directory } = yield* fixture
const file = path.join(directory, "long.txt")
yield* files.writeFileString(file, "a".repeat(2_001))
const result = yield* ReadToolFileSystem.read(environment, absolute(file), "long.txt", { limit: 1 })
expect(result).toMatchObject({
type: "text-page",
content: `${"a".repeat(2_000)}... (line truncated to 2000 chars)`,
truncated: false,
})
}),
)
it.effect("enforces line and byte budgets with continuation offsets", () =>
Effect.gen(function* () {
const { environment, files, directory } = yield* fixture
const linesFile = path.join(directory, "many-lines.txt")
const bytesFile = path.join(directory, "many-bytes.txt")
yield* files.writeFileString(linesFile, Array.from({ length: 2_001 }, (_, index) => String(index)).join("\n"))
yield* files.writeFileString(bytesFile, Array.from({ length: 200 }, () => "a".repeat(2_000)).join("\n"))
const ranges: Array<{ readonly offset: number; readonly length: number } | undefined> = []
const tracked = {
...environment,
read: (path: string, range?: { readonly offset: number; readonly length: number }) =>
Effect.sync(() => ranges.push(range)).pipe(Effect.andThen(environment.read(path, range))),
expect(result).toMatchObject({ type: "text-page", content: "one", truncated: true, next: 2 })
}
const lines = yield* ReadToolFileSystem.read(environment, absolute(linesFile), "many-lines.txt", { limit: 2_000 })
const bytes = yield* ReadToolFileSystem.read(tracked, absolute(bytesFile), "many-bytes.txt", {})
expect(lines).toMatchObject({ type: "text-page", truncated: true, next: 2_001 })
expect(lines.type === "text-page" ? lines.content.split("\n") : []).toHaveLength(2_000)
expect(bytes).toMatchObject({ type: "text-page", truncated: true, next: 26 })
expect(bytes.type === "text-page" ? Buffer.byteLength(bytes.content) : Infinity).toBeLessThanOrEqual(
ReadToolFileSystem.MAX_READ_BYTES,
)
expect(ranges).toEqual([{ offset: 0, length: 256 * 1024 }])
}),
)
it.effect("sorts and pages directory entries", () =>
Effect.gen(function* () {
const { environment, files, directory } = yield* fixture
yield* files.makeDirectory(path.join(directory, "z"))
yield* files.makeDirectory(path.join(directory, "a"))
yield* files.writeFileString(path.join(directory, "b.txt"), "")
const result = yield* ReadToolFileSystem.read(environment, absolute(directory), "folder", {
offset: 2,
limit: 1,
})
expect(result).toMatchObject({
type: "list-page",
entries: [{ path: `z${path.sep}`, type: "directory" }],
truncated: true,
next: 3,
})
}),
)
it.effect("stops checking for null bytes after the requested page", () =>
Effect.gen(function* () {
const { environment, files, directory } = yield* fixture
const file = path.join(directory, "nul.txt")
yield* files.writeFile(file, Uint8Array.from([...new TextEncoder().encode("one\n"), 0]))
const result = yield* ReadToolFileSystem.read(environment, absolute(file), "nul.txt", { limit: 1 })
expect(result).toMatchObject({ type: "text-page", content: "one", truncated: true, next: 2 })
}),
)
it.effect("reads page two after fetching more than the first 256KB range", () =>
Effect.gen(function* () {
const { environment, files, directory } = yield* fixture
const file = path.join(directory, "large.txt")
yield* files.writeFileString(file, `${"a".repeat(300 * 1024)}\nsecond\n`)
const result = yield* ReadToolFileSystem.read(environment, absolute(file), "large.txt", {
offset: 2,
limit: 1,
})
expect(result).toMatchObject({ type: "text-page", content: "second", offset: 2, truncated: false })
}),
)
it.effect("preserves the media ingestion limit message", () =>
Effect.gen(function* () {
const { environment, files, directory } = yield* fixture
const { fs, files, directory } = yield* fixture
const file = path.join(directory, "oversized.png")
yield* files.writeFile(file, Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a))
yield* files.truncate(file, ReadToolFileSystem.MAX_MEDIA_INGEST_BYTES + 1)
const error = yield* ReadToolFileSystem.read(environment, absolute(file), "oversized.png").pipe(Effect.flip)
const error = yield* ReadToolFileSystem.read(fs, file, "oversized.png").pipe(Effect.flip)
expect(error).toBeInstanceOf(ReadToolFileSystem.MediaIngestLimitError)
expect(error.message).toBe(
@@ -260,11 +150,11 @@ describe("ReadToolFileSystem", () => {
it.effect("reads PDFs as bounded media", () =>
Effect.gen(function* () {
const { environment, files, directory } = yield* fixture
const { fs, files, directory } = yield* fixture
const file = path.join(directory, "document.pdf")
yield* files.writeFileString(file, "%PDF-1.7\ncontent")
const result = yield* ReadToolFileSystem.read(environment, absolute(file), "document.pdf")
const result = yield* ReadToolFileSystem.read(fs, file, "document.pdf")
expect(result).toMatchObject({
type: "file",
+48 -23
View File
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect } from "bun:test"
import path from "path"
import { Effect, Exit, Layer, Stream } from "effect"
import { Effect, Exit, Layer, PlatformError, Stream } from "effect"
import { Config } from "@opencode-ai/core/config"
import { Document, Info } from "@opencode-ai/schema/config"
import { ConfigMedia } from "@opencode-ai/schema/config/media"
@@ -21,7 +21,6 @@ import { ReadTool } from "@opencode-ai/core/tool/plugin/read"
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { SessionInstructions } from "@opencode-ai/core/session/instructions"
import { Environment } from "@opencode-ai/core/environment"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
@@ -43,13 +42,24 @@ const readToolNode = makeLocationNode({
const assertions: Permission.AssertInput[] = []
const missingPath = "__missing_read_target__.txt"
const missingAbsolutePath = path.join(process.cwd(), missingPath)
const notFound = (target: string) =>
PlatformError.systemError({
_tag: "NotFound",
module: "FileSystem",
method: "stat",
pathOrDescriptor: target,
})
const readCalls: {
input: AbsolutePath
page: ReadToolFileSystem.PageInput
}[] = []
const listCalls: ReadToolFileSystem.PageInput[] = []
let listResult = new ReadToolFileSystem.ListPage({ type: "list-page", entries: [], truncated: false })
let resolvedType: "file" | "directory" = "file"
let resolveFailure: unknown
let inspectFailure: ReadToolFileSystem.InspectError | undefined
let directoryEntries: string[] = []
let readResult: ReadToolFileSystem.FileContent | ReadToolFileSystem.TextPage | ReadToolFileSystem.ListPage = {
let readResult: ReadToolFileSystem.FileContent | ReadToolFileSystem.TextPage = {
type: "file",
uri: "file:///README.md",
name: "README.md",
@@ -61,12 +71,22 @@ let readFailure: ReadToolFileSystem.ReadError | undefined
const reader = Layer.succeed(
ReadToolFileSystem.Service,
ReadToolFileSystem.Service.of({
inspect: () =>
resolveFailure !== undefined
? Effect.die(resolveFailure)
: inspectFailure !== undefined
? Effect.fail(inspectFailure)
: Effect.succeed(resolvedType),
read: (input, _resource, page = {}) => {
readCalls.push({ input, page })
if (resolveFailure !== undefined) return Effect.die(resolveFailure)
if (readFailure !== undefined) return Effect.fail(readFailure)
return Effect.succeed(readResult)
},
list: (_path, input = {}) =>
Effect.sync(() => {
listCalls.push(input)
return listResult
}),
}),
)
let allow = true
@@ -105,6 +125,17 @@ const testFileSystem = Layer.effect(
FSUtil.Service.of({
...fs,
readDirectory: () => Effect.succeed(directoryEntries),
realPath: (path) =>
path === missingAbsolutePath
? Effect.fail(
PlatformError.systemError({
_tag: "NotFound",
module: "FileSystem",
method: "realPath",
pathOrDescriptor: path,
}),
)
: Effect.succeed(path),
}),
),
),
@@ -164,8 +195,11 @@ describe("ReadTool", () => {
beforeEach(() => {
assertions.length = 0
readCalls.length = 0
listCalls.length = 0
allow = true
resolvedType = "file"
resolveFailure = undefined
inspectFailure = undefined
directoryEntries = []
readResult = {
type: "file",
@@ -176,6 +210,7 @@ describe("ReadTool", () => {
mime: "text/plain",
}
readFailure = undefined
listResult = new ReadToolFileSystem.ListPage({ type: "list-page", entries: [], truncated: false })
})
it.effect("registers, authorizes, and reads through the location filesystem", () =>
@@ -637,7 +672,7 @@ describe("ReadTool", () => {
it.effect("returns missing paths as model-visible tool failures", () =>
Effect.gen(function* () {
readFailure = new Environment.NotFound({ path: missingAbsolutePath })
inspectFailure = notFound(missingAbsolutePath)
directoryEntries = [
"__missing_read_target__.txt.bak",
"copy___missing_read_target__.txt",
@@ -661,18 +696,14 @@ describe("ReadTool", () => {
},
})
expect(assertions).toMatchObject([{ sessionID, action: "read", resources: [missingPath], save: ["*"] }])
expect(readCalls).toEqual([
{
input: AbsolutePath.make(missingAbsolutePath),
page: { offset: undefined, limit: undefined },
},
])
expect(readCalls).toEqual([])
}),
)
it.effect("lists a bounded directory page through read", () =>
Effect.gen(function* () {
readResult = new ReadToolFileSystem.ListPage({
resolvedType = "directory"
listResult = new ReadToolFileSystem.ListPage({
type: "list-page",
entries: [
FileSystem.Entry.make({ path: RelativePath.make("components/"), type: "directory" }),
@@ -695,7 +726,7 @@ describe("ReadTool", () => {
})
expect(result).toMatchObject({
status: "completed",
output: { entries: readResult.entries, truncated: true, next: 4 },
output: { entries: listResult.entries, truncated: true, next: 4 },
})
if (result.status !== "completed") return
expect(result.metadata).toEqual({ truncated: true })
@@ -706,15 +737,14 @@ describe("ReadTool", () => {
},
])
expect(assertions).toMatchObject([{ sessionID, action: "read", resources: ["src"], save: ["*"] }])
expect(readCalls).toEqual([
{ input: AbsolutePath.make(path.join(process.cwd(), "src")), page: { offset: 2, limit: 10 } },
])
expect(listCalls).toEqual([{ offset: 2, limit: 10 }])
}),
)
it.effect("does not list a directory when permission is denied", () =>
Effect.gen(function* () {
allow = false
resolvedType = "directory"
const registry = yield* Tool.Service
expect(
@@ -724,7 +754,7 @@ describe("ReadTool", () => {
call: { type: "tool-call", id: "call-read-directory-denied", name: "read", input: { path: "src" } },
}),
).toEqual({ status: "error", error: { type: "permission.rejected", message: "Permission denied: read" } })
expect(readCalls).toEqual([])
expect(listCalls).toEqual([])
}),
)
@@ -743,12 +773,7 @@ describe("ReadTool", () => {
),
).toBe(true)
expect(readCalls).toEqual([
{
input: AbsolutePath.make(path.join(process.cwd(), "missing.txt")),
page: { offset: undefined, limit: undefined },
},
])
expect(readCalls).toEqual([])
}),
)
+19 -6
View File
@@ -5,8 +5,8 @@ import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Environment } from "@opencode-ai/core/environment"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { Permission } from "@opencode-ai/core/permission"
@@ -24,12 +24,19 @@ import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
const globToolNode = makeLocationNode({
name: "test/glob-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(GlobTool.Plugin)),
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node],
deps: [
Tool.node,
FSUtil.node,
Ripgrep.node,
Location.node,
LocationMutation.node,
Permission.node,
],
})
const grepToolNode = makeLocationNode({
name: "test/grep-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(GrepTool.Plugin)),
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node],
deps: [Tool.node, FSUtil.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node],
})
const sessionID = Session.ID.make("ses_search_tool_test")
@@ -179,7 +186,9 @@ describe("search tools", () => {
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.promise(() => fs.writeFile(path.join(tmp.path, "file.txt"), "haystack\n")).pipe(
Effect.andThen(withTools(tmp.path, (registry) => executeTool(registry, call("grep", { pattern: "needle" })))),
Effect.andThen(
withTools(tmp.path, (registry) => executeTool(registry, call("grep", { pattern: "needle" }))),
),
Effect.tap((result) =>
Effect.sync(() => {
expect(result).toMatchObject({
@@ -288,7 +297,9 @@ describe("search tools", () => {
(tmp) =>
Effect.promise(() => fs.writeFile(path.join(tmp.path, "file.txt"), "content\n")).pipe(
Effect.andThen(
withTools(tmp.path, (registry) => executeTool(registry, call("glob", { path: "file.txt", pattern: "*" }))),
withTools(tmp.path, (registry) =>
executeTool(registry, call("glob", { path: "file.txt", pattern: "*" })),
),
),
Effect.tap((result) =>
Effect.sync(() => {
@@ -320,7 +331,9 @@ describe("search tools", () => {
Effect.sync(() => {
expect(result.status).toBe("completed")
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "glob"])
expect(assertions[0]?.resources).toEqual([path.join(outside.path, "*").replaceAll("\\", "/")])
expect(assertions[0]?.resources).toEqual([
path.join(outside.path, "*").replaceAll("\\", "/"),
])
}),
),
)
+1 -2
View File
@@ -524,8 +524,7 @@ describe("ShellTool", () => {
const content = settled.content?.[0]
if (!content || content.type !== "text") throw new Error("Expected text content")
expect(content.text).not.toContain("one")
// Windows shells emit CRLF; the assertion targets line limits, not line endings.
expect(content.text.replaceAll("\r\n", "\n")).toStartWith("two\nthree")
expect(content.text).toStartWith("two\nthree")
expect(content.text).toContain("output truncated; full output saved to:")
})
},
+11 -14
View File
@@ -6,7 +6,7 @@ import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Formatter } from "@opencode-ai/core/formatter"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Environment } from "@opencode-ai/core/environment"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { Permission } from "@opencode-ai/core/permission"
@@ -23,7 +23,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
const writeToolNode = makeLocationNode({
name: "test/write-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(WriteTool.Plugin)),
deps: [Tool.node, LocationMutation.node, FileMutation.node, Environment.node, Formatter.node, Permission.node],
deps: [Tool.node, LocationMutation.node, FileMutation.node, Formatter.node, FSUtil.node, Permission.node],
})
const sessionID = Session.ID.make("ses_write_tool_test")
@@ -68,20 +68,17 @@ const reset = () => {
denyAction = undefined
}
const environment = Layer.effect(
Environment.Service,
const filesystem = Layer.effect(
FSUtil.Service,
Effect.gen(function* () {
const current = yield* Environment.Service
return Environment.Service.of({
...current,
files: {
...current.files,
write: (target, content) =>
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(current.files.write(target, content))),
},
const fs = yield* FSUtil.Service
return FSUtil.Service.of({
...fs,
writeWithDirs: (target, content, mode) =>
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeWithDirs(target, content, mode))),
})
}),
).pipe(Layer.provide(LayerNode.compile(Environment.node)))
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) => Effect.Effect<A, E, R>) => {
const activeLocation = Layer.succeed(
@@ -95,7 +92,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
AppNodeBuilder.build(
LayerNode.group([Tool.node, Tool.node, LocationMutation.node, FileMutation.node, writeToolNode]),
[
[Environment.node, environment],
[FSUtil.node, filesystem],
[Location.node, activeLocation],
[Formatter.node, formatter],
[Permission.node, permission],
-39
View File
@@ -522,45 +522,6 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
}),
),
)
.add(
HttpApiEndpoint.delete("session.pending.cancel", "/api/session/:sessionID/pending/:inputID", {
params: { sessionID: Session.ID, inputID: SessionMessage.ID },
success: HttpApiSchema.NoContent,
error: [ConflictError, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.pending.cancel",
summary: "Cancel pending input",
description: "Cancel an input that has not yet been promoted into session history.",
}),
),
)
.add(
HttpApiEndpoint.post("session.pending.steer", "/api/session/:sessionID/pending/:inputID/steer", {
params: { sessionID: Session.ID, inputID: SessionMessage.ID },
success: HttpApiSchema.NoContent,
error: [ConflictError, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.pending.steer",
summary: "Steer queued input",
description: "Change a queued input to steer delivery and wake session execution.",
}),
),
)
.add(
HttpApiEndpoint.post("session.pending.queue", "/api/session/:sessionID/pending/:inputID/queue", {
params: { sessionID: Session.ID, inputID: SessionMessage.ID },
success: HttpApiSchema.NoContent,
error: [ConflictError, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.pending.queue",
summary: "Queue pending steer",
description: "Change a pending steer to queued delivery.",
}),
),
)
.add(
HttpApiEndpoint.get("session.instructions.entry.list", "/api/session/:sessionID/instructions/entries", {
params: { sessionID: Session.ID },
+7 -35
View File
@@ -152,15 +152,13 @@ export const Forked = Event.durable({
})
export type Forked = typeof Forked.Type
const InputRef = {
...Base,
inputID: SessionMessage.ID,
}
export const InputPromoted = Event.durable({
type: "session.input.promoted",
...options,
schema: InputRef,
schema: {
sessionID: SessionID,
inputID: SessionMessage.ID,
},
})
export type InputPromoted = typeof InputPromoted.Type
@@ -168,33 +166,13 @@ export const InputAdmitted = Event.durable({
type: "session.input.admitted",
...options,
schema: {
...InputRef,
...Base,
inputID: SessionMessage.ID,
input: SessionPending.Message,
},
})
export type InputAdmitted = typeof InputAdmitted.Type
export const InputCancelled = Event.durable({
type: "session.input.cancelled",
...options,
schema: InputRef,
})
export type InputCancelled = typeof InputCancelled.Type
export const InputSteered = Event.durable({
type: "session.input.steered",
...options,
schema: InputRef,
})
export type InputSteered = typeof InputSteered.Type
export const InputQueued = Event.durable({
type: "session.input.queued",
...options,
schema: InputRef,
})
export type InputQueued = typeof InputQueued.Type
export namespace Execution {
export const Started = Event.durable({ type: "session.execution.started", ...options, schema: Base })
export type Started = typeof Started.Type
@@ -602,9 +580,6 @@ export const Definitions = Event.inventory(
Forked,
InputPromoted,
InputAdmitted,
InputCancelled,
InputSteered,
InputQueued,
Execution.Started,
Execution.Succeeded,
Execution.Failed,
@@ -646,16 +621,13 @@ export const DurableDefinitions = Event.inventory(
...Definitions.filter((definition) => definition.durability === "durable"),
UsageRecorded,
)
export const EphemeralDefinitions = Event.inventory(
...Definitions.filter((definition) => definition.durability === "ephemeral"),
)
export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" })
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Session.Event.Durable" })
export type DurableEvent = typeof Durable.Type
export const All = Schema.Union([Durable, ...EphemeralDefinitions], { mode: "oneOf" }).pipe(
export const All = Schema.Union(Event.inventory(...Definitions, UsageRecorded), { mode: "oneOf" }).pipe(
Schema.toTaggedUnion("type"),
)
export type Event = typeof All.Type
@@ -84,9 +84,6 @@ describe("public event manifest", () => {
"session.forked.2",
"session.input.promoted.1",
"session.input.admitted.1",
"session.input.cancelled.1",
"session.input.steered.1",
"session.input.queued.1",
"session.execution.started.1",
"session.execution.succeeded.1",
"session.execution.failed.1",
-43
View File
@@ -26,22 +26,6 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
Effect.gen(function* () {
const session = yield* Session.Service
const transfer = yield* SessionTransfer.Service
const pendingMutation = (effect: ReturnType<typeof session.cancelPending>, conflict: string) =>
effect.pipe(
Effect.catchTag(
"Session.NotFoundError",
(error) =>
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
Effect.catchTag(
"Session.PendingInputConflictError",
(error) => new ConflictError({ resource: error.inputID, message: `${conflict}: ${error.inputID}` }),
),
Effect.as(HttpApiSchema.NoContent.make()),
)
return handlers
.handle(
@@ -677,33 +661,6 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
}
}),
)
.handle(
"session.pending.cancel",
Effect.fn(function* (ctx) {
return yield* pendingMutation(
session.cancelPending({ sessionID: ctx.params.sessionID, inputID: ctx.params.inputID }),
"Pending input can no longer be cancelled",
)
}),
)
.handle(
"session.pending.steer",
Effect.fn(function* (ctx) {
return yield* pendingMutation(
session.steerPending({ sessionID: ctx.params.sessionID, inputID: ctx.params.inputID }),
"Pending input is no longer queued",
)
}),
)
.handle(
"session.pending.queue",
Effect.fn(function* (ctx) {
return yield* pendingMutation(
session.queuePending({ sessionID: ctx.params.sessionID, inputID: ctx.params.inputID }),
"Pending input is no longer a steer",
)
}),
)
.handle(
"session.instructions.entry.list",
Effect.fn(function* (ctx) {
@@ -462,7 +462,6 @@ function newLayout() {
function webSearchProviderLabel(provider: unknown) {
if (provider === "parallel") return "Parallel Web Search"
if (provider === "exa") return "Exa Web Search"
if (provider === "firecrawl") return "Firecrawl Web Search"
return "Web Search"
}
+7 -49
View File
@@ -53,14 +53,12 @@ import { useLocation } from "../../context/location"
import { Keymap, type KeymapCommand } from "../../context/keymap"
import { abbreviateHome } from "../../runtime"
import { PluginSlot } from "../../plugin/render"
import type { SessionPending } from "@opencode-ai/schema/session-pending"
export type PromptProps = {
sessionID?: string
visible?: boolean
disabled?: boolean
onSubmit?: () => void
onEmptySubmit?: () => boolean | Promise<boolean>
ref?: (ref: PromptRef | undefined) => void
hint?: JSX.Element
right?: JSX.Element
@@ -359,20 +357,6 @@ export function Prompt(props: PromptProps) {
dialog.clear()
},
},
{
title: "Queue prompt",
name: "prompt.queue",
category: "Prompt",
palette: undefined,
run: async (_input: string | undefined, event?: KeyEvent) => {
event?.preventDefault()
event?.stopPropagation()
if (!input.focused) return
const handled = await submit("queue")
if (!handled) return
dialog.clear()
},
},
{
title: "Remove editor context",
name: "prompt.editor_context.clear",
@@ -531,11 +515,6 @@ export function Prompt(props: PromptProps) {
commands: promptCommands(),
}))
Keymap.createLayer(() => ({
priority: 1,
bindings: ["prompt.queue"],
}))
Keymap.createLayer(() => ({
bindings: [
"prompt.submit",
@@ -921,7 +900,7 @@ export function Prompt(props: PromptProps) {
})
let submitting = false
async function submit(delivery: SessionPending.Delivery = "steer") {
async function submit() {
// Prevent overlapping invocations (e.g. a double-pressed Enter, or the
// input's native onSubmit racing another dispatch). Without this guard,
// a second call slips past the empty-input check before the first call
@@ -931,13 +910,13 @@ export function Prompt(props: PromptProps) {
if (submitting) return false
submitting = true
try {
return await submitInner(delivery)
return await submitInner()
} finally {
submitting = false
}
}
async function submitInner(delivery: SessionPending.Delivery) {
async function submitInner() {
// IME: double-defer may fire before onContentChange flushes the last
// composed character (e.g. Korean hangul) to the store, so read
// plainText directly and sync before any downstream reads.
@@ -948,25 +927,14 @@ export function Prompt(props: PromptProps) {
if (props.disabled) return false
if (move.creating()) return false
if (auto()?.visible) return false
if (!store.prompt.text) return false
const trimmed = store.prompt.text.trim()
if (!trimmed) return delivery === "steer" ? (await props.onEmptySubmit?.()) === true : false
if (
delivery === "queue" &&
(store.mode === "shell" || trimmed === "exit" || trimmed === "quit" || trimmed === ":q")
) {
toast.show({ message: "This prompt cannot be queued", variant: "warning" })
return false
}
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
void exit()
return true
}
const slash = argumentSlash(store.prompt.text, keymapCommands())
if (slash) {
if (delivery === "queue") {
toast.show({ message: "This prompt cannot be queued", variant: "warning" })
return false
}
clearPrompt()
await slash.command.run(slash.input)
return true
@@ -990,16 +958,6 @@ export function Prompt(props: PromptProps) {
const isCommand =
slashHead !== undefined &&
(data.location.command.list(currentLocation.ref) ?? []).some((command) => command.name === slashHead.name)
if (delivery === "queue" && isSkill) {
toast.show({ message: "Skills cannot be queued", variant: "warning" })
return false
}
const editorSelection = editorContext()
const pendingEditorSelection = editorSelection && editor.labelState() === "pending" ? editorSelection : undefined
if (delivery === "queue" && pendingEditorSelection) {
toast.show({ message: "Editor context cannot be queued", variant: "warning" })
return false
}
const agent = local.agent.current()
if (!agent) return false
const selection = local.model.selection()
@@ -1058,6 +1016,8 @@ export function Prompt(props: PromptProps) {
// Capture mode before it gets reset
const currentMode = store.mode
const editorSelection = editorContext()
const pendingEditorSelection = editorSelection && editor.labelState() === "pending" ? editorSelection : undefined
if (store.mode === "shell") {
move.startSubmit()
@@ -1080,7 +1040,6 @@ export function Prompt(props: PromptProps) {
model,
files: store.prompt.files,
agents: store.prompt.agents,
delivery,
})
.catch((error) => {
cancelCommit()
@@ -1090,7 +1049,7 @@ export function Prompt(props: PromptProps) {
move.startSubmit()
void client.api.session.skill({
sessionID,
skill: slashHead.name,
skill: slashHead!.name,
})
} else {
move.startSubmit()
@@ -1146,7 +1105,6 @@ export function Prompt(props: PromptProps) {
text: inputText,
files: store.prompt.files,
agents: store.prompt.agents,
delivery,
})
.then(
() => undefined,
+2 -6
View File
@@ -103,8 +103,7 @@ export const Definitions = {
session_interrupt: keybind("escape", "Interrupt current session"),
session_background: keybind("ctrl+b", "Background blocking session tools"),
session_compact: keybind("<leader>c", "Compact the session"),
session_queued_prompts: keybind("<leader>q", "View queued prompts"),
queued_prompt_delete: keybind("ctrl+d", "Delete queued prompt"),
session_queued_prompts: keybind("<leader>q", "View pending work"),
session_child_first: keybind("down", "Toggle subagent picker"),
session_parent: keybind("up", "Go to parent session"),
session_pin_toggle: keybind("ctrl+f", "Pin or unpin session in the session list"),
@@ -162,7 +161,6 @@ export const Definitions = {
display_thinking: keybind("none", "Toggle thinking blocks visibility"),
prompt_submit: keybind("none", "Submit prompt"),
prompt_queue: keybind("alt+return", "Queue prompt"),
prompt_editor_context_clear: keybind("none", "Clear editor context"),
prompt_skills: keybind("none", "Open skill selector"),
prompt_stash: keybind("none", "Stash prompt"),
@@ -172,7 +170,7 @@ export const Definitions = {
input_clear: keybind("ctrl+c", "Clear input field"),
input_paste: keybind({ key: "ctrl+v", preventDefault: false }, "Paste from clipboard"),
input_submit: keybind("return", "Submit input"),
input_newline: keybind("shift+return,ctrl+return,ctrl+j", "Insert newline in input"),
input_newline: keybind("shift+return,ctrl+return,alt+return,ctrl+j", "Insert newline in input"),
input_move_left: keybind("left,ctrl+b", "Move cursor left in input"),
input_move_right: keybind("right,ctrl+f", "Move cursor right in input"),
input_move_up: keybind("up", "Move cursor up in input"),
@@ -307,7 +305,6 @@ export const CommandMap = {
session_background: "session.background",
session_compact: "session.compact",
session_queued_prompts: "session.queued_prompts",
queued_prompt_delete: "queued_prompt.delete",
session_child_first: "session.child.first",
session_parent: "session.parent",
session_pin_toggle: "session.pin.toggle",
@@ -362,7 +359,6 @@ export const CommandMap = {
messages_redo: "session.redo",
display_thinking: "session.toggle.thinking",
prompt_submit: "prompt.submit",
prompt_queue: "prompt.queue",
prompt_editor_context_clear: "prompt.editor_context.clear",
prompt_skills: "prompt.skills",
prompt_stash: "prompt.stash",
+15 -49
View File
@@ -38,7 +38,6 @@ import { createStore, produce, reconcile } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useClient } from "./client"
import { nonEmptyToolContent } from "../util/tool-display"
import type { SessionPending } from "@opencode-ai/schema/session-pending"
import { createEffect, createSignal, onCleanup } from "solid-js"
export type DataSessionStatus = "idle" | "running"
@@ -171,20 +170,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
function removePending(sessionID: string, inputID?: string) {
if (!inputID) return
if (store.session.pending[sessionID]?.some((item) => item.id === inputID))
setStore(
"session",
"pending",
sessionID,
(store.session.pending[sessionID] ?? []).filter((item) => item.id !== inputID),
)
if (store.session.input[sessionID]?.includes(inputID))
setStore(
"session",
"input",
sessionID,
(store.session.input[sessionID] ?? []).filter((id) => id !== inputID),
)
setStore(
"session",
"pending",
sessionID,
(store.session.pending[sessionID] ?? []).filter((item) => item.id !== inputID),
)
}
function removePermission(sessionID: string, requestID: string) {
@@ -198,13 +189,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
)
}
function updatePending(sessionID: string, inputID: string, delivery: SessionPending.Delivery) {
const index = store.session.pending[sessionID]?.findIndex((item) => item.id === inputID) ?? -1
const item = store.session.pending[sessionID]?.[index]
if (index < 0 || !item || item.type === "compaction" || item.delivery === delivery) return
setStore("session", "pending", sessionID, index, { ...item, delivery })
}
const message = {
update(sessionID: string, fn: (messages: SessionMessageInfo[], index: Map<string, number>) => void) {
setStore(
@@ -251,12 +235,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && !item.time?.completed,
)
},
reindex(messages: SessionMessageInfo[], index: Map<string, number>, start: number) {
for (let position = start; position < messages.length; position++) {
const item = messages[position]
if (item) index.set(item.id, position)
}
},
}
function index(sessionID: string) {
@@ -438,36 +416,24 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
}
break
case "session.input.promoted": {
const admitted = store.session.input[event.data.sessionID]?.includes(event.data.inputID) ?? false
removePending(event.data.sessionID, event.data.inputID)
message.update(event.data.sessionID, (draft, index) => {
const position = index.get(event.data.inputID)
if (position === undefined) return
const existing = draft[position]
if (!existing || !admitted) return
if (!existing || !store.session.input[event.data.sessionID]?.includes(event.data.inputID)) return
existing.time.created = event.created
draft.splice(position, 1)
draft.push(existing)
message.reindex(draft, index, position)
index.clear()
draft.forEach((message, indexValue) => index.set(message.id, indexValue))
})
break
}
case "session.input.steered":
updatePending(event.data.sessionID, event.data.inputID, "steer")
break
case "session.input.queued":
updatePending(event.data.sessionID, event.data.inputID, "queue")
break
case "session.input.cancelled": {
removePending(event.data.sessionID, event.data.inputID)
if (messageIndex.get(event.data.sessionID)?.has(event.data.inputID))
message.update(event.data.sessionID, (draft, index) => {
const position = index.get(event.data.inputID)
if (position === undefined) return
draft.splice(position, 1)
index.delete(event.data.inputID)
message.reindex(draft, index, position)
})
setStore(
"session",
"input",
event.data.sessionID,
(store.session.input[event.data.sessionID] ?? []).filter((id) => id !== event.data.inputID),
)
break
}
case "session.input.admitted":
+2 -20
View File
@@ -27,7 +27,6 @@ export interface Storage {
* JSON-serializable.
*/
memory<Value extends object>(key: string, options: { readonly initial: Value }): MemoryEntry<Value>
flush(): Promise<void>
}
function clone<Value extends object>(value: Value) {
@@ -47,7 +46,6 @@ function segment(value: string) {
function createStorage(root: string, channel: string) {
const entries = new Map<string, { readonly value: Entry<object>; readonly reload: () => void }>()
const memories = new Map<string, MemoryEntry<object>>()
const pending = new Set<Promise<void>>()
const directory = path.join(root, segment(channel), "tui")
const locks = path.join(root, segment(channel), "locks")
mkdirSync(directory, { recursive: true })
@@ -68,8 +66,8 @@ function createStorage(root: string, channel: string) {
const [store, setStore] = createStore(load())
const merge = (next: Value) => reconcile(next, { key: options.key })
const reload = () => batch(() => setStore(merge(load())))
const update = (mutation: (draft: Value) => void) => {
const operation = Flock.withLock(
const update = (mutation: (draft: Value) => void) =>
Flock.withLock(
file,
async () => {
const draft = load()
@@ -80,13 +78,6 @@ function createStorage(root: string, channel: string) {
},
{ dir: locks },
)
pending.add(operation)
operation.then(
() => pending.delete(operation),
() => pending.delete(operation),
)
return operation
}
const entry = [store, update] as const
entries.set(file, { value: entry as Entry<object>, reload })
return entry
@@ -99,15 +90,6 @@ function createStorage(root: string, channel: string) {
memories.set(key, entry as MemoryEntry<object>)
return entry
},
async flush() {
const failures: unknown[] = []
while (pending.size > 0) {
const results = await Promise.allSettled(pending)
failures.push(...results.filter((result) => result.status === "rejected").map((result) => result.reason))
}
if (failures.length === 1) throw failures[0]
if (failures.length > 1) throw new AggregateError(failures, "Storage writes failed")
},
}
const watcher = watch(directory, () => entries.forEach((entry) => entry.reload()))
+6 -33
View File
@@ -3,9 +3,7 @@ import { TextAttributes, type InputRenderable, type KeyEvent } from "@opentui/co
import { useKeyboard, type JSX } from "@opentui/solid"
import fuzzysort from "fuzzysort"
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
import { Keymap } from "../context/keymap"
import { RunFooterMenu, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
import { monoShortcut } from "./mono"
import type { RunFooterTheme } from "./theme"
import type {
FooterQueuedPrompt,
@@ -58,10 +56,6 @@ type SkillEntry = PanelEntry & {
name: string
}
type QueuedPromptEntry = PanelEntry & {
prompt: FooterQueuedPrompt
}
type SubagentEntry = PanelEntry & {
sessionID: string
current: boolean
@@ -448,7 +442,7 @@ export function RunCommandMenuBody(props: {
{
action: "queued" as const,
category: "Agent",
display: "View queued prompts",
display: "View pending work",
footer: `${props.queued().length} pending`,
keywords: props
.queued()
@@ -843,48 +837,28 @@ export function RunQueuedPromptSelectBody(props: {
theme: Accessor<RunFooterTheme>
prompts: Accessor<FooterQueuedPrompt[]>
onClose: () => void
onSteer: (prompt: FooterQueuedPrompt) => void
onDelete: (prompt: FooterQueuedPrompt) => void
onRows?: (rows: number) => void
mono?: boolean
}) {
const entries = createMemo<QueuedPromptEntry[]>(() =>
const entries = createMemo(() =>
props.prompts().map((prompt) => ({
category: "",
display: prompt.prompt.text.replaceAll("\n", " "),
footer: "queued",
footer: prompt.delivery,
keywords: prompt.prompt.text,
prompt,
})),
)
const controller = createSearchablePanelController({
entries,
limit: SUBAGENT_LIST_ROWS,
onClose: props.onClose,
onSelect: (item) => props.onSteer(item.prompt),
onSelect: props.onClose,
onRows: props.onRows,
})
const shortcuts = Keymap.useShortcuts()
const deleteShortcut = () => monoShortcut(shortcuts.get("queued_prompt.delete") ?? "", props.mono ?? false)
Keymap.createLayer(() => ({
priority: 1,
commands: [
{
id: "queued_prompt.delete",
title: "Delete queued prompt",
group: "Prompt",
run() {
const item = controller.items()[controller.menu.selected()]
if (!item) return false
props.onDelete(item.prompt)
},
},
],
}))
return (
<PanelShell
title="Queued prompts"
title="Pending work"
query={controller.query()}
count={controller.items().length}
total={entries().length}
@@ -892,7 +866,6 @@ export function RunQueuedPromptSelectBody(props: {
theme={props.theme}
inputRef={controller.inputRef}
onQuery={controller.setQuery}
hint={["enter steer", deleteShortcut() ? `${deleteShortcut()} delete` : undefined].filter(Boolean).join(" · ")}
mono={props.mono}
>
<RunFooterMenu
@@ -902,7 +875,7 @@ export function RunQueuedPromptSelectBody(props: {
offset={controller.menu.offset}
rows={controller.menu.rows}
limit={SUBAGENT_LIST_ROWS}
empty="No queued prompts"
empty="No pending work"
border={false}
paddingLeft={panelPad(props.mono)}
paddingRight={panelPad(props.mono)}
+13 -62
View File
@@ -19,7 +19,6 @@ import {
displayCharAt,
displaySlice,
isExitCommand,
isCompactCommand,
mentionTriggerIndex,
isNewCommand,
movePromptHistory,
@@ -32,16 +31,7 @@ import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.edit
import { monoTruncateMiddle } from "./mono"
import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
import type { RunFooterTheme } from "./theme"
import type {
FooterQueuedPrompt,
FooterState,
RunAgent,
RunCommand,
RunDelivery,
RunPrompt,
RunPromptPart,
RunReference,
} from "./types"
import type { FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunReference } from "./types"
const AUTOCOMPLETE_ROWS = FOOTER_MENU_ROWS
const AUTOCOMPLETE_BOTTOM_ROWS = 1
@@ -82,8 +72,6 @@ type PromptInput = {
theme: Accessor<RunFooterTheme>
mono: Accessor<boolean>
history?: Accessor<RunPrompt[]>
queuedPrompts: Accessor<FooterQueuedPrompt[]>
onQueuedPromptSteer: (inputID: string) => Promise<boolean>
onSubmit: (input: RunPrompt) => boolean | Promise<boolean>
onCycle: () => void
onInterrupt: () => boolean
@@ -992,18 +980,8 @@ export function createPromptState(input: PromptInput): PromptState {
}))
Keymap.createLayer(() => ({
priority: 1,
enabled: input.prompt() && !visible(),
commands: [
{
id: "prompt.queue",
title: "Queue prompt",
group: "Prompt",
run() {
syncDraft()
submitPrompt(promptCopy(draft), "queue")
},
},
{
id: "prompt.editor",
title: "Open editor",
@@ -1138,8 +1116,7 @@ export function createPromptState(input: PromptInput): PromptState {
}
}
let submitting = false
const submitPrompt = (next: RunPrompt, delivery: RunDelivery = "steer") => {
const submitPrompt = (next: RunPrompt) => {
if (!area || area.isDestroyed) {
draft = promptCopy(next)
}
@@ -1153,34 +1130,12 @@ export function createPromptState(input: PromptInput): PromptState {
hide()
}
if (submitting) return
if (!next.text.trim()) {
const queued = delivery === "steer" ? input.queuedPrompts()[0] : undefined
if (queued) {
submitting = true
void input.onQueuedPromptSteer(queued.messageID).finally(() => {
submitting = false
})
return
}
input.onStatus(input.state().phase === "running" ? "waiting for current response" : "empty prompt ignored")
return
}
const command = next.mode === "shell" ? undefined : selectedCommand(next.text, next.command)
if (
delivery === "queue" &&
(next.mode === "shell" ||
command?.source === "skill" ||
isNewCommand(next.text) ||
isCompactCommand(next.text) ||
isExitCommand(next.text) ||
next.text.trim().toLowerCase() === "/settings")
) {
input.onStatus("this prompt cannot be queued")
return
}
if (!command && next.mode !== "shell" && isExitCommand(next.text)) {
input.onExit()
return
@@ -1202,28 +1157,24 @@ export function createPromptState(input: PromptInput): PromptState {
}
const submit = command
? { ...next, command, delivery }
? { ...next, command }
: parsed?.type === "command"
? { ...next, command: parsed.command, delivery }
: { ...next, delivery }
? { ...next, command: parsed.command }
: next
const shellMode = next.mode === "shell"
submitting = true
resetDraft()
queueMicrotask(async () => {
try {
if (await input.onSubmit(submit)) {
push(next)
if (shellMode) {
setShellMode(false)
draft = emptyPrompt(false)
}
return
if (await input.onSubmit(submit)) {
push(next)
if (shellMode) {
setShellMode(false)
draft = emptyPrompt(false)
}
restore(next)
} finally {
submitting = false
return
}
restore(next)
})
}
-3
View File
@@ -51,7 +51,6 @@ import type {
MiniSettingChange,
MiniSettings,
PermissionReply,
QueuedPromptAction,
RunAgent,
RunCommand,
RunInput,
@@ -97,7 +96,6 @@ type RunFooterOptions = {
onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
onInterrupt?: () => void
onBackground?: () => void
onQueuedPromptAction?: (action: QueuedPromptAction, inputID: string) => Promise<void>
onEditorOpen: (input: { value: string }) => Promise<string | undefined>
onSubagentSelect?: (sessionID: string | undefined) => void
onSubagentInterrupt?: (sessionID: string) => void
@@ -345,7 +343,6 @@ export class RunFooter implements FooterApi {
onCycle: footer.handleCycle,
onInterrupt: footer.handleInterrupt,
onBackground: options.onBackground,
onQueuedPromptAction: options.onQueuedPromptAction,
onEditorOpen: options.onEditorOpen,
onInputClear: footer.handleInputClear,
onExitRequest: footer.handleExit,
+11 -41
View File
@@ -34,8 +34,6 @@ import { Keymap } from "../context/keymap"
import { modelInfo } from "./variant.shared"
import { monoShortcut } from "./mono"
import { stringWidth } from "../util/string-width"
import { errorMessage } from "../util/error"
import { createSingleFlight } from "../util/single-flight"
import type {
FooterPromptRoute,
@@ -48,7 +46,6 @@ import type {
MiniSettingChange,
MiniSettings,
PermissionReply,
QueuedPromptAction,
RunAgent,
RunCommand,
RunInput,
@@ -95,14 +92,13 @@ type RunFooterViewProps = {
mono: boolean
miniSettings: () => MiniSettings
history?: () => RunPrompt[]
onSubmit: (input: RunPrompt) => boolean | Promise<boolean>
onSubmit: (input: RunPrompt) => boolean
onPermissionReply: (input: PermissionReply) => void | Promise<void>
onFormReply: (input: FormReply) => void | Promise<void>
onFormCancel: (input: FormCancel) => void | Promise<void>
onCycle: () => void
onInterrupt: () => boolean
onBackground?: () => void
onQueuedPromptAction?: (action: QueuedPromptAction, inputID: string) => Promise<void>
onEditorOpen: (input: { value: string }) => Promise<string | undefined>
onInputClear: () => void
onExitRequest?: () => boolean
@@ -136,7 +132,6 @@ export function RunFooterView(props: RunFooterViewProps) {
const [route, setRoute] = createSignal<FooterPromptRoute>({ type: "composer" })
const [subagentMenuRows, setSubagentMenuRows] = createSignal(RUN_SUBAGENT_PANEL_ROWS)
const queuedPrompts = createMemo(() => props.queuedPrompts?.() ?? [])
const queue = createMemo(() => queuedPrompts().filter((item) => item.delivery === "queue"))
const skills = createMemo(() => (props.commands() ?? []).filter((item) => item.source === "skill"))
const prompt = createMemo(() => active().type === "prompt" && route().type === "composer")
const selectingSubagent = createMemo(() => active().type === "prompt" && route().type === "subagent-menu")
@@ -234,7 +229,7 @@ export function RunFooterView(props: RunFooterViewProps) {
const details = [busy() ? "running" : "idle", `agent ${props.currentAgent()}`]
if (current) details.push(variant ? `${current} ${variant}` : current)
if (usage()) details.push(props.mono ? usage().replaceAll(" · ", " - ") : usage())
if (queue().length > 0) details.push(`${queue().length} queued`)
if (queuedPrompts().length > 0) details.push(`${queuedPrompts().length} pending`)
if (activeTabs().length > 0) details.push(`${activeTabs().length} subagent${activeTabs().length === 1 ? "" : "s"}`)
return details.join(props.mono ? " - " : " · ")
})
@@ -314,7 +309,7 @@ export function RunFooterView(props: RunFooterViewProps) {
}
const openQueuedMenu = () => {
if (queue().length === 0) return
if (queuedPrompts().length === 0) return
setRoute({ type: "queued-menu" })
props.onSubagentSelect?.(undefined)
}
@@ -323,22 +318,6 @@ export function RunFooterView(props: RunFooterViewProps) {
setRoute({ type: "composer" })
}
const runQueuedAction = createSingleFlight<string>()
const queuedPromptAction = async (action: QueuedPromptAction, inputID: string) => {
const run = props.onQueuedPromptAction
if (!run) return false
const result = await runQueuedAction(inputID, async () => {
const error = await run(action, inputID).then(
() => undefined,
(error) => error,
)
if (!error) return true
props.onStatus(`failed to ${action === "cancel" ? "delete" : action} queued prompt: ${errorMessage(error)}`)
return false
})
return result ?? false
}
const openTab = (sessionID: string) => {
setRoute({ type: "subagent", sessionID })
props.onSubagentSelect?.(sessionID)
@@ -378,8 +357,6 @@ export function RunFooterView(props: RunFooterViewProps) {
theme,
mono: () => props.mono,
history: props.history,
queuedPrompts: queue,
onQueuedPromptSteer: (inputID) => queuedPromptAction("steer", inputID),
onSubmit: props.onSubmit,
onCycle: props.onCycle,
onInterrupt: props.onInterrupt,
@@ -474,12 +451,13 @@ export function RunFooterView(props: RunFooterViewProps) {
if (foregroundSubagents() && backgroundShortcut()) {
items.push({ key: backgroundShortcut(), label: "background" })
}
if (queue().length > 0 && queuedShortcut()) {
items.push({ key: queuedShortcut(), label: `${queue().length} queued` })
if (queuedPrompts().length > 0 && queuedShortcut()) {
items.push({ key: queuedShortcut(), label: `${queuedPrompts().length} pending` })
}
if (activeTabs().length > 0 && subagentShortcut()) {
items.push({ key: subagentShortcut(), label: "subagents" })
}
return items
})
const commandHint = createMemo(() => {
@@ -590,11 +568,11 @@ export function RunFooterView(props: RunFooterViewProps) {
}))
Keymap.createLayer(() => ({
enabled: active().type === "prompt" && route().type === "composer" && queue().length > 0,
enabled: active().type === "prompt" && route().type === "composer" && queuedPrompts().length > 0,
commands: [
{
id: "session.queued_prompts",
title: "View queued prompts",
title: "View pending work",
group: "Session",
run: openQueuedMenu,
},
@@ -652,7 +630,7 @@ export function RunFooterView(props: RunFooterViewProps) {
})
createEffect(() => {
if (route().type !== "queued-menu" || queue().length > 0) return
if (route().type !== "queued-menu" || queuedPrompts().length > 0) return
closePanel()
})
@@ -756,16 +734,8 @@ export function RunFooterView(props: RunFooterViewProps) {
<Match when={selectingQueued()}>
<RunQueuedPromptSelectBody
theme={theme}
prompts={queue}
prompts={queuedPrompts}
onClose={closePanel}
onSteer={(item) => {
void queuedPromptAction("steer", item.messageID).then((steered) => {
if (steered) closePanel()
})
}}
onDelete={(item) => {
void queuedPromptAction("cancel", item.messageID)
}}
onRows={setSubagentMenuRows}
mono={props.mono}
/>
@@ -775,7 +745,7 @@ export function RunFooterView(props: RunFooterViewProps) {
theme={theme}
commands={props.commands}
subagents={tabs}
queued={queue}
queued={queuedPrompts}
variants={props.variants}
variantCycle={variantCycle()}
onClose={closePanel}
@@ -22,7 +22,6 @@ import type {
MiniSettings,
MiniHost,
PermissionReply,
QueuedPromptAction,
RunAgent,
RunInput,
RunPrompt,
@@ -71,7 +70,6 @@ export type LifecycleInput = {
onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
onInterrupt?: () => void
onBackground?: () => void
onQueuedPromptAction?: (action: QueuedPromptAction, inputID: string) => Promise<void>
onSubagentSelect?: (sessionID: string | undefined) => void
onSubagentInterrupt?: (sessionID: string) => void
}
@@ -245,7 +243,6 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
onVariantSelect: input.onVariantSelect,
onInterrupt: input.onInterrupt,
onBackground: input.onBackground,
onQueuedPromptAction: input.onQueuedPromptAction,
onEditorOpen: async ({ value }) => {
if (closed || renderer.isDestroyed) {
return
+6 -7
View File
@@ -11,7 +11,7 @@
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Locale } from "../util/locale"
import { isCompactCommand, isExitCommand, isNewCommand } from "./prompt.shared"
import type { FooterApi, FooterEvent, RunDelivery, RunPrompt } from "./types"
import type { FooterApi, FooterEvent, RunPrompt } from "./types"
type Trace = {
write(type: string, data?: unknown): void
@@ -21,11 +21,11 @@ export type QueueInput = {
footer: FooterApi
initialInput?: string
trace?: Trace
onSend?: (prompt: RunPrompt, delivery: RunDelivery) => void
onSend?: (prompt: RunPrompt, delivery: "steer" | "queue") => void
onAdmissionError?: (prompt: RunPrompt, error: unknown) => void | Promise<void>
onNewSession?: () => void | Promise<void>
onCompact?: () => void | Promise<void>
admit: (prompt: RunPrompt, delivery: RunDelivery, signal: AbortSignal) => Promise<void>
admit: (prompt: RunPrompt, signal: AbortSignal) => Promise<void>
settle: () => Promise<void>
run: (prompt: RunPrompt, signal: AbortSignal, admitted: () => void) => Promise<void>
}
@@ -183,7 +183,7 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
input.trace?.write("ui.commit", commit)
input.footer.append(commit)
}
input.onSend?.(sent, sent.delivery ?? "steer")
input.onSend?.(sent, "steer")
if (state.closed) {
break
@@ -276,11 +276,10 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
const sent = { ...prompt, messageID: SessionMessage.ID.create() }
const admission = state.admission
admissionVersion += 1
const delivery = prompt.delivery ?? "queue"
input.onSend?.(sent, delivery)
input.onSend?.(sent, "queue")
admissions = admissions
.then(() => admission)
.then(() => input.admit(sent, delivery, admissionController.signal))
.then(() => input.admit(sent, admissionController.signal))
.catch((error) => (state.closed ? undefined : input.onAdmissionError?.(sent, error)))
return
}

Some files were not shown because too many files have changed in this diff Show More