mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-27 12:06:22 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
917dcd7004 |
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
"@opencode-ai/server": patch
|
||||
---
|
||||
|
||||
Keep the live models.dev catalog independent of persistence so failed cache reads or writes cannot prevent model updates. Cache downloaded catalogs in local files on Bun and Node, and use the bundled snapshot plus in-memory refreshes on workerd instead of storing the catalog in each Durable Object's database. Explicit catalog files refresh locally without fetching or writing an implicit cache.
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Make the experimental portable shell scanner authoritative, with no Tree-sitter
|
||||
fallback. Scan common Bash and PowerShell control flow, heredocs, functions,
|
||||
expressions, quoting, and substitutions natively. Preserve existing redirect and
|
||||
declaration permission matching, and make PowerShell saved approvals cover the
|
||||
original command spelling. Parser failures remain visible without changing the
|
||||
permission engine. The default Tree-sitter path is unchanged.
|
||||
@@ -125,7 +125,7 @@
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/pty": "0.1.11",
|
||||
"@opencode-ai/pty": "0.1.10",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
"@opencode-ai/tui": "workspace:*",
|
||||
@@ -364,7 +364,7 @@
|
||||
"@opencode-ai/ai": "workspace:*",
|
||||
"@opencode-ai/codemode": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/pty": "0.1.11",
|
||||
"@opencode-ai/pty": "0.1.10",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
@@ -553,20 +553,6 @@
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/latex": {
|
||||
"name": "@opencode-ai/latex",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
"string-width": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/merman": {
|
||||
"name": "@opencode-ai/merman",
|
||||
"version": "0.0.0",
|
||||
@@ -745,7 +731,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@happy-dom/global-registrator": "20.0.11",
|
||||
"@playwright/test": "catalog:",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/luxon": "catalog:",
|
||||
@@ -851,7 +836,6 @@
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/session-ui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@playwright/test": "catalog:",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@storybook/addon-a11y": "10.4.4",
|
||||
"@storybook/addon-docs": "10.4.4",
|
||||
@@ -893,7 +877,6 @@
|
||||
"dependencies": {
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/latex": "workspace:*",
|
||||
"@opencode-ai/merman": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
@@ -2165,8 +2148,6 @@
|
||||
|
||||
"@opencode-ai/httpapi-codegen": ["@opencode-ai/httpapi-codegen@workspace:packages/httpapi-codegen"],
|
||||
|
||||
"@opencode-ai/latex": ["@opencode-ai/latex@workspace:packages/latex"],
|
||||
|
||||
"@opencode-ai/merman": ["@opencode-ai/merman@workspace:packages/merman"],
|
||||
|
||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
|
||||
@@ -2175,19 +2156,19 @@
|
||||
|
||||
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
|
||||
|
||||
"@opencode-ai/pty": ["@opencode-ai/pty@0.1.11", "", { "optionalDependencies": { "@opencode-ai/pty-darwin-arm64": "0.1.11", "@opencode-ai/pty-darwin-x64": "0.1.11", "@opencode-ai/pty-linux-arm64-gnu": "0.1.11", "@opencode-ai/pty-linux-arm64-musl": "0.1.11", "@opencode-ai/pty-linux-x64-gnu": "0.1.11", "@opencode-ai/pty-linux-x64-musl": "0.1.11" }, "bin": { "opencode-pty": "bin/opencode-pty.js" } }, "sha512-Q4p0XXZWbc8FnpEJaaLqVbCdodxR9lVzaQjMH18KvjX/4m6tYfuspz03mvkN9MdmtDJ2GOZS7QgGQ7Q+RE9aWw=="],
|
||||
"@opencode-ai/pty": ["@opencode-ai/pty@0.1.10", "", { "optionalDependencies": { "@opencode-ai/pty-darwin-arm64": "0.1.10", "@opencode-ai/pty-darwin-x64": "0.1.10", "@opencode-ai/pty-linux-arm64-gnu": "0.1.10", "@opencode-ai/pty-linux-arm64-musl": "0.1.10", "@opencode-ai/pty-linux-x64-gnu": "0.1.10", "@opencode-ai/pty-linux-x64-musl": "0.1.10" }, "bin": { "opencode-pty": "bin/opencode-pty.js" } }, "sha512-cEJT1ADtmnb+df2wrlUcsGny6Q7pTe9Sa7keISzCO0xN1FrL1aS6+eleBPpDimHjgM/sXqvLwJv0UiAeiAvgxQ=="],
|
||||
|
||||
"@opencode-ai/pty-darwin-arm64": ["@opencode-ai/pty-darwin-arm64@0.1.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Hz59ImecqeBdLQ40TknPDc9k4xWjQPaTgZ7cXzF3vclAvZiFYSM1rRdbBF6rOaOB0DBp0OFYsiaPP1ykszLsfw=="],
|
||||
"@opencode-ai/pty-darwin-arm64": ["@opencode-ai/pty-darwin-arm64@0.1.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-j7aszDFRwCIazGUT9eIy4PZwh4rltjvRmoicPRTK3kONN3v0MMflstkmAFDYYpqDPTNh3qJ6xkQmB+DugEbhAg=="],
|
||||
|
||||
"@opencode-ai/pty-darwin-x64": ["@opencode-ai/pty-darwin-x64@0.1.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-TPpA+FZ08BXtTcOeqe0FEJitqLld6Nl46UizcSmQCTRM22xOKPar6OoxHGYtqdFCaFk43a+hk/0GWpV7mcEQEg=="],
|
||||
"@opencode-ai/pty-darwin-x64": ["@opencode-ai/pty-darwin-x64@0.1.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-UAMP/E4lo9RGQF7xrfIwpW2ZEemj308rCogJy14ruKYJt5MwHeGNTynGiHE/1JlDLRy+21wV50jpugADgT71ag=="],
|
||||
|
||||
"@opencode-ai/pty-linux-arm64-gnu": ["@opencode-ai/pty-linux-arm64-gnu@0.1.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-PTU9Ss5a5pApw6IeVvjjbFPuui2oKMoTQ/nY+K1+idLpgMeQHXk2URJbQqetqJxH4OLL5eSutDeWvpkweW0tTw=="],
|
||||
"@opencode-ai/pty-linux-arm64-gnu": ["@opencode-ai/pty-linux-arm64-gnu@0.1.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-lTPlZNQ66koFHZqoPmvvq0SetlepKVQYgnLryhlVfYtcryWDJM7gV4+P66V12RwqWQTjt2u8j12mtg3axSKg2w=="],
|
||||
|
||||
"@opencode-ai/pty-linux-arm64-musl": ["@opencode-ai/pty-linux-arm64-musl@0.1.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-NFZ2LLfEaO6858cYtEwfQKda/HnCrPR0WflnWoDllHmdY12umeHkeE8DnZQK48tziXxvEACspItXmleiAMAg3g=="],
|
||||
"@opencode-ai/pty-linux-arm64-musl": ["@opencode-ai/pty-linux-arm64-musl@0.1.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-IDmWHRylMR/ZfMw9/AAktO/Edi4TITPC+Tq7Xx3JZHsDgSba3QdyE11uNL0zM1myTGdk6Yrt4rpdAzaItPnDjw=="],
|
||||
|
||||
"@opencode-ai/pty-linux-x64-gnu": ["@opencode-ai/pty-linux-x64-gnu@0.1.11", "", { "os": "linux", "cpu": "x64" }, "sha512-2Wbko2tFkgTmY6ceB+QMA3E+omaRd5IBBFjJoikMxkc7n75TXYQVz09tCC0pPW+flGvApFQ2YcIkSotdLwit+A=="],
|
||||
"@opencode-ai/pty-linux-x64-gnu": ["@opencode-ai/pty-linux-x64-gnu@0.1.10", "", { "os": "linux", "cpu": "x64" }, "sha512-Q1yob0/8X2JoJZzFmNKUc32XDRAe0avKQ8PLKkpJr30qWXSrGmhltgcDmn94Q70zW9Ght9on84T7cmge9brvdQ=="],
|
||||
|
||||
"@opencode-ai/pty-linux-x64-musl": ["@opencode-ai/pty-linux-x64-musl@0.1.11", "", { "os": "linux", "cpu": "x64" }, "sha512-PF7vbOsSOVbRSo11pOOmJq/Vp34Ww7Xoo8rUeMSAoG1tJSIp2SXFHCRKEGH9H4KxGUfpRHQlDBV1HLz8onnyJQ=="],
|
||||
"@opencode-ai/pty-linux-x64-musl": ["@opencode-ai/pty-linux-x64-musl@0.1.10", "", { "os": "linux", "cpu": "x64" }, "sha512-7RLHWQxX/wfUKJJP2ZMMtkXaPsrgoMNKzE6PL/LbnYbMBtkqfld9EDcMv1RFZ0CqjNFgI0Hg4eRk6x+ZNc/wyQ=="],
|
||||
|
||||
"@opencode-ai/schema": ["@opencode-ai/schema@workspace:packages/schema"],
|
||||
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-NV1PD2fCgWEKsr9kR0pV9jgkC400dzoF7/DnI/fY5yI=",
|
||||
"aarch64-linux": "sha256-TDTdwE0mcHLrrKPDwPPBk3qIDl/PXJrLX6Zbwp7EH3I=",
|
||||
"aarch64-darwin": "sha256-6MEoiV1UKAWgC7C6PR4USCP/LLZXROfBfPg6sb2VVWg=",
|
||||
"x86_64-darwin": "sha256-8JV6YVZFq1BC++zpARxBWhQ+wuNJrWgTZJ6jfQhDybs="
|
||||
"x86_64-linux": "sha256-QWLIdvu985FH5I9cZJOAuoeFeXU+4Jx9RzBB9RPoeeQ=",
|
||||
"aarch64-linux": "sha256-SSzGD5hMj2vFvyw+dUPR9g/ZH6qhs0ZyZ/DnltZt3N8=",
|
||||
"aarch64-darwin": "sha256-CeFUxiV+e8pKho+YcSclC3soQBogoxNMxwyIMztAExU=",
|
||||
"x86_64-darwin": "sha256-FYwcACzU72y0+KtOpFfU7ndak8vMasqMgd5NLS6+XtY="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,7 +406,6 @@ const AnthropicEvent = Schema.Struct({
|
||||
type AnthropicEvent = Schema.Schema.Type<typeof AnthropicEvent>
|
||||
|
||||
interface ParserState {
|
||||
readonly providerMetadataKey: string
|
||||
readonly tools: ToolStream.State<number>
|
||||
readonly reasoningSignatures: Readonly<Record<number, string>>
|
||||
readonly usage?: Usage
|
||||
@@ -441,18 +440,18 @@ const cacheControl = (breakpoints: Cache.Breakpoints, cache: CacheHint | undefin
|
||||
return Cache.ttlBucket(cache.ttlSeconds) === "1h" ? EPHEMERAL_1H : EPHEMERAL_5M
|
||||
}
|
||||
|
||||
const providerMetadata = (key: string, metadata: Record<string, unknown>): ProviderMetadata => ({ [key]: metadata })
|
||||
const anthropicMetadata = (metadata: Record<string, unknown>): ProviderMetadata => ({ anthropic: metadata })
|
||||
|
||||
const signatureFromMetadata = (metadata: ProviderMetadata | undefined, key: string): string | undefined => {
|
||||
const provider = metadata?.[key]
|
||||
if (!ProviderShared.isRecord(provider)) return undefined
|
||||
return typeof provider.signature === "string" ? provider.signature : undefined
|
||||
const signatureFromMetadata = (metadata: ProviderMetadata | undefined): string | undefined => {
|
||||
const anthropic = metadata?.anthropic
|
||||
if (!ProviderShared.isRecord(anthropic)) return undefined
|
||||
return typeof anthropic.signature === "string" ? anthropic.signature : undefined
|
||||
}
|
||||
|
||||
const redactedDataFromMetadata = (metadata: ProviderMetadata | undefined, key: string): string | undefined => {
|
||||
const provider = metadata?.[key]
|
||||
if (!ProviderShared.isRecord(provider)) return undefined
|
||||
return typeof provider.redactedData === "string" ? provider.redactedData : undefined
|
||||
const redactedDataFromMetadata = (metadata: ProviderMetadata | undefined): string | undefined => {
|
||||
const anthropic = metadata?.anthropic
|
||||
if (!ProviderShared.isRecord(anthropic)) return undefined
|
||||
return typeof anthropic.redactedData === "string" ? anthropic.redactedData : undefined
|
||||
}
|
||||
|
||||
const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition, inputSchema: JsonSchema): AnthropicTool => ({
|
||||
@@ -512,16 +511,13 @@ const serverToolResultType = (name: string): AnthropicServerToolResultType | und
|
||||
return undefined
|
||||
}
|
||||
|
||||
const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult")(function* (
|
||||
part: ToolResultPart,
|
||||
providerMetadataKey: string,
|
||||
) {
|
||||
const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult")(function* (part: ToolResultPart) {
|
||||
const wireType = serverToolResultType(part.name)
|
||||
if (!wireType)
|
||||
return yield* invalid(`Anthropic Messages does not know how to round-trip server tool result for ${part.name}`)
|
||||
// Prefer the provider-owned replay payload; fall back to the result value for
|
||||
// histories constructed directly from provider events.
|
||||
const payload = part.providerMetadata?.[providerMetadataKey]?.["result"] ?? part.result.value
|
||||
const payload = part.providerMetadata?.anthropic?.["result"] ?? part.result.value
|
||||
return {
|
||||
type: wireType,
|
||||
tool_use_id: scrubToolCallID(part.id),
|
||||
@@ -808,7 +804,6 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
breakpoints: Cache.Breakpoints,
|
||||
) {
|
||||
const messages: AnthropicMessage[] = []
|
||||
const providerMetadataKey = request.model.route.providerMetadataKey ?? String(request.model.provider)
|
||||
|
||||
for (const [index, message] of request.messages.entries()) {
|
||||
if (message.role === "system") {
|
||||
@@ -854,8 +849,8 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
if (part.type === "reasoning") {
|
||||
// A signature marks visible thinking; only signature-less parts carrying
|
||||
// redactedData round-trip as opaque redacted_thinking blocks.
|
||||
const signature = part.encrypted ?? signatureFromMetadata(part.providerMetadata, providerMetadataKey)
|
||||
const redactedData = redactedDataFromMetadata(part.providerMetadata, providerMetadataKey)
|
||||
const signature = part.encrypted ?? signatureFromMetadata(part.providerMetadata)
|
||||
const redactedData = redactedDataFromMetadata(part.providerMetadata)
|
||||
if (signature === undefined && redactedData !== undefined) {
|
||||
content.push({ type: "redacted_thinking", data: redactedData })
|
||||
continue
|
||||
@@ -884,7 +879,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
continue
|
||||
}
|
||||
if (part.type === "tool-result" && part.providerExecuted) {
|
||||
content.push(yield* lowerServerToolResult(part, providerMetadataKey))
|
||||
content.push(yield* lowerServerToolResult(part))
|
||||
continue
|
||||
}
|
||||
return yield* invalid(
|
||||
@@ -1074,7 +1069,7 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
|
||||
// inclusive `inputTokens` the rest of the contract expects. Extended
|
||||
// thinking tokens are included in `output_tokens`; newer responses also
|
||||
// expose that subset through `output_tokens_details.thinking_tokens`.
|
||||
const mapUsage = (usage: AnthropicUsage | undefined, providerMetadataKey: string): Usage | undefined => {
|
||||
const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => {
|
||||
if (!usage) return undefined
|
||||
const nonCached = usage.input_tokens ?? undefined
|
||||
const cacheRead = usage.cache_read_input_tokens ?? undefined
|
||||
@@ -1088,7 +1083,7 @@ const mapUsage = (usage: AnthropicUsage | undefined, providerMetadataKey: string
|
||||
cacheWriteInputTokens: cacheWrite,
|
||||
reasoningTokens: usage.output_tokens_details?.thinking_tokens,
|
||||
totalTokens: ProviderShared.totalTokens(inputTokens, usage.output_tokens, undefined),
|
||||
providerMetadata: { [providerMetadataKey]: usage },
|
||||
providerMetadata: { anthropic: usage },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1097,7 +1092,7 @@ const mapUsage = (usage: AnthropicUsage | undefined, providerMetadataKey: string
|
||||
// field prefers `right` when defined, falls back to `left`. `inputTokens` is
|
||||
// recomputed from the merged breakdown so the inclusive total stays
|
||||
// consistent with `nonCached + cacheRead + cacheWrite`.
|
||||
const mergeUsage = (left: Usage | undefined, right: Usage | undefined, providerMetadataKey: string) => {
|
||||
const mergeUsage = (left: Usage | undefined, right: Usage | undefined) => {
|
||||
if (!left) return right
|
||||
if (!right) return left
|
||||
const nonCachedInputTokens = right.nonCachedInputTokens ?? left.nonCachedInputTokens
|
||||
@@ -1115,9 +1110,7 @@ const mergeUsage = (left: Usage | undefined, right: Usage | undefined, providerM
|
||||
reasoningTokens,
|
||||
totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined),
|
||||
providerMetadata: {
|
||||
[providerMetadataKey]:
|
||||
mergeJsonRecords(left.providerMetadata?.[providerMetadataKey], right.providerMetadata?.[providerMetadataKey]) ??
|
||||
{},
|
||||
anthropic: mergeJsonRecords(left.providerMetadata?.["anthropic"], right.providerMetadata?.["anthropic"]) ?? {},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1135,7 +1128,7 @@ const SERVER_TOOL_RESULT_NAMES: Record<AnthropicServerToolResultType, string> =
|
||||
|
||||
const isServerToolResultType = (type: string): type is AnthropicServerToolResultType => type in SERVER_TOOL_RESULT_NAMES
|
||||
|
||||
const serverToolResultEvent = (block: AnthropicStreamBlock, providerMetadataKey: string): LLMEvent | undefined => {
|
||||
const serverToolResultEvent = (block: AnthropicStreamBlock): LLMEvent | undefined => {
|
||||
if (!block.type || !isServerToolResultType(block.type)) return undefined
|
||||
const errorPayload =
|
||||
typeof block.content === "object" && block.content !== null && "type" in block.content
|
||||
@@ -1149,7 +1142,7 @@ const serverToolResultEvent = (block: AnthropicStreamBlock, providerMetadataKey:
|
||||
providerExecuted: true,
|
||||
// The complete payload is irreducible provider replay state: subsequent
|
||||
// stateless requests must round-trip the typed result block verbatim.
|
||||
providerMetadata: providerMetadata(providerMetadataKey, { blockType: block.type, result: block.content }),
|
||||
providerMetadata: anthropicMetadata({ blockType: block.type, result: block.content }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1158,8 +1151,8 @@ type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
|
||||
const NO_EVENTS: StepResult["1"] = []
|
||||
|
||||
const onMessageStart = (state: ParserState, event: AnthropicEvent): StepResult => {
|
||||
const usage = mapUsage(event.message?.usage, state.providerMetadataKey)
|
||||
return [usage ? { ...state, usage: mergeUsage(state.usage, usage, state.providerMetadataKey) } : state, NO_EVENTS]
|
||||
const usage = mapUsage(event.message?.usage)
|
||||
return [usage ? { ...state, usage: mergeUsage(state.usage, usage) } : state, NO_EVENTS]
|
||||
}
|
||||
|
||||
const onContentBlockStart = (
|
||||
@@ -1211,16 +1204,14 @@ const onContentBlockStart = (
|
||||
if (block.type === "thinking" && block.thinking !== undefined) {
|
||||
const events: LLMEvent[] = []
|
||||
const id = `reasoning-${event.index ?? 0}`
|
||||
const metadata =
|
||||
block.signature === undefined
|
||||
? undefined
|
||||
: providerMetadata(state.providerMetadataKey, { signature: block.signature })
|
||||
const lifecycle = Lifecycle.reasoningStart(state.lifecycle, events, id, metadata)
|
||||
const providerMetadata =
|
||||
block.signature === undefined ? undefined : anthropicMetadata({ signature: block.signature })
|
||||
const lifecycle = Lifecycle.reasoningStart(state.lifecycle, events, id, providerMetadata)
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: block.thinking
|
||||
? Lifecycle.reasoningDelta(lifecycle, events, id, block.thinking, metadata)
|
||||
? Lifecycle.reasoningDelta(lifecycle, events, id, block.thinking, providerMetadata)
|
||||
: lifecycle,
|
||||
reasoningSignatures:
|
||||
event.index === undefined || block.signature === undefined
|
||||
@@ -1243,14 +1234,14 @@ const onContentBlockStart = (
|
||||
state.lifecycle,
|
||||
events,
|
||||
`reasoning-${event.index ?? 0}`,
|
||||
providerMetadata(state.providerMetadataKey, { redactedData: block.data }),
|
||||
anthropicMetadata({ redactedData: block.data }),
|
||||
),
|
||||
},
|
||||
events,
|
||||
]
|
||||
}
|
||||
|
||||
const result = serverToolResultEvent(block, state.providerMetadataKey)
|
||||
const result = serverToolResultEvent(block)
|
||||
if (!result) return [state, NO_EVENTS]
|
||||
const events: LLMEvent[] = []
|
||||
return [{ ...state, lifecycle: Lifecycle.stepStart(state.lifecycle, events) }, [...events, result]]
|
||||
@@ -1330,7 +1321,7 @@ const onContentBlockStop = Effect.fn("AnthropicMessages.onContentBlockStop")(fun
|
||||
Lifecycle.textEnd(state.lifecycle, events, `text-${event.index}`),
|
||||
events,
|
||||
`reasoning-${event.index}`,
|
||||
signature === undefined ? undefined : providerMetadata(state.providerMetadataKey, { signature }),
|
||||
signature === undefined ? undefined : anthropicMetadata({ signature }),
|
||||
)
|
||||
events.push(...resultEvents)
|
||||
const reasoningSignatures = { ...state.reasoningSignatures }
|
||||
@@ -1342,7 +1333,7 @@ const onMessageDelta = (
|
||||
state: ParserState,
|
||||
event: AnthropicEvent & { readonly delta?: AnthropicStreamDelta },
|
||||
): StepResult => {
|
||||
const usage = mergeUsage(state.usage, mapUsage(event.usage, state.providerMetadataKey), state.providerMetadataKey)
|
||||
const usage = mergeUsage(state.usage, mapUsage(event.usage))
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
@@ -1355,7 +1346,7 @@ const onMessageDelta = (
|
||||
providerMetadata:
|
||||
event.delta?.stop_sequence === null || event.delta?.stop_sequence === undefined
|
||||
? undefined
|
||||
: providerMetadata(state.providerMetadataKey, { stopSequence: event.delta.stop_sequence }),
|
||||
: anthropicMetadata({ stopSequence: event.delta.stop_sequence }),
|
||||
},
|
||||
},
|
||||
NO_EVENTS,
|
||||
@@ -1481,8 +1472,7 @@ export const protocol = Protocol.make({
|
||||
},
|
||||
stream: {
|
||||
event: Protocol.jsonEvent(AnthropicEvent),
|
||||
initial: (request) => ({
|
||||
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
|
||||
initial: () => ({
|
||||
tools: ToolStream.empty<number>(),
|
||||
reasoningSignatures: {},
|
||||
lifecycle: Lifecycle.initial(),
|
||||
|
||||
@@ -258,21 +258,19 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
|
||||
tool: (name) => ({ tool: { name } }) as const,
|
||||
})
|
||||
|
||||
const providerMetadata = (key: string, metadata: Record<string, unknown>): ProviderMetadata => ({ [key]: metadata })
|
||||
const bedrockMetadata = (metadata: Record<string, unknown>): ProviderMetadata => ({ bedrock: metadata })
|
||||
|
||||
const reasoningSignature = (part: ReasoningPart, providerMetadataKey: string) => {
|
||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||
const reasoningSignature = (part: ReasoningPart) => {
|
||||
const bedrock = part.providerMetadata?.bedrock
|
||||
return (
|
||||
part.encrypted ??
|
||||
(ProviderShared.isRecord(metadata) && typeof metadata.signature === "string" ? metadata.signature : undefined)
|
||||
(ProviderShared.isRecord(bedrock) && typeof bedrock.signature === "string" ? bedrock.signature : undefined)
|
||||
)
|
||||
}
|
||||
|
||||
const reasoningRedactedData = (part: ReasoningPart, providerMetadataKey: string) => {
|
||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||
return ProviderShared.isRecord(metadata) && typeof metadata.redactedData === "string"
|
||||
? metadata.redactedData
|
||||
: undefined
|
||||
const reasoningRedactedData = (part: ReasoningPart) => {
|
||||
const bedrock = part.providerMetadata?.bedrock
|
||||
return ProviderShared.isRecord(bedrock) && typeof bedrock.redactedData === "string" ? bedrock.redactedData : undefined
|
||||
}
|
||||
|
||||
const lowerToolCall = (part: ToolCallPart): BedrockToolUseBlock => ({
|
||||
@@ -320,7 +318,6 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
breakpoints: BedrockCache.Breakpoints,
|
||||
) {
|
||||
const messages: BedrockMessage[] = []
|
||||
const providerMetadataKey = request.model.route.providerMetadataKey ?? String(request.model.provider)
|
||||
|
||||
for (const message of request.messages) {
|
||||
if (message.role === "system") {
|
||||
@@ -368,8 +365,8 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
continue
|
||||
}
|
||||
if (part.type === "reasoning") {
|
||||
const signature = reasoningSignature(part, providerMetadataKey)
|
||||
const redactedData = reasoningRedactedData(part, providerMetadataKey)
|
||||
const signature = reasoningSignature(part)
|
||||
const redactedData = reasoningRedactedData(part)
|
||||
if (signature === undefined && redactedData !== undefined) {
|
||||
content.push({ reasoningContent: { redactedContent: redactedData } })
|
||||
continue
|
||||
@@ -469,7 +466,7 @@ const mapFinishReason = (reason: string): FinishReason => {
|
||||
|
||||
// AWS reports inputTokens separately from cache reads and writes.
|
||||
// Bedrock does not break reasoning out of outputTokens for current models.
|
||||
const mapUsage = (usage: BedrockUsageSchema | undefined, providerMetadataKey: string): Usage | undefined => {
|
||||
const mapUsage = (usage: BedrockUsageSchema | undefined): Usage | undefined => {
|
||||
if (!usage) return undefined
|
||||
const inputTokens = ProviderShared.sumTokens(
|
||||
usage.inputTokens,
|
||||
@@ -483,12 +480,11 @@ const mapUsage = (usage: BedrockUsageSchema | undefined, providerMetadataKey: st
|
||||
cacheReadInputTokens: usage.cacheReadInputTokens,
|
||||
cacheWriteInputTokens: usage.cacheWriteInputTokens,
|
||||
totalTokens: ProviderShared.totalTokens(inputTokens, usage.outputTokens, usage.totalTokens),
|
||||
providerMetadata: { [providerMetadataKey]: usage },
|
||||
providerMetadata: { bedrock: usage },
|
||||
})
|
||||
}
|
||||
|
||||
interface ParserState {
|
||||
readonly providerMetadataKey: string
|
||||
readonly tools: ToolStream.State<number>
|
||||
// Bedrock splits the finish into `messageStop` (carries `stopReason`) and
|
||||
// `metadata` (carries usage). Hold the terminal event in state so `onHalt`
|
||||
@@ -545,14 +541,20 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
const reasoning = event.contentBlockDelta.delta.reasoningContent
|
||||
const events: LLMEvent[] = []
|
||||
const redactedData = reasoning.redactedContent ?? reasoning.data
|
||||
const metadata = reasoning.signature
|
||||
? providerMetadata(state.providerMetadataKey, { signature: reasoning.signature })
|
||||
const providerMetadata = reasoning.signature
|
||||
? bedrockMetadata({ signature: reasoning.signature })
|
||||
: redactedData !== undefined
|
||||
? providerMetadata(state.providerMetadataKey, { redactedData })
|
||||
? bedrockMetadata({ redactedData })
|
||||
: undefined
|
||||
const lifecycle =
|
||||
reasoning.text !== undefined || metadata !== undefined
|
||||
? Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${index}`, reasoning.text ?? "", metadata)
|
||||
reasoning.text !== undefined || providerMetadata !== undefined
|
||||
? Lifecycle.reasoningDelta(
|
||||
state.lifecycle,
|
||||
events,
|
||||
`reasoning-${index}`,
|
||||
reasoning.text ?? "",
|
||||
providerMetadata,
|
||||
)
|
||||
: state.lifecycle
|
||||
return [
|
||||
{
|
||||
@@ -594,7 +596,7 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
events,
|
||||
`reasoning-${index}`,
|
||||
state.reasoningSignatures[index]
|
||||
? providerMetadata(state.providerMetadataKey, { signature: state.reasoningSignatures[index] })
|
||||
? bedrockMetadata({ signature: state.reasoningSignatures[index] })
|
||||
: undefined,
|
||||
)
|
||||
events.push(...resultEvents)
|
||||
@@ -631,7 +633,7 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
}
|
||||
|
||||
if (event.metadata) {
|
||||
const usage = mapUsage(event.metadata.usage, state.providerMetadataKey) ?? state.pendingFinish?.usage
|
||||
const usage = mapUsage(event.metadata.usage) ?? state.pendingFinish?.usage
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
@@ -696,8 +698,7 @@ export const protocol = Protocol.make({
|
||||
},
|
||||
stream: {
|
||||
event: BedrockEvent,
|
||||
initial: (request) => ({
|
||||
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
|
||||
initial: () => ({
|
||||
tools: ToolStream.empty<number>(),
|
||||
pendingFinish: undefined,
|
||||
hasToolCalls: false,
|
||||
|
||||
@@ -229,7 +229,6 @@ type GeminiEvent = Schema.Schema.Type<typeof GeminiEvent>
|
||||
|
||||
interface ParserState {
|
||||
readonly route: string
|
||||
readonly providerMetadataKey: string
|
||||
readonly finishReason?: string
|
||||
readonly hasToolCalls: boolean
|
||||
readonly promptFeedback?: GeminiPromptFeedback
|
||||
@@ -286,23 +285,22 @@ const lowerUserPart = Effect.fn("Gemini.lowerUserPart")(function* (part: TextPar
|
||||
return { inlineData: { mimeType: media.mime, data: media.base64 } }
|
||||
})
|
||||
|
||||
const providerMetadata = (key: string, metadata: Record<string, unknown>): ProviderMetadata => ({ [key]: metadata })
|
||||
const googleMetadata = (metadata: Record<string, unknown>): ProviderMetadata => ({ google: metadata })
|
||||
|
||||
const thoughtSignature = (metadata: ProviderMetadata | undefined, key: string) => {
|
||||
const value = metadata?.[key]
|
||||
return ProviderShared.isRecord(value) && typeof value.thoughtSignature === "string"
|
||||
? value.thoughtSignature
|
||||
const thoughtSignature = (providerMetadata: ProviderMetadata | undefined) => {
|
||||
const google = providerMetadata?.google
|
||||
return ProviderShared.isRecord(google) && typeof google.thoughtSignature === "string"
|
||||
? google.thoughtSignature
|
||||
: undefined
|
||||
}
|
||||
|
||||
const lowerToolCall = (part: ToolCallPart, omitIds: boolean, metadataKey: string) => ({
|
||||
const lowerToolCall = (part: ToolCallPart, omitIds: boolean) => ({
|
||||
functionCall: { ...(omitIds ? {} : { id: part.id }), name: part.name, args: part.input },
|
||||
thoughtSignature: thoughtSignature(part.providerMetadata, metadataKey),
|
||||
thoughtSignature: thoughtSignature(part.providerMetadata),
|
||||
})
|
||||
|
||||
const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMRequest) {
|
||||
const contents: GeminiContent[] = []
|
||||
const metadataKey = request.model.route.providerMetadataKey ?? String(request.model.provider)
|
||||
const omitCallIds = omitsFunctionCallIds(request.model.id)
|
||||
const legacyToolMedia = routesLegacyToolMedia(request.model.id)
|
||||
let pendingMedia: GeminiInlineDataPart[] | undefined
|
||||
@@ -344,19 +342,15 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
if (!ProviderShared.supportsContent(part, ["text", "reasoning", "tool-call"]))
|
||||
return yield* ProviderShared.unsupportedContent("Gemini", "assistant", ["text", "reasoning", "tool-call"])
|
||||
if (part.type === "text") {
|
||||
parts.push({ text: part.text, thoughtSignature: thoughtSignature(part.providerMetadata, metadataKey) })
|
||||
parts.push({ text: part.text, thoughtSignature: thoughtSignature(part.providerMetadata) })
|
||||
continue
|
||||
}
|
||||
if (part.type === "reasoning") {
|
||||
parts.push({
|
||||
text: part.text,
|
||||
thought: true,
|
||||
thoughtSignature: thoughtSignature(part.providerMetadata, metadataKey),
|
||||
})
|
||||
parts.push({ text: part.text, thought: true, thoughtSignature: thoughtSignature(part.providerMetadata) })
|
||||
continue
|
||||
}
|
||||
if (part.type === "tool-call") {
|
||||
const lowered = lowerToolCall(part, omitCallIds, metadataKey)
|
||||
const lowered = lowerToolCall(part, omitCallIds)
|
||||
const signature = lowered.thoughtSignature
|
||||
parts.push({
|
||||
...lowered,
|
||||
@@ -504,7 +498,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
|
||||
// `cachedContentTokenCount` subset. `candidatesTokenCount` is *exclusive*
|
||||
// of `thoughtsTokenCount` — visible-only, not a total — so we sum the two
|
||||
// to produce the inclusive `outputTokens` the rest of the contract expects.
|
||||
const mapUsage = (usage: GeminiUsage | undefined, metadataKey: string) => {
|
||||
const mapUsage = (usage: GeminiUsage | undefined) => {
|
||||
if (!usage) return undefined
|
||||
// Explicit provider nulls decode as `null`; normalize to `undefined` so the
|
||||
// token arithmetic below treats them like absent counts.
|
||||
@@ -525,7 +519,7 @@ const mapUsage = (usage: GeminiUsage | undefined, metadataKey: string) => {
|
||||
cacheReadInputTokens: cached,
|
||||
reasoningTokens: thoughts,
|
||||
totalTokens: ProviderShared.totalTokens(promptTokens, outputTokens, usage.totalTokenCount ?? undefined),
|
||||
providerMetadata: providerMetadata(metadataKey, usage),
|
||||
providerMetadata: { google: usage },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -573,14 +567,14 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||
lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
providerMetadata(state.providerMetadataKey, { thoughtSignature: state.reasoningSignature }),
|
||||
googleMetadata({ thoughtSignature: state.reasoningSignature }),
|
||||
)
|
||||
if (state.textSignature !== undefined)
|
||||
lifecycle = Lifecycle.textEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
"text-0",
|
||||
providerMetadata(state.providerMetadataKey, { thoughtSignature: state.textSignature }),
|
||||
googleMetadata({ thoughtSignature: state.textSignature }),
|
||||
)
|
||||
Lifecycle.finish(lifecycle, events, {
|
||||
reason: {
|
||||
@@ -590,9 +584,7 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||
},
|
||||
usage: state.usage,
|
||||
providerMetadata:
|
||||
state.promptFeedback === undefined
|
||||
? undefined
|
||||
: providerMetadata(state.providerMetadataKey, { promptFeedback: state.promptFeedback }),
|
||||
state.promptFeedback === undefined ? undefined : googleMetadata({ promptFeedback: state.promptFeedback }),
|
||||
})
|
||||
return events
|
||||
}
|
||||
@@ -601,9 +593,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
const nextState = {
|
||||
...state,
|
||||
promptFeedback: event.promptFeedback ?? state.promptFeedback,
|
||||
usage: event.usageMetadata
|
||||
? (mapUsage(event.usageMetadata, state.providerMetadataKey) ?? state.usage)
|
||||
: state.usage,
|
||||
usage: event.usageMetadata ? (mapUsage(event.usageMetadata) ?? state.usage) : state.usage,
|
||||
}
|
||||
const candidate = event.candidates?.[0]
|
||||
if (!candidate?.content)
|
||||
@@ -647,7 +637,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
events,
|
||||
"reasoning-0",
|
||||
part.text,
|
||||
signature ? providerMetadata(state.providerMetadataKey, { thoughtSignature: signature }) : undefined,
|
||||
signature ? googleMetadata({ thoughtSignature: signature }) : undefined,
|
||||
)
|
||||
continue
|
||||
}
|
||||
@@ -655,16 +645,14 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
reasoningSignature
|
||||
? providerMetadata(state.providerMetadataKey, { thoughtSignature: reasoningSignature })
|
||||
: undefined,
|
||||
reasoningSignature ? googleMetadata({ thoughtSignature: reasoningSignature }) : undefined,
|
||||
)
|
||||
lifecycle = Lifecycle.textDelta(
|
||||
lifecycle,
|
||||
events,
|
||||
"text-0",
|
||||
part.text,
|
||||
textSignature ? providerMetadata(state.providerMetadataKey, { thoughtSignature: textSignature }) : undefined,
|
||||
textSignature ? googleMetadata({ thoughtSignature: textSignature }) : undefined,
|
||||
)
|
||||
textSignature = undefined
|
||||
continue
|
||||
@@ -684,9 +672,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
reasoningSignature
|
||||
? providerMetadata(state.providerMetadataKey, { thoughtSignature: reasoningSignature })
|
||||
: undefined,
|
||||
reasoningSignature ? googleMetadata({ thoughtSignature: reasoningSignature }) : undefined,
|
||||
)
|
||||
lifecycle = Lifecycle.stepStart(lifecycle, events)
|
||||
events.push(
|
||||
@@ -695,7 +681,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
name: part.functionCall.name,
|
||||
input,
|
||||
providerMetadata: part.thoughtSignature
|
||||
? providerMetadata(state.providerMetadataKey, { thoughtSignature: part.thoughtSignature })
|
||||
? googleMetadata({ thoughtSignature: part.thoughtSignature })
|
||||
: undefined,
|
||||
}),
|
||||
)
|
||||
@@ -734,7 +720,6 @@ export const protocol = Protocol.make({
|
||||
event: Protocol.jsonEvent(GeminiEvent),
|
||||
initial: (request) => ({
|
||||
route: `${request.model.provider}/${request.model.route.id}`,
|
||||
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
|
||||
hasToolCalls: false,
|
||||
lifecycle: Lifecycle.initial(),
|
||||
}),
|
||||
|
||||
@@ -253,7 +253,6 @@ interface PendingToolDelta {
|
||||
}
|
||||
|
||||
export interface ParserState {
|
||||
readonly providerMetadataKey: string
|
||||
readonly tools: ToolStream.State<number>
|
||||
readonly pendingTools: Partial<Record<number, PendingToolDelta>>
|
||||
readonly toolCallEvents: ReadonlyArray<LLMEvent>
|
||||
@@ -325,18 +324,17 @@ const lowerMedia = Effect.fn("OpenAIChat.lowerMedia")(function* (part: MediaPart
|
||||
const openAICompatibleReasoningContent = (native: unknown) =>
|
||||
isRecord(native) && typeof native.reasoning_content === "string" ? native.reasoning_content : undefined
|
||||
|
||||
const reasoningField = (part: ReasoningPart, providerMetadataKey: string) => {
|
||||
const field = part.providerMetadata?.[providerMetadataKey]?.reasoningField
|
||||
const reasoningField = (part: ReasoningPart) => {
|
||||
const field = part.providerMetadata?.openai?.reasoningField
|
||||
return typeof field === "string" ? field : undefined
|
||||
}
|
||||
|
||||
const reasoningDetails = (parts: ReadonlyArray<ReasoningPart>, native: unknown, providerMetadataKey: string) => {
|
||||
const reasoningDetails = (parts: ReadonlyArray<ReasoningPart>, native: unknown) => {
|
||||
const observed = parts.flatMap((part) => {
|
||||
const details = part.providerMetadata?.[providerMetadataKey]?.reasoningDetails
|
||||
const details = part.providerMetadata?.openai?.reasoningDetails
|
||||
return Array.isArray(details) ? details : []
|
||||
})
|
||||
if (parts.some((part) => Array.isArray(part.providerMetadata?.[providerMetadataKey]?.reasoningDetails)))
|
||||
return observed
|
||||
if (parts.some((part) => Array.isArray(part.providerMetadata?.openai?.reasoningDetails))) return observed
|
||||
if (isRecord(native) && Array.isArray(native.reasoning_details)) return native.reasoning_details
|
||||
}
|
||||
|
||||
@@ -368,7 +366,7 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
|
||||
message: OpenAIChatRequestMessage,
|
||||
configuredField: string | undefined,
|
||||
requireReasoning: boolean,
|
||||
options: LoweringOptions & { readonly providerMetadataKey: string },
|
||||
options: LoweringOptions,
|
||||
) {
|
||||
const content: TextPart[] = []
|
||||
const reasoning: ReasoningPart[] = []
|
||||
@@ -390,14 +388,10 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
|
||||
}
|
||||
}
|
||||
const text = reasoning.map((part) => part.text).join("")
|
||||
const details = reasoningDetails(reasoning, message.native?.openaiCompatible, options.providerMetadataKey)
|
||||
const observedField = reasoning
|
||||
.map((part) => reasoningField(part, options.providerMetadataKey))
|
||||
.find((value) => value !== undefined)
|
||||
const details = reasoningDetails(reasoning, message.native?.openaiCompatible)
|
||||
const observedField = reasoning.map(reasoningField).find((value) => value !== undefined)
|
||||
const nativeReasoning = openAICompatibleReasoningContent(message.native?.openaiCompatible)
|
||||
const fullyStructured = reasoning.every((part) =>
|
||||
Array.isArray(part.providerMetadata?.[options.providerMetadataKey]?.reasoningDetails),
|
||||
)
|
||||
const fullyStructured = reasoning.every((part) => Array.isArray(part.providerMetadata?.openai?.reasoningDetails))
|
||||
const field = (() => {
|
||||
if (configuredField !== undefined && (requireReasoning || reasoning.length > 0 || nativeReasoning !== undefined))
|
||||
return configuredField
|
||||
@@ -465,7 +459,7 @@ const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* (
|
||||
message: OpenAIChatRequestMessage,
|
||||
reasoningField: string | undefined,
|
||||
requireReasoning: boolean,
|
||||
options: LoweringOptions & { readonly providerMetadataKey: string },
|
||||
options: LoweringOptions,
|
||||
) {
|
||||
if (message.role === "user") return [yield* lowerUserMessage(message, options)]
|
||||
if (message.role === "assistant")
|
||||
@@ -501,7 +495,6 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
|
||||
const mistral = ["mistral", "devstral", "codestral", "pixtral", "mixtral"].some((family) => modelID.includes(family))
|
||||
const lowering = {
|
||||
...options,
|
||||
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
|
||||
toolCallID: (id: string) => {
|
||||
if (mistral)
|
||||
return id
|
||||
@@ -827,7 +820,7 @@ const mapFinishReason = Effect.fn("OpenAIChat.mapFinishReason")(function* (event
|
||||
// Providers differ on cache-hit location: OpenAI uses
|
||||
// `prompt_tokens_details.cached_tokens`, DeepSeek uses
|
||||
// `prompt_cache_hit_tokens`, and Zai uses top-level `cached_tokens`.
|
||||
const mapUsage = (usage: OpenAIChatEvent["usage"], providerMetadataKey: string): Usage | undefined => {
|
||||
const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
|
||||
if (!usage) return undefined
|
||||
const input = usage.prompt_tokens ?? undefined
|
||||
const output = usage.completion_tokens ?? undefined
|
||||
@@ -846,7 +839,7 @@ const mapUsage = (usage: OpenAIChatEvent["usage"], providerMetadataKey: string):
|
||||
cacheWriteInputTokens: cacheWrite,
|
||||
reasoningTokens: reasoning,
|
||||
totalTokens: ProviderShared.totalTokens(input, output, usage.total_tokens ?? undefined),
|
||||
providerMetadata: { [providerMetadataKey]: usage },
|
||||
providerMetadata: { openai: usage },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -920,12 +913,8 @@ const conflictingReasoningTextDetails = (previous: Record<string, unknown>, curr
|
||||
const conflictingDetailValue = (previous: unknown, current: unknown) =>
|
||||
previous !== undefined && previous !== null && current !== undefined && current !== null && previous !== current
|
||||
|
||||
const reasoningMetadata = (
|
||||
providerMetadataKey: string,
|
||||
field: ParserState["reasoningField"],
|
||||
details?: ReadonlyArray<unknown>,
|
||||
) => ({
|
||||
[providerMetadataKey]: {
|
||||
const reasoningMetadata = (field: ParserState["reasoningField"], details?: ReadonlyArray<unknown>) => ({
|
||||
openai: {
|
||||
...(field ? { reasoningField: field } : {}),
|
||||
...(details ? { reasoningDetails: details } : {}),
|
||||
},
|
||||
@@ -952,10 +941,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
// Moonshot (and a few other OpenAI-compatible providers) attach usage to
|
||||
// `choice.usage` instead of the top-level `usage` field.
|
||||
const choiceUsage = (choice as unknown as { usage?: OpenAIChatEvent["usage"] })?.usage
|
||||
const usage =
|
||||
mapUsage(event.usage, state.providerMetadataKey) ??
|
||||
(choiceUsage ? mapUsage(choiceUsage, state.providerMetadataKey) : undefined) ??
|
||||
state.usage
|
||||
const usage = mapUsage(event.usage) ?? (choiceUsage ? mapUsage(choiceUsage) : undefined) ?? state.usage
|
||||
const rawFinishReason = choice?.finish_reason
|
||||
const finishReason = rawFinishReason
|
||||
? {
|
||||
@@ -993,7 +979,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
const detailDelta = Array.isArray(delta?.reasoning_details) ? delta.reasoning_details : undefined
|
||||
if (detailDelta !== undefined) appendReasoningDetails(state.reasoningDetails, detailDelta)
|
||||
const reasoningDetailsObserved = state.reasoningDetailsObserved || detailDelta !== undefined
|
||||
const deltaMetadata = reasoningMetadata(state.providerMetadataKey, reasoningField)
|
||||
const deltaMetadata = reasoningMetadata(reasoningField)
|
||||
const text = detailDelta?.length ? (detailText(detailDelta) ?? reasoning?.text) : reasoning?.text
|
||||
if (text !== undefined) lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", text, deltaMetadata)
|
||||
else if (
|
||||
@@ -1009,11 +995,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
reasoningMetadata(
|
||||
state.providerMetadataKey,
|
||||
reasoningField,
|
||||
reasoningDetailsObserved ? state.reasoningDetails : undefined,
|
||||
),
|
||||
reasoningMetadata(reasoningField, reasoningDetailsObserved ? state.reasoningDetails : undefined),
|
||||
)
|
||||
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
|
||||
}
|
||||
@@ -1023,11 +1005,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
reasoningMetadata(
|
||||
state.providerMetadataKey,
|
||||
reasoningField,
|
||||
reasoningDetailsObserved ? state.reasoningDetails : undefined,
|
||||
),
|
||||
reasoningMetadata(reasoningField, reasoningDetailsObserved ? state.reasoningDetails : undefined),
|
||||
)
|
||||
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.refusal)
|
||||
}
|
||||
@@ -1088,7 +1066,6 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
|
||||
return [
|
||||
{
|
||||
providerMetadataKey: state.providerMetadataKey,
|
||||
tools: finished?.tools ?? tools,
|
||||
pendingTools,
|
||||
toolCallEvents: finished?.events ?? state.toolCallEvents,
|
||||
@@ -1132,18 +1109,12 @@ const finishEvents = Effect.fn("OpenAIChat.finishEvents")(function* (state: Pars
|
||||
}
|
||||
: { normalized: hasToolCalls ? ("tool-calls" as const) : ("stop" as const) }
|
||||
const metadata = reasoningMetadata(
|
||||
state.providerMetadataKey,
|
||||
state.reasoningField,
|
||||
state.reasoningDetailsObserved ? state.reasoningDetails : undefined,
|
||||
)
|
||||
const started =
|
||||
state.reasoningDetailsObserved && !state.reasoningEmitted
|
||||
? Lifecycle.reasoningStart(
|
||||
state.lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
reasoningMetadata(state.providerMetadataKey, state.reasoningField),
|
||||
)
|
||||
? Lifecycle.reasoningStart(state.lifecycle, events, "reasoning-0", reasoningMetadata(state.reasoningField))
|
||||
: state.lifecycle
|
||||
const ended = Lifecycle.reasoningEnd(started, events, "reasoning-0", metadata)
|
||||
const lifecycle = toolCallEvents.length ? Lifecycle.stepStart(ended, events) : ended
|
||||
@@ -1170,7 +1141,6 @@ export const protocol = Protocol.make({
|
||||
stream: {
|
||||
event: Protocol.jsonEvent(OpenAIChatEvent),
|
||||
initial: (request) => ({
|
||||
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
|
||||
tools: ToolStream.empty<number>(),
|
||||
pendingTools: {},
|
||||
toolCallEvents: [],
|
||||
|
||||
@@ -23,14 +23,13 @@ export interface Settings extends ProviderPackage.Settings {
|
||||
readonly baseURL?: string
|
||||
readonly credentials?: Credentials
|
||||
readonly region?: string
|
||||
readonly topP?: number
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
const responsesRoute = Route.make({
|
||||
id: "bedrock-mantle-responses",
|
||||
provider: id,
|
||||
providerMetadataKey: "mantle",
|
||||
providerMetadataKey: OpenAIResponses.route.providerMetadataKey,
|
||||
protocol: OpenAIResponses.protocol,
|
||||
endpoint: OpenAIResponses.route.endpoint,
|
||||
auth: OpenAIResponses.route.auth,
|
||||
@@ -41,7 +40,6 @@ const responsesRoute = Route.make({
|
||||
const chatRoute = OpenAIChat.route.with({
|
||||
id: "bedrock-mantle-chat",
|
||||
provider: id,
|
||||
providerMetadataKey: "mantle",
|
||||
})
|
||||
|
||||
export const routes = [responsesRoute, chatRoute]
|
||||
@@ -78,7 +76,7 @@ export const configure = (input: Config = {}) => {
|
||||
|
||||
return {
|
||||
id,
|
||||
model: responses,
|
||||
model: chat,
|
||||
chat,
|
||||
responses,
|
||||
configure,
|
||||
@@ -96,7 +94,6 @@ const config = (settings: Settings): Config => {
|
||||
apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
credentials: settings.credentials,
|
||||
generation: settings.topP === undefined ? undefined : { topP: settings.topP },
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
@@ -112,4 +109,4 @@ export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProvider
|
||||
modelID,
|
||||
settings,
|
||||
) => configure(config(settings)).responses(modelID)
|
||||
export const model = responsesModel
|
||||
export const model = chatModel
|
||||
|
||||
@@ -35,7 +35,6 @@ const configuredRoute = (input: Config) => {
|
||||
return BedrockConverse.route.with({
|
||||
...rest,
|
||||
provider: id,
|
||||
providerMetadataKey: "bedrock",
|
||||
endpoint: { baseURL: baseURL ?? bedrockBaseURL(resolvedRegion) },
|
||||
auth: apiKey === undefined ? BedrockConverse.sigV4Auth(credentials) : Auth.bearer(apiKey),
|
||||
})
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { responsesModel as model } from "../amazon-bedrock-mantle.js"
|
||||
export { chatModel as model } from "../amazon-bedrock-mantle.js"
|
||||
export type { Settings } from "../amazon-bedrock-mantle.js"
|
||||
|
||||
@@ -27,7 +27,6 @@ export interface Settings extends ProviderPackage.Settings {
|
||||
const route = OpenAICompatibleChat.route.with({
|
||||
id: "google-vertex-chat",
|
||||
provider: id,
|
||||
providerMetadataKey: "vertex",
|
||||
})
|
||||
|
||||
export const routes = [route]
|
||||
|
||||
@@ -27,7 +27,6 @@ export interface Settings extends ProviderPackage.Settings {
|
||||
const route = OpenAICompatibleResponses.route.with({
|
||||
id: "google-vertex-responses",
|
||||
provider: id,
|
||||
providerMetadataKey: "vertex",
|
||||
providerOptions: { store: false },
|
||||
})
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ const protocol = {
|
||||
const route = Route.make({
|
||||
id: "google-vertex-gemini",
|
||||
provider: id,
|
||||
providerMetadataKey: "vertex",
|
||||
providerMetadataKey: "google",
|
||||
protocol,
|
||||
endpoint: Endpoint.path(({ request }) => {
|
||||
const model = String(request.model.id)
|
||||
|
||||
@@ -164,7 +164,6 @@ const bodyOptions = (input: unknown) => {
|
||||
export const route = Route.make({
|
||||
id: ADAPTER,
|
||||
provider: profile.provider,
|
||||
providerMetadataKey: "openrouter",
|
||||
protocol,
|
||||
endpoint: Endpoint.path("/chat/completions", { baseURL: profile.baseURL }),
|
||||
framing: Framing.sse,
|
||||
|
||||
@@ -89,7 +89,6 @@ export interface RouteDefaultsInput {
|
||||
export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
|
||||
readonly id?: string
|
||||
readonly provider?: string | ProviderID
|
||||
readonly providerMetadataKey?: string
|
||||
readonly auth?: Auth.Definition
|
||||
readonly transport?: Transport<Body, Prepared, unknown>
|
||||
readonly endpoint?: EndpointPatch<Body>
|
||||
@@ -290,16 +289,11 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
defaults: routeInput.defaults ?? {},
|
||||
body: protocol.body,
|
||||
with: (patch: RoutePatch<Body, Prepared>) => {
|
||||
const { id, provider, providerMetadataKey, auth, transport, endpoint, ...defaults } = patch
|
||||
const { id, provider, auth, transport, endpoint, ...defaults } = patch
|
||||
return build({
|
||||
...routeInput,
|
||||
id: id ?? routeInput.id,
|
||||
provider: provider ?? routeInput.provider,
|
||||
providerMetadataKey:
|
||||
providerMetadataKey ??
|
||||
(provider !== undefined && String(provider) !== String(routeInput.provider)
|
||||
? String(provider)
|
||||
: routeInput.providerMetadataKey),
|
||||
auth: auth ?? routeInput.auth,
|
||||
endpoint: endpoint ? Endpoint.merge(routeInput.endpoint, endpoint) : routeInput.endpoint,
|
||||
transport: (transport as Transport<Body, Prepared, Frame> | undefined) ?? routeInput.transport,
|
||||
|
||||
@@ -40,6 +40,17 @@ const headerDetails = (headers: Headers.Headers) =>
|
||||
const normalizedHeaders = (headers: Headers.Headers) =>
|
||||
Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]))
|
||||
|
||||
const requestId = (headers: Record<string, string>) => {
|
||||
return (
|
||||
headers["x-request-id"] ??
|
||||
headers["request-id"] ??
|
||||
headers["x-amzn-requestid"] ??
|
||||
headers["x-amz-request-id"] ??
|
||||
headers["x-goog-request-id"] ??
|
||||
headers["cf-ray"]
|
||||
)
|
||||
}
|
||||
|
||||
const retryAfterMs = (headers: Record<string, string>) => {
|
||||
const millis = Number(headers["retry-after-ms"])
|
||||
if (Number.isFinite(millis)) return Math.max(0, millis)
|
||||
@@ -136,12 +147,14 @@ const responseHttp = (input: {
|
||||
readonly request: HttpClientRequest.HttpClientRequest
|
||||
readonly response: HttpClientResponse.HttpClientResponse
|
||||
readonly body: ReturnType<typeof responseBody>
|
||||
readonly requestId?: string | undefined
|
||||
readonly rateLimit?: HttpRateLimitDetails | undefined
|
||||
}) =>
|
||||
new HttpContext({
|
||||
request: requestDetails(input.request),
|
||||
response: responseDetails(input.response),
|
||||
...input.body,
|
||||
requestId: input.requestId,
|
||||
rateLimit: input.rateLimit,
|
||||
})
|
||||
|
||||
@@ -166,6 +179,7 @@ const statusError =
|
||||
request,
|
||||
response,
|
||||
body: details,
|
||||
requestId: requestId(headers),
|
||||
rateLimit,
|
||||
}),
|
||||
}),
|
||||
@@ -202,6 +216,7 @@ export const classifyHttpFailure = (input: {
|
||||
? undefined
|
||||
: new HttpResponseDetails({ status: input.status, headers: headerDetails(Headers.fromInput(headers)) }),
|
||||
...details,
|
||||
requestId: requestId(headers),
|
||||
rateLimit,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -29,6 +29,7 @@ export class HttpContext extends Schema.Class<HttpContext>("AI.HttpContext")({
|
||||
response: Schema.optional(HttpResponseDetails),
|
||||
body: Schema.optional(Schema.String),
|
||||
bodyTruncated: Schema.optional(Schema.Boolean),
|
||||
requestId: Schema.optional(Schema.String),
|
||||
rateLimit: Schema.optional(HttpRateLimitDetails),
|
||||
}) {}
|
||||
|
||||
|
||||
@@ -327,6 +327,7 @@ describe("RequestExecutor", () => {
|
||||
retryAfterMs: 0,
|
||||
rateLimit: { retryAfterMs: 0 },
|
||||
http: {
|
||||
requestId: "req_123",
|
||||
request: {
|
||||
method: "POST",
|
||||
url: "https://provider.test/v1/chat?api_key=secret&key=secret&debug=1",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { CacheHint, LLM, AIError, LLMRequest, Message, ToolCallPart, ToolDefinition, Usage } from "../../src/index.js"
|
||||
import { Auth, Endpoint, LLMClient, Route } from "../../src/route.js"
|
||||
import { Auth, LLMClient } from "../../src/route.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import * as AnthropicMessages from "../../src/protocols/anthropic-messages.js"
|
||||
import { GoogleVertexMessages } from "../../src/providers.js"
|
||||
@@ -810,99 +810,6 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("round-trips compatible provider metadata in its own namespace", () =>
|
||||
Effect.gen(function* () {
|
||||
const compatible = Route.make({
|
||||
id: "custom-anthropic-messages",
|
||||
provider: "custom-anthropic",
|
||||
protocol: AnthropicMessages.protocol,
|
||||
endpoint: Endpoint.path("/messages", { baseURL: "https://compatible.test/v1" }),
|
||||
auth: Auth.header("x-api-key", "test"),
|
||||
framing: AnthropicMessages.framing,
|
||||
}).model({ id: "custom-model" })
|
||||
const result = [
|
||||
{
|
||||
type: "web_search_result",
|
||||
url: "https://example.com",
|
||||
citations: [{ type: "web_search_result_location", cited_text: "Example" }],
|
||||
},
|
||||
]
|
||||
const response = yield* LLMClient.generate(LLM.request({ model: compatible, prompt: "Search." })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5, custom_start: true } } },
|
||||
{ type: "content_block_start", index: 0, content_block: { type: "thinking", thinking: "Thinking." } },
|
||||
{ type: "content_block_delta", index: 0, delta: { type: "signature_delta", signature: "custom_sig" } },
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 1,
|
||||
content_block: { type: "redacted_thinking", data: "custom_redacted" },
|
||||
},
|
||||
{ type: "content_block_stop", index: 1 },
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 2,
|
||||
content_block: {
|
||||
type: "server_tool_use",
|
||||
id: "custom_tool",
|
||||
name: "web_search",
|
||||
input: { query: "example" },
|
||||
},
|
||||
},
|
||||
{ type: "content_block_stop", index: 2 },
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 3,
|
||||
content_block: { type: "web_search_tool_result", tool_use_id: "custom_tool", content: result },
|
||||
},
|
||||
{ type: "content_block_stop", index: 3 },
|
||||
{
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: "end_turn", stop_sequence: "custom_stop" },
|
||||
usage: { output_tokens: 2, custom_terminal: true },
|
||||
},
|
||||
{ type: "message_stop" },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.message.content).toMatchObject([
|
||||
{ type: "reasoning", text: "Thinking.", providerMetadata: { "custom-anthropic": { signature: "custom_sig" } } },
|
||||
{ type: "reasoning", text: "", providerMetadata: { "custom-anthropic": { redactedData: "custom_redacted" } } },
|
||||
{ type: "tool-call", id: "custom_tool", providerExecuted: true },
|
||||
{
|
||||
type: "tool-result",
|
||||
providerExecuted: true,
|
||||
providerMetadata: { "custom-anthropic": { blockType: "web_search_tool_result", result } },
|
||||
},
|
||||
])
|
||||
expect(response.usage?.providerMetadata).toEqual({
|
||||
"custom-anthropic": { input_tokens: 5, custom_start: true, output_tokens: 2, custom_terminal: true },
|
||||
})
|
||||
expect(response.events.at(-1)).toMatchObject({
|
||||
providerMetadata: { "custom-anthropic": { stopSequence: "custom_stop" } },
|
||||
})
|
||||
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({ model: compatible, messages: [response.message], cache: "none" }),
|
||||
)
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "Thinking.", signature: "custom_sig" },
|
||||
{ type: "redacted_thinking", data: "custom_redacted" },
|
||||
{ type: "server_tool_use", id: "custom_tool", name: "web_search", input: { query: "example" } },
|
||||
{ type: "web_search_tool_result", tool_use_id: "custom_tool", content: result },
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses text, reasoning, and usage stream fixtures", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
|
||||
@@ -569,57 +569,6 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("round-trips reassigned provider reasoning and usage metadata in its own namespace", () =>
|
||||
Effect.gen(function* () {
|
||||
const compatible = model.route.with({ provider: "custom-bedrock" }).model({ id: model.id })
|
||||
const redactedData = "cmVkYWN0ZWQtdGhpbmtpbmc="
|
||||
const response = yield* LLMClient.generate(LLMRequest.update(baseRequest, { model: compatible })).pipe(
|
||||
Effect.provide(
|
||||
fixedBytes(
|
||||
eventStreamBody(
|
||||
["messageStart", { role: "assistant" }],
|
||||
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { text: "Let me think." } } }],
|
||||
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { signature: "custom_sig" } } }],
|
||||
["contentBlockStop", { contentBlockIndex: 0 }],
|
||||
[
|
||||
"contentBlockDelta",
|
||||
{ contentBlockIndex: 1, delta: { reasoningContent: { redactedContent: redactedData } } },
|
||||
],
|
||||
["contentBlockStop", { contentBlockIndex: 1 }],
|
||||
["messageStop", { stopReason: "end_turn" }],
|
||||
["metadata", { usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 } }],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.message.content).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Let me think.",
|
||||
providerMetadata: { "custom-bedrock": { signature: "custom_sig" } },
|
||||
},
|
||||
{ type: "reasoning", text: "", providerMetadata: { "custom-bedrock": { redactedData } } },
|
||||
])
|
||||
expect(response.usage?.providerMetadata).toEqual({
|
||||
"custom-bedrock": { inputTokens: 5, outputTokens: 2, totalTokens: 7 },
|
||||
})
|
||||
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({ model: compatible, messages: [response.message], cache: "none" }),
|
||||
)
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ reasoningContent: { reasoningText: { text: "Let me think.", signature: "custom_sig" } } },
|
||||
{ reasoningContent: { redactedContent: redactedData } },
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves reasoning signatures when contentBlockStop is missing", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(baseRequest).pipe(
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Effect } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, Message } from "../../src/index.js"
|
||||
import { AmazonBedrockMantle } from "../../src/providers.js"
|
||||
import { model } from "../../src/providers/amazon-bedrock/mantle.js"
|
||||
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
|
||||
import { compileRequest, LLMClient } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
@@ -18,16 +17,13 @@ const credentials = {
|
||||
}
|
||||
|
||||
describe("Amazon Bedrock Mantle provider", () => {
|
||||
it.effect("uses Responses by default and exposes Chat explicitly", () =>
|
||||
it.effect("uses Chat by default and exposes Responses", () =>
|
||||
Effect.gen(function* () {
|
||||
const provider = AmazonBedrockMantle.configure({ credentials })
|
||||
expect(provider.model).toBe(provider.responses)
|
||||
expect(AmazonBedrockMantle.model).toBe(AmazonBedrockMantle.responsesModel)
|
||||
expect(model).toBe(AmazonBedrockMantle.responsesModel)
|
||||
expect(provider.model("openai.gpt-oss-120b").route.transport).toBe(OpenAIResponses.httpTransport)
|
||||
const chat = yield* compileRequest(LLM.request({ model: provider.chat("openai.gpt-oss-120b"), prompt: "Hi" }))
|
||||
expect(provider.responses("openai.gpt-oss-120b").route.transport).toBe(OpenAIResponses.httpTransport)
|
||||
const chat = yield* compileRequest(LLM.request({ model: provider.model("openai.gpt-oss-120b"), prompt: "Hi" }))
|
||||
const responses = yield* compileRequest(
|
||||
LLM.request({ model: provider.model("openai.gpt-oss-120b"), prompt: "Hi" }),
|
||||
LLM.request({ model: provider.responses("openai.gpt-oss-120b"), prompt: "Hi" }),
|
||||
)
|
||||
|
||||
expect(chat).toMatchObject({
|
||||
@@ -40,23 +36,6 @@ describe("Amazon Bedrock Mantle provider", () => {
|
||||
protocol: "openai-responses",
|
||||
body: { model: "openai.gpt-oss-120b", store: false },
|
||||
})
|
||||
expect(provider.model("openai.gpt-oss-120b").route.providerMetadataKey).toBe("mantle")
|
||||
expect(provider.chat("openai.gpt-oss-120b").route.providerMetadataKey).toBe("mantle")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves configured top-p generation defaults for Chat and Responses", () =>
|
||||
Effect.gen(function* () {
|
||||
const settings = { apiKey: "test-key", topP: 0.8 }
|
||||
const chat = yield* compileRequest(
|
||||
LLM.request({ model: AmazonBedrockMantle.chatModel("openai.gpt-oss-safeguard-20b", settings), prompt: "Hi" }),
|
||||
)
|
||||
const responses = yield* compileRequest(
|
||||
LLM.request({ model: AmazonBedrockMantle.responsesModel("openai.gpt-oss-120b", settings), prompt: "Hi" }),
|
||||
)
|
||||
|
||||
expect(chat.body.top_p).toBe(0.8)
|
||||
expect(responses.body.top_p).toBe(0.8)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -128,9 +107,6 @@ describe("Amazon Bedrock Mantle provider", () => {
|
||||
LLM.request({ model, messages: [response.message, Message.user("Continue.")] }),
|
||||
)
|
||||
|
||||
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||
mantle: { itemId: "msg_95d4d0af4350432a", reasoningEncryptedContent: "mantle-state" },
|
||||
})
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
|
||||
@@ -126,7 +126,7 @@ describe("Cloudflare", () => {
|
||||
expect(response.reasoning).toBe("Thinking")
|
||||
expect(response.events.filter(LLMEvent.is.reasoningDelta)).toHaveLength(2)
|
||||
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||
"cloudflare-ai-gateway": { reasoningField: "reasoning", reasoningDetails: merged },
|
||||
openai: { reasoningField: "reasoning", reasoningDetails: merged },
|
||||
})
|
||||
|
||||
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
|
||||
|
||||
@@ -6,7 +6,7 @@ import { GoogleVertex, GoogleVertexChat, GoogleVertexMessages, GoogleVertexRespo
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { dynamicResponse, fixedResponse } from "../lib/http.js"
|
||||
import { dynamicResponse } from "../lib/http.js"
|
||||
import { deltaChunk, finishChunk } from "../lib/openai-chunks.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
@@ -89,7 +89,7 @@ describe("Google Vertex providers", () => {
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
input: { query: "weather" },
|
||||
providerMetadata: { vertex: { functionCallId: "provider_call_1" } },
|
||||
providerMetadata: { google: { functionCallId: "provider_call_1" } },
|
||||
}),
|
||||
]),
|
||||
Message.tool({
|
||||
@@ -97,7 +97,7 @@ describe("Google Vertex providers", () => {
|
||||
name: "lookup",
|
||||
result: "sunny",
|
||||
resultType: "text",
|
||||
providerMetadata: { vertex: { functionCallId: "provider_call_1" } },
|
||||
providerMetadata: { google: { functionCallId: "provider_call_1" } },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
@@ -122,91 +122,6 @@ describe("Google Vertex providers", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("round-trips Vertex Gemini metadata through signed content, tool calls, and usage", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = GoogleVertex.configure({
|
||||
accessToken: "vertex-token",
|
||||
project: "vertex-project",
|
||||
}).model("gemini-3.5-flash")
|
||||
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Check the weather." })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: "model",
|
||||
parts: [
|
||||
{ text: "Thinking.", thought: true, thoughtSignature: "reasoning_sig" },
|
||||
{ text: "Checking.", thoughtSignature: "text_sig" },
|
||||
{
|
||||
functionCall: { id: "provider_call_1", name: "lookup", args: { query: "weather" } },
|
||||
thoughtSignature: "tool_sig",
|
||||
},
|
||||
],
|
||||
},
|
||||
finishReason: "STOP",
|
||||
},
|
||||
],
|
||||
promptFeedback: { blockReasonMessage: "Reviewed" },
|
||||
usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 2, thoughtsTokenCount: 1 },
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
const reasoning = response.events.find((event) => event.type === "reasoning-end")
|
||||
const text = response.events.find((event) => event.type === "text-delta")
|
||||
const toolCall = response.toolCalls[0]
|
||||
|
||||
expect(reasoning?.providerMetadata).toEqual({ vertex: { thoughtSignature: "reasoning_sig" } })
|
||||
expect(text?.providerMetadata).toEqual({ vertex: { thoughtSignature: "text_sig" } })
|
||||
expect(toolCall).toMatchObject({
|
||||
id: "provider_call_1",
|
||||
providerMetadata: { vertex: { thoughtSignature: "tool_sig" } },
|
||||
})
|
||||
expect(response.usage?.providerMetadata).toEqual({
|
||||
vertex: { promptTokenCount: 5, candidatesTokenCount: 2, thoughtsTokenCount: 1 },
|
||||
})
|
||||
expect(response.events.at(-1)?.providerMetadata).toEqual({
|
||||
vertex: { promptFeedback: { blockReasonMessage: "Reviewed" } },
|
||||
})
|
||||
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
{ type: "reasoning", text: "Thinking.", providerMetadata: reasoning?.providerMetadata },
|
||||
{ type: "text", text: "Checking.", providerMetadata: text?.providerMetadata },
|
||||
ToolCallPart.make({
|
||||
id: toolCall.id,
|
||||
name: toolCall.name,
|
||||
input: toolCall.input,
|
||||
providerMetadata: toolCall.providerMetadata,
|
||||
}),
|
||||
]),
|
||||
Message.tool({ id: toolCall.id, name: toolCall.name, result: "sunny", resultType: "text" }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.contents).toEqual([
|
||||
{
|
||||
role: "model",
|
||||
parts: [
|
||||
{ text: "Thinking.", thought: true, thoughtSignature: "reasoning_sig" },
|
||||
{ text: "Checking.", thoughtSignature: "text_sig" },
|
||||
{ functionCall: { name: "lookup", args: { query: "weather" } }, thoughtSignature: "tool_sig" },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
parts: [{ functionResponse: { name: "lookup", response: { name: "lookup", content: "sunny" } } }],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("projects Anthropic Messages onto the Vertex raw-predict API", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = GoogleVertexMessages.configure({
|
||||
|
||||
@@ -2,80 +2,13 @@ import { describe, expect } from "bun:test"
|
||||
import { ConfigProvider, Effect } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, Message, ToolDefinition } from "../../src/index.js"
|
||||
import {
|
||||
AmazonBedrock,
|
||||
AmazonBedrockMantle,
|
||||
Anthropic,
|
||||
AnthropicCompatible,
|
||||
Azure,
|
||||
Cerebras,
|
||||
CloudflareAIGateway,
|
||||
CloudflareWorkersAI,
|
||||
DeepInfra,
|
||||
Google,
|
||||
GoogleVertex,
|
||||
GoogleVertexChat,
|
||||
GoogleVertexMessages,
|
||||
GoogleVertexResponses,
|
||||
Groq,
|
||||
OpenAI,
|
||||
OpenAICompatible,
|
||||
OpenAICompatibleResponses,
|
||||
OpenRouter,
|
||||
TogetherAI,
|
||||
XAI,
|
||||
} from "../../src/providers/index.js"
|
||||
import { Cerebras, DeepInfra, Groq, TogetherAI } from "../../src/providers/index.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { dynamicResponse } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
describe("native OpenAI-compatible providers", () => {
|
||||
it.effect("assigns provider-owned metadata namespaces across native routes", () =>
|
||||
Effect.gen(function* () {
|
||||
const vertex = { project: "project", accessToken: "token" }
|
||||
const providers = [
|
||||
[OpenAI.configure({ apiKey: "test" }).chat("model"), "openai"],
|
||||
[OpenAI.configure({ apiKey: "test" }).responses("model"), "openai"],
|
||||
[Azure.configure({ resourceName: "resource", apiKey: "test" }).chat("model"), "azure"],
|
||||
[Azure.configure({ resourceName: "resource", apiKey: "test" }).responses("model"), "azure"],
|
||||
[AmazonBedrock.configure({ apiKey: "test" }).model("model"), "bedrock"],
|
||||
[AmazonBedrockMantle.configure({ apiKey: "test" }).chat("model"), "mantle"],
|
||||
[AmazonBedrockMantle.configure({ apiKey: "test" }).responses("model"), "mantle"],
|
||||
[Google.configure({ apiKey: "test" }).model("model"), "google"],
|
||||
[GoogleVertex.configure(vertex).model("model"), "vertex"],
|
||||
[GoogleVertexChat.configure(vertex).model("model"), "vertex"],
|
||||
[GoogleVertexResponses.configure(vertex).model("model"), "vertex"],
|
||||
[GoogleVertexMessages.configure(vertex).model("model"), "anthropic"],
|
||||
[Anthropic.configure({ apiKey: "test" }).model("model"), "anthropic"],
|
||||
[
|
||||
AnthropicCompatible.configure({ baseURL: "https://example.test/v1", provider: "minimax" }).model("model"),
|
||||
"minimax",
|
||||
],
|
||||
[
|
||||
OpenAICompatible.configure({ baseURL: "https://example.test/v1", provider: "custom" }).model("model"),
|
||||
"custom",
|
||||
],
|
||||
[
|
||||
OpenAICompatibleResponses.configure({ baseURL: "https://example.test/v1", provider: "custom" }).model(
|
||||
"model",
|
||||
),
|
||||
"custom",
|
||||
],
|
||||
[Cerebras.configure({ apiKey: "test" }).model("model"), "cerebras"],
|
||||
[DeepInfra.configure({ apiKey: "test" }).model("model"), "deepinfra"],
|
||||
[TogetherAI.configure({ apiKey: "test" }).model("model"), "togetherai"],
|
||||
[CloudflareAIGateway.configure({ accountId: "account" }).model("model"), "cloudflare-ai-gateway"],
|
||||
[CloudflareWorkersAI.configure({ accountId: "account" }).model("model"), "cloudflare-workers-ai"],
|
||||
[OpenRouter.configure({ apiKey: "test" }).model("model"), "openrouter"],
|
||||
[XAI.configure({ apiKey: "test" }).chat("model"), "xai"],
|
||||
[XAI.configure({ apiKey: "test" }).responses("model"), "xai"],
|
||||
] as const
|
||||
|
||||
for (const [model, key] of providers) expect(model.route.providerMetadataKey).toBe(key)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves native Together AI and Cerebras provider and route identities", () =>
|
||||
Effect.gen(function* () {
|
||||
const together = TogetherAI.configure({ apiKey: "fixture" }).model("meta-llama/Llama-3.3-70B")
|
||||
|
||||
@@ -68,13 +68,11 @@ for (const item of cases) {
|
||||
expect(response.text.replaceAll(",", "").trim()).toBe("37887")
|
||||
expect(response.reasoning.length).toBeGreaterThan(0)
|
||||
expect(response.events.some(LLMEvent.is.reasoningDelta)).toBe(true)
|
||||
const metadata = response.message.content.find((part) => part.type === "reasoning")?.providerMetadata?.[
|
||||
item.model.route.providerMetadataKey ?? String(item.model.provider)
|
||||
]
|
||||
expect(metadata?.reasoningField).toBe(item.structured ? "reasoning" : "reasoning_content")
|
||||
expect(Array.isArray(metadata?.reasoningDetails)).toBe(item.structured)
|
||||
const metadata = response.message.content.find((part) => part.type === "reasoning")?.providerMetadata
|
||||
expect(metadata?.openai?.reasoningField).toBe(item.structured ? "reasoning" : "reasoning_content")
|
||||
expect(Array.isArray(metadata?.openai?.reasoningDetails)).toBe(item.structured)
|
||||
if (!item.structured) return
|
||||
const details = metadata?.reasoningDetails
|
||||
const details = metadata?.openai?.reasoningDetails
|
||||
if (!Array.isArray(details)) return
|
||||
expect(
|
||||
details.some(
|
||||
@@ -128,11 +126,7 @@ for (const item of cases) {
|
||||
).toMatch(/^Paris is sunny\.?$/)
|
||||
const details = events
|
||||
.filter(LLMEvent.is.reasoningEnd)
|
||||
.map(
|
||||
(event) =>
|
||||
event.providerMetadata?.[item.model.route.providerMetadataKey ?? String(item.model.provider)]
|
||||
?.reasoningDetails,
|
||||
)
|
||||
.map((event) => event.providerMetadata?.openai?.reasoningDetails)
|
||||
.find(Array.isArray)
|
||||
expect(Array.isArray(details)).toBe(item.structured)
|
||||
if (!item.structured || !Array.isArray(details)) return
|
||||
|
||||
@@ -903,70 +903,6 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses the configured provider metadata namespace for reasoning and usage", () =>
|
||||
Effect.gen(function* () {
|
||||
const selected = LanguageModel.update(model, {
|
||||
route: { ...model.route, providerMetadataKey: "vendor" },
|
||||
})
|
||||
const details = [{ type: "reasoning.text", text: "thinking", signature: "signed" }]
|
||||
const response = yield* LLMClient.generate(LLMRequest.update(request, { model: selected })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ choices: [{ delta: { reasoning: "thinking", reasoning_details: details } }] },
|
||||
deltaChunk({ content: "Hello" }),
|
||||
deltaChunk({}, "stop"),
|
||||
usageChunk({ prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 }),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||
vendor: { reasoningField: "reasoning", reasoningDetails: details },
|
||||
})
|
||||
expect(response.usage?.providerMetadata).toEqual({
|
||||
vendor: { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 },
|
||||
})
|
||||
|
||||
const replay = yield* compileRequest(LLM.request({ model: selected, messages: [response.message] }))
|
||||
expect(replay.body.messages).toEqual([
|
||||
{ role: "assistant", content: "Hello", reasoning: "thinking", reasoning_details: details },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("falls back to the selected provider for the metadata namespace", () =>
|
||||
Effect.gen(function* () {
|
||||
const compatible = model.route.with({ provider: "deepseek" }).model({ id: "deepseek-chat" })
|
||||
const selected = LanguageModel.update(compatible, {
|
||||
route: { ...compatible.route, providerMetadataKey: undefined },
|
||||
})
|
||||
const response = yield* LLMClient.generate(LLMRequest.update(request, { model: selected })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
deltaChunk({ reasoning_content: "thinking" }),
|
||||
deltaChunk({ content: "Hello" }),
|
||||
deltaChunk({}, "stop"),
|
||||
usageChunk({ prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 }),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||
deepseek: { reasoningField: "reasoning_content" },
|
||||
})
|
||||
expect(response.usage?.providerMetadata).toEqual({
|
||||
deepseek: { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 },
|
||||
})
|
||||
|
||||
const replay = yield* compileRequest(LLM.request({ model: selected, messages: [response.message] }))
|
||||
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_content: "thinking" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses and replays a configured custom reasoning field", () =>
|
||||
Effect.gen(function* () {
|
||||
const custom = LanguageModel.update(model, { compatibility: { reasoningField: "vendor_reasoning" } })
|
||||
|
||||
@@ -437,7 +437,7 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
outputTokens: undefined,
|
||||
totalTokens: undefined,
|
||||
providerMetadata: {
|
||||
deepseek: {
|
||||
openai: {
|
||||
prompt_tokens: null,
|
||||
completion_tokens: null,
|
||||
total_tokens: null,
|
||||
|
||||
@@ -195,19 +195,19 @@ describe("Open Responses-compatible route", () => {
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
{ type: "text", text: "Kept.", providerMetadata: { "openai-compatible": { itemId: "history_1" } } },
|
||||
{ type: "text", text: "Kept.", providerMetadata: { openresponses: { itemId: "history_1" } } },
|
||||
{
|
||||
type: "text",
|
||||
text: "Long.",
|
||||
providerMetadata: { "openai-compatible": { itemId: `history_${"a".repeat(64)}` } },
|
||||
providerMetadata: { openresponses: { itemId: `history_${"a".repeat(64)}` } },
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "Opaque.",
|
||||
providerMetadata: { "openai-compatible": { itemId: "provider_value/with+symbols" } },
|
||||
providerMetadata: { openresponses: { itemId: "provider_value/with+symbols" } },
|
||||
},
|
||||
{ type: "text", text: "No suffix.", providerMetadata: { "openai-compatible": { itemId: "msg_" } } },
|
||||
{ type: "text", text: "No prefix.", providerMetadata: { "openai-compatible": { itemId: "_item" } } },
|
||||
{ type: "text", text: "No suffix.", providerMetadata: { openresponses: { itemId: "msg_" } } },
|
||||
{ type: "text", text: "No prefix.", providerMetadata: { openresponses: { itemId: "_item" } } },
|
||||
]),
|
||||
],
|
||||
}),
|
||||
@@ -267,7 +267,7 @@ describe("Open Responses-compatible route", () => {
|
||||
name: item.type,
|
||||
result: { type: "json", value: item },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { example: { itemId: item.id } },
|
||||
providerMetadata: { openresponses: { itemId: item.id } },
|
||||
}),
|
||||
),
|
||||
}),
|
||||
@@ -302,7 +302,7 @@ describe("Open Responses-compatible route", () => {
|
||||
)
|
||||
|
||||
expect(response.message.content).toEqual([
|
||||
{ type: "text", text: "Indexed", providerMetadata: { "openai-compatible": { itemId: "msg_1" } } },
|
||||
{ type: "text", text: "Indexed", providerMetadata: { openresponses: { itemId: "msg_1" } } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -368,7 +368,7 @@ describe("Open Responses-compatible route", () => {
|
||||
),
|
||||
)
|
||||
|
||||
const metadata = { "openai-compatible": { itemId: routing.id } }
|
||||
const metadata = { openresponses: { itemId: routing.id } }
|
||||
if (fixture.item.type === "function_call") {
|
||||
expect(response.toolCalls).toEqual([
|
||||
expect.objectContaining({
|
||||
@@ -386,7 +386,7 @@ describe("Open Responses-compatible route", () => {
|
||||
type: "reasoning",
|
||||
text: "Preserved",
|
||||
providerMetadata: {
|
||||
"openai-compatible": { itemId: routing.id, reasoningEncryptedContent: "encrypted-state" },
|
||||
openresponses: { itemId: routing.id, reasoningEncryptedContent: "encrypted-state" },
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -438,26 +438,22 @@ describe("Open Responses-compatible route", () => {
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "First.",
|
||||
providerMetadata: { "openai-compatible": { itemId: routing.id } },
|
||||
providerMetadata: { openresponses: { itemId: routing.id } },
|
||||
},
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Second.",
|
||||
providerMetadata: {
|
||||
"openai-compatible": { itemId: routing.id, reasoningEncryptedContent: "final-state" },
|
||||
},
|
||||
providerMetadata: { openresponses: { itemId: routing.id, reasoningEncryptedContent: "final-state" } },
|
||||
},
|
||||
])
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toEqual([
|
||||
expect.objectContaining({
|
||||
id: `${routing.id}:0`,
|
||||
providerMetadata: { "openai-compatible": { itemId: routing.id } },
|
||||
providerMetadata: { openresponses: { itemId: routing.id } },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: `${routing.id}:1`,
|
||||
providerMetadata: {
|
||||
"openai-compatible": { itemId: routing.id, reasoningEncryptedContent: "final-state" },
|
||||
},
|
||||
providerMetadata: { openresponses: { itemId: routing.id, reasoningEncryptedContent: "final-state" } },
|
||||
}),
|
||||
])
|
||||
}),
|
||||
@@ -487,7 +483,7 @@ describe("Open Responses-compatible route", () => {
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
input: { query: "complete" },
|
||||
providerMetadata: { "openai-compatible": { itemId: "" } },
|
||||
providerMetadata: { openresponses: { itemId: "" } },
|
||||
}),
|
||||
])
|
||||
}),
|
||||
@@ -514,7 +510,7 @@ describe("Open Responses-compatible route", () => {
|
||||
)
|
||||
|
||||
expect(response.message.content).toEqual([
|
||||
{ type: "text", text: "Before after", providerMetadata: { "openai-compatible": { itemId: "msg_1" } } },
|
||||
{ type: "text", text: "Before after", providerMetadata: { openresponses: { itemId: "msg_1" } } },
|
||||
])
|
||||
expect(response.events.map((event) => event.type)).toEqual([
|
||||
"step-start",
|
||||
@@ -666,7 +662,7 @@ describe("Open Responses-compatible route", () => {
|
||||
|
||||
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
|
||||
input: { query: "complete" },
|
||||
providerMetadata: { example: { itemId: "item_1" } },
|
||||
providerMetadata: { openresponses: { itemId: "item_1" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -698,7 +694,7 @@ describe("Open Responses-compatible route", () => {
|
||||
)
|
||||
|
||||
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
|
||||
providerMetadata: { "openai-compatible": { itemId: "rs_raw", reasoningEncryptedContent: "raw-state" } },
|
||||
providerMetadata: { openresponses: { itemId: "rs_raw", reasoningEncryptedContent: "raw-state" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -747,7 +743,7 @@ describe("Open Responses-compatible route", () => {
|
||||
Message.assistant({
|
||||
type: "text",
|
||||
text: "Unclassified.",
|
||||
providerMetadata: { "openai-compatible": { phase: null } },
|
||||
providerMetadata: { openresponses: { phase: null } },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
@@ -806,7 +802,7 @@ describe("Open Responses-compatible route", () => {
|
||||
{
|
||||
type: "text",
|
||||
text: "I can't help with that.",
|
||||
providerMetadata: { example: { itemId: "msg_refusal" } },
|
||||
providerMetadata: { openresponses: { itemId: "msg_refusal" } },
|
||||
},
|
||||
])
|
||||
|
||||
@@ -895,7 +891,7 @@ describe("Open Responses-compatible route", () => {
|
||||
|
||||
expect(response.toolCalls).toEqual([])
|
||||
expect(response.events.find(LLMEvent.is.finish)).toMatchObject({
|
||||
providerMetadata: { example: { responseId: "resp_1" } },
|
||||
providerMetadata: { openresponses: { responseId: "resp_1" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -295,7 +295,7 @@ describe("OpenRouter", () => {
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Thinking",
|
||||
providerMetadata: { openrouter: { reasoningField: "reasoning", reasoningDetails: details } },
|
||||
providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
|
||||
},
|
||||
]),
|
||||
],
|
||||
@@ -328,7 +328,7 @@ describe("OpenRouter", () => {
|
||||
Message.assistant({
|
||||
type: "reasoning",
|
||||
text: "Thinking",
|
||||
providerMetadata: { openrouter: { reasoningField: "reasoning", reasoningDetails: details } },
|
||||
providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
@@ -354,7 +354,7 @@ describe("OpenRouter", () => {
|
||||
Message.assistant({
|
||||
type: "reasoning",
|
||||
text: "AB",
|
||||
providerMetadata: { openrouter: { reasoningField: "reasoning", reasoningDetails: details } },
|
||||
providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -40,13 +40,4 @@ describe("Route.with", () => {
|
||||
"x-patch": "patch",
|
||||
})
|
||||
})
|
||||
|
||||
test("assigns metadata ownership to a replacement provider and preserves explicit overrides", () => {
|
||||
const route = OpenAIChat.route.with({ provider: "azure" })
|
||||
const overridden = route.with({ providerMetadataKey: "custom-azure" }).with({ headers: { "x-test": "value" } })
|
||||
|
||||
expect(route.providerMetadataKey).toBe("azure")
|
||||
expect(overridden.providerMetadataKey).toBe("custom-azure")
|
||||
expect(overridden.defaults).not.toHaveProperty("providerMetadataKey")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
src/assets/theme.css
|
||||
e2e/test-results
|
||||
e2e/playwright-report
|
||||
component-tests/test-results
|
||||
component-tests/playwright-report
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
// Moved from packages/app/e2e/regression/prompt-thinking-level.spec.ts
|
||||
story("shows the thinking level control while relevant", async ({ mount, page }) => {
|
||||
const component = await mount("opencode-composer-flow--model-and-variant")
|
||||
const composer = component.locator('[data-component="composer"]')
|
||||
const input = composer.locator('[data-component="composer-editor"]')
|
||||
const control = composer.getByRole("button", { name: "Choose model variant" })
|
||||
|
||||
await page.mouse.move(0, 0)
|
||||
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
|
||||
await expect(control).toBeVisible()
|
||||
|
||||
await control.click()
|
||||
const high = page.getByRole("menuitemradio", { name: "high" })
|
||||
await expect(high).toBeVisible()
|
||||
await page.mouse.move(0, 0)
|
||||
await expect(control).toBeVisible()
|
||||
await expect(high).toBeVisible()
|
||||
await high.click()
|
||||
|
||||
await input.focus()
|
||||
await expect(control).toBeVisible()
|
||||
await input.blur()
|
||||
await expect(control).toBeVisible()
|
||||
})
|
||||
@@ -12,6 +12,54 @@ test.beforeEach(async ({ page }) => {
|
||||
await openReview(page)
|
||||
})
|
||||
|
||||
test("opens the comment editor when code is clicked", async ({ page }) => {
|
||||
const review = page.locator('[data-component="session-review"]')
|
||||
const line = review.getByText("export const value = 'after'", { exact: true })
|
||||
await expectAppVisible(line)
|
||||
await line.click()
|
||||
|
||||
await expect(review.getByRole("textbox")).toBeVisible()
|
||||
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 2")
|
||||
})
|
||||
|
||||
test("opens the comment editor when a line number is clicked", async ({ page }) => {
|
||||
const review = page.locator('[data-component="session-review"]')
|
||||
const lineNumber = review.locator('[data-column-number="1"]').last()
|
||||
await expectAppVisible(lineNumber)
|
||||
await lineNumber.click()
|
||||
|
||||
await expect(review.getByRole("textbox")).toBeVisible()
|
||||
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
|
||||
})
|
||||
|
||||
test("opens the comment editor for a line number range", async ({ page }) => {
|
||||
const review = page.locator('[data-component="session-review"]')
|
||||
const start = review.locator('[data-column-number="1"]').last()
|
||||
const end = review.locator('[data-column-number="3"]').last()
|
||||
await expectAppVisible(start)
|
||||
await expectAppVisible(end)
|
||||
|
||||
await start.dragTo(end)
|
||||
|
||||
await expect(review.getByRole("textbox")).toBeVisible()
|
||||
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on lines 1-3")
|
||||
})
|
||||
|
||||
test("shows a comment button when a diff line is hovered", async ({ page }) => {
|
||||
const review = page.locator('[data-component="session-review"]')
|
||||
const line = review.getByText("export const first = 1", { exact: true })
|
||||
await expectAppVisible(line)
|
||||
|
||||
const comment = review.getByRole("button", { name: "Comment", exact: true, includeHidden: true })
|
||||
await expect(comment).toHaveCount(1)
|
||||
await line.dispatchEvent("pointermove", { pointerType: "mouse", bubbles: true, composed: true })
|
||||
await expect(comment).toBeVisible()
|
||||
await expect(comment).toHaveCSS("pointer-events", "auto")
|
||||
await comment.dispatchEvent("click")
|
||||
await expect(review.getByRole("textbox")).toBeVisible()
|
||||
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
|
||||
})
|
||||
|
||||
test("stages a submitted line comment in the prompt context", async ({ page }) => {
|
||||
page.on("request", (request) => {
|
||||
expect.soft(request.method(), `unexpected ${request.method()} ${new URL(request.url()).pathname}`).toBe("GET")
|
||||
|
||||
@@ -201,15 +201,12 @@ test("editing restores the existing draft and replaces only the original queue p
|
||||
await view.input.fill("my in-progress draft")
|
||||
await original.click()
|
||||
await expect(view.input).toHaveText("tighten the error copy")
|
||||
await expect(view.input).toBeFocused()
|
||||
await view.input.press("Escape")
|
||||
await expect(view.input).toHaveText("my in-progress draft")
|
||||
|
||||
await original.click()
|
||||
await expect(view.input).toHaveText("tighten the error copy")
|
||||
await expect(view.input).toBeFocused()
|
||||
await view.input.fill("tighten the error copy and add a retry hint")
|
||||
await expect(view.input).toHaveText("tighten the error copy and add a retry hint")
|
||||
await view.input.press("Enter")
|
||||
|
||||
await expect(view.rows.locator('[data-action="session-queue-edit"]')).toHaveText([
|
||||
|
||||
@@ -1,43 +1,16 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
assistantMessage,
|
||||
setupTimeline,
|
||||
shell,
|
||||
textPart,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
import { assistantMessage, setupTimeline, shell, userMessage } from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("space activates a focused timeline button instead of scrolling", async ({ page }) => {
|
||||
const shellID = "prt_space_button_shell"
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
shell(shellID, "completed", lines(5)),
|
||||
textPart(
|
||||
"prt_space_following",
|
||||
"Following content leaves room to focus the command away from the bottom. ".repeat(40),
|
||||
),
|
||||
]),
|
||||
],
|
||||
messages: [userMessage(), assistantMessage([shell(shellID, "completed", lines(5))])],
|
||||
settings: { shellToolPartsExpanded: false },
|
||||
reducedMotion: true,
|
||||
seedHistory: true,
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
const trigger = page.getByRole("button", { name: "Used Shell" })
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight))
|
||||
.toBeGreaterThan(300)
|
||||
await trigger.scrollIntoViewIfNeeded()
|
||||
await scroller.hover()
|
||||
await page.mouse.wheel(0, -100)
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
||||
.toBeGreaterThan(50)
|
||||
await expect(trigger).toBeInViewport()
|
||||
await trigger.focus()
|
||||
await expect(trigger).toBeFocused()
|
||||
const before = await scroller.evaluate((element) => element.scrollTop)
|
||||
await trigger.press("Space")
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
|
||||
@@ -8,6 +8,21 @@ import {
|
||||
userText,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("renders completed write content", async ({ page }) => {
|
||||
const id = "prt_file_projection_write"
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(id, "write", "completed", { path: "src/write.ts", content: "export const written = true\n" }),
|
||||
]),
|
||||
],
|
||||
settings: { editToolPartsExpanded: true },
|
||||
})
|
||||
|
||||
await expect(page.locator(`[data-timeline-part-id="${id}"] [data-component="write-content"]`)).toBeVisible()
|
||||
})
|
||||
|
||||
test("renders a completed single-file patch", async ({ page }) => {
|
||||
const id = "prt_file_projection_single_patch"
|
||||
await setupTimeline(page, {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
|
||||
test("keeps patch file disclosures independent", async ({ page }) => {
|
||||
const patchID = "prt_nested_patch"
|
||||
const files = [patchFile("src/a.ts", "modified"), patchFile("src/b.ts", "added"), patchFile("src/old.ts", "deleted")]
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(
|
||||
patchID,
|
||||
"patch",
|
||||
"completed",
|
||||
{ patchText: "Update three files" },
|
||||
{ metadata: { files } },
|
||||
),
|
||||
]),
|
||||
],
|
||||
settings: { editToolPartsExpanded: true },
|
||||
})
|
||||
const wrapper = page.locator(`[data-timeline-part-id="${patchID}"]`)
|
||||
const modified = wrapper.locator('[data-scope="apply-patch"] [data-type="update"]')
|
||||
const deleted = wrapper.locator('[data-scope="apply-patch"] [data-type="delete"]')
|
||||
await expect(wrapper.locator('[data-scope="apply-patch"] [aria-expanded="false"]')).toHaveCount(3)
|
||||
await deleted.getByRole("button").click()
|
||||
await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(modified.getByRole("button")).toHaveAttribute("aria-expanded", "false")
|
||||
await modified.getByRole("button").click()
|
||||
await expect(modified.getByRole("button")).toHaveAttribute("aria-expanded", "true")
|
||||
await deleted.getByRole("button").click()
|
||||
await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(modified.getByRole("button")).toHaveAttribute("aria-expanded", "true")
|
||||
})
|
||||
|
||||
function patchFile(file: string, status: "added" | "modified" | "deleted") {
|
||||
const before = status === "added" ? "" : source(false)
|
||||
const after = status === "deleted" ? "" : source(true)
|
||||
return {
|
||||
file,
|
||||
status,
|
||||
patch: createTwoFilesPatch(`a/${file}`, `b/${file}`, before, after),
|
||||
additions: status === "deleted" ? 0 : 4,
|
||||
deletions: status === "added" ? 0 : 3,
|
||||
}
|
||||
}
|
||||
|
||||
function source(changed: boolean) {
|
||||
return Array.from({ length: 12 }, (_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`).join(
|
||||
"",
|
||||
)
|
||||
}
|
||||
@@ -122,7 +122,6 @@ test("transitions thinking and hidden reasoning through busy to idle", async ({
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible()
|
||||
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(reasoningID)}"]`)).toHaveCount(0)
|
||||
await timeline.send(partUpdated(shell("prt_reasoning_shell", "running")), 160)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await timeline.send(partUpdated(shell("prt_reasoning_shell", "completed", "done")), 180)
|
||||
@@ -130,7 +129,6 @@ test("transitions thinking and hidden reasoning through busy to idle", async ({
|
||||
await timeline.send(status("idle"), 300)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(reasoningID)}"]`)).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("moves busy through retry and recovery to final idle content", async ({ page }) => {
|
||||
|
||||
@@ -122,7 +122,6 @@ test("updates running compactions to failed and cancelled boundaries", async ({
|
||||
|
||||
await timeline.send(compactionStarted({ sessionID, reason: "auto", recent: "" }))
|
||||
await timeline.send(compactionDelta({ sessionID, text: "Partial summary that should be discarded." }))
|
||||
await expect(page.getByText("Partial summary that should be discarded.", { exact: true })).toBeVisible()
|
||||
await timeline.send(
|
||||
compactionFailed({
|
||||
sessionID,
|
||||
@@ -141,9 +140,6 @@ test("updates running compactions to failed and cancelled boundaries", async ({
|
||||
await expect(failed).not.toContainText("Partial summary that should be discarded.")
|
||||
|
||||
await timeline.send(compactionStarted({ sessionID, reason: "manual", recent: "" }))
|
||||
await expect(compactions).toHaveCount(2)
|
||||
await timeline.send(compactionDelta({ sessionID, text: "Summary before cancellation." }))
|
||||
await expect(page.getByText("Summary before cancellation.", { exact: true })).toBeVisible()
|
||||
await timeline.send(
|
||||
compactionFailed({
|
||||
sessionID,
|
||||
@@ -156,7 +152,88 @@ test("updates running compactions to failed and cancelled boundaries", async ({
|
||||
const cancelled = compactions.filter({ hasNotText: "The provider rejected the summary." })
|
||||
await expect(cancelled.getByText("Session compacted", { exact: true })).toBeVisible()
|
||||
await expect(cancelled).not.toContainText("Cancellation detail should stay hidden.")
|
||||
await expect(cancelled).not.toContainText("Summary before cancellation.")
|
||||
})
|
||||
|
||||
test("shows a delegating row while subagent input streams", async ({ page }) => {
|
||||
await setupTimeline(page, {
|
||||
sessionMessages: [
|
||||
user,
|
||||
{
|
||||
...assistant(false),
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_subagent",
|
||||
name: "subagent",
|
||||
state: { status: "streaming", input: "" },
|
||||
time: { created: 2 },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const delegating = page.locator('[data-component="task-tool-delegating"]')
|
||||
await expect(delegating).toBeVisible()
|
||||
const shimmer = delegating.locator('[data-component="text-shimmer"]')
|
||||
await expect(shimmer).toHaveAttribute("aria-label", "Delegating agent...")
|
||||
await expect(shimmer).toHaveCSS("line-height", "16px")
|
||||
const icon = delegating.locator('[data-slot="icon-svg"]')
|
||||
await expect(icon.locator('use[href="#opencode-v2-icon-subagent"]')).toBeVisible()
|
||||
await expect(icon).toHaveCSS("color", "rgb(174, 174, 174)")
|
||||
await expect(page.locator('[data-component="task-tool-card"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("renders the moved location notice in its compact timeline style", async ({ page }) => {
|
||||
const directory = `/Users/usrnk1/Developer/opencode/${"nested-directory/".repeat(24)}session`
|
||||
await page.setViewportSize({ width: 480, height: 720 })
|
||||
await setupTimeline(page, {
|
||||
sessionMessages: [
|
||||
user,
|
||||
{
|
||||
id: "msg_location",
|
||||
type: "location-switched",
|
||||
location: { directory },
|
||||
time: { created: 2 },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const notice = page.locator('[data-slot="session-timeline-notice"][data-type="location-switched"]')
|
||||
const label = notice.locator('[data-slot="session-timeline-notice-label"]')
|
||||
const value = notice.locator('[data-slot="session-timeline-notice-value"]')
|
||||
const tooltipTrigger = notice.locator('[data-component="tooltip-v2-trigger"]')
|
||||
|
||||
await expect(label).toHaveText("Moved to")
|
||||
await expect(value).toHaveText(directory)
|
||||
await expect(notice).not.toContainText("·")
|
||||
await expect(notice.locator("svg")).toHaveCount(0)
|
||||
await expect(notice).toHaveCSS("height", "28px")
|
||||
await expect(notice).toHaveCSS("gap", "8px")
|
||||
await expect(notice).toHaveCSS("padding-top", "4px")
|
||||
await expect(notice).toHaveCSS("padding-bottom", "4px")
|
||||
await expect(label).toHaveCSS("font-size", "13px")
|
||||
await expect(label).toHaveCSS("font-weight", "530")
|
||||
await expect(label).toHaveCSS("line-height", "16px")
|
||||
await expect(label).toHaveCSS("color", "rgb(128, 128, 128)")
|
||||
await expect(value).toHaveCSS("font-size", "13px")
|
||||
await expect(value).toHaveCSS("font-weight", "440")
|
||||
await expect(value).toHaveCSS("line-height", "16px")
|
||||
await expect(value).toHaveCSS("color", "rgb(128, 128, 128)")
|
||||
await expect(value).toHaveCSS("text-overflow", "ellipsis")
|
||||
await expect(value).toHaveCSS("white-space", "nowrap")
|
||||
await expect(value).toHaveAttribute("dir", "ltr")
|
||||
await expect.poll(() => value.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true)
|
||||
|
||||
const tooltip = page.getByText("Session working directory changed", { exact: true })
|
||||
await label.hover()
|
||||
await expect(tooltip).toBeVisible()
|
||||
await page.mouse.move(0, 0)
|
||||
await expect(tooltip).toBeHidden()
|
||||
await tooltipTrigger.focus()
|
||||
await expect(tooltipTrigger).toBeFocused()
|
||||
await expect(tooltip).toBeVisible()
|
||||
})
|
||||
|
||||
test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
|
||||
@@ -194,6 +271,11 @@ test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
|
||||
await request
|
||||
})
|
||||
|
||||
test("waits for completion before labeling requested background work", async ({ page }) => {
|
||||
await setupTimeline(page, { sessionMessages: [user, assistant(false, true, undefined, true)] })
|
||||
await expect(page.locator('[data-component="task-tool-card"]')).not.toContainText("(background)")
|
||||
})
|
||||
|
||||
test("navigates from a running subagent card and hides background controls in the child", async ({ page }) => {
|
||||
const childID = "ses_running_child"
|
||||
await setupTimeline(page, {
|
||||
|
||||
@@ -7,9 +7,86 @@ import {
|
||||
toolPart,
|
||||
userMessage,
|
||||
userText,
|
||||
type PartSeed,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
test.describe("session timeline projection", () => {
|
||||
test("renders every admitted tool family and hides timeline-only exclusions", async ({ page }) => {
|
||||
const parts = [
|
||||
toolPart("prt_01_read", "read", "completed", { path: "src/a.ts" }),
|
||||
toolPart("prt_02_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }),
|
||||
toolPart("prt_03_grep", "grep", "completed", { path: ".", pattern: "value" }),
|
||||
toolPart("prt_04_list", "list", "completed", { path: "src" }),
|
||||
toolPart("prt_webfetch", "webfetch", "completed", { url: "https://example.com" }),
|
||||
toolPart(
|
||||
"prt_websearch",
|
||||
"websearch",
|
||||
"completed",
|
||||
{ query: "timeline stability" },
|
||||
{ output: "https://example.com/result" },
|
||||
),
|
||||
toolPart("prt_task", "subagent", "completed", {
|
||||
description: "Inspect timeline",
|
||||
agent: "explore",
|
||||
prompt: "Inspect the timeline implementation.",
|
||||
}),
|
||||
toolPart(
|
||||
"prt_bash",
|
||||
"shell",
|
||||
"completed",
|
||||
{ command: "printf stable" },
|
||||
{ output: "stable", title: "printf stable" },
|
||||
),
|
||||
editPart("prt_edit"),
|
||||
toolPart("prt_write", "write", "completed", { path: "src/new.ts", content: "export const stable = true\n" }),
|
||||
patchPart("prt_patch"),
|
||||
toolPart("prt_todo", "todowrite", "completed", { todos: [{ content: "Hidden", status: "pending" }] }),
|
||||
toolPart(
|
||||
"prt_question",
|
||||
"question",
|
||||
"completed",
|
||||
{ questions: [{ question: "Keep stable?", header: "Stability", options: [] }] },
|
||||
{ metadata: { answers: [["Yes"]] } },
|
||||
),
|
||||
toolPart("prt_skill", "skill", "completed", { name: "stability" }),
|
||||
toolPart("prt_custom", "custom_mcp_tool", "completed", { target: "timeline", count: 2 }),
|
||||
]
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
||||
|
||||
const first = page.locator(
|
||||
'[data-timeline-part-ids="prt_01_read,prt_02_glob,prt_03_grep,prt_04_list,prt_webfetch,prt_websearch,prt_task,prt_bash,prt_edit,prt_write,prt_patch"]',
|
||||
)
|
||||
const second = page.locator('[data-timeline-part-ids="prt_skill,prt_custom"]')
|
||||
await expect(first).toBeVisible()
|
||||
await expect(second).toBeVisible()
|
||||
await first.getByRole("button").click()
|
||||
await second.getByRole("button").click()
|
||||
for (const id of [
|
||||
"prt_webfetch",
|
||||
"prt_websearch",
|
||||
"prt_task",
|
||||
"prt_bash",
|
||||
"prt_edit",
|
||||
"prt_write",
|
||||
"prt_patch",
|
||||
"prt_question",
|
||||
"prt_skill",
|
||||
"prt_custom",
|
||||
]) {
|
||||
await expect(page.locator(`[data-timeline-part-id="${id}"]`).first(), id).toBeVisible()
|
||||
}
|
||||
const patch = page.locator('[data-timeline-part-id="prt_patch"]')
|
||||
await expect(patch.getByText("1 file", { exact: true })).toBeVisible()
|
||||
await expect(patch.getByRole("button", { name: "Patch 1 file", exact: true })).toHaveCount(0)
|
||||
await expect(patch.getByRole("button")).toHaveCount(1)
|
||||
await expect(patch.locator('[data-scope="apply-patch"] button[aria-expanded="false"]')).toHaveCount(1)
|
||||
await expect(patch.locator('[data-slot="message-part-title-filename"]')).toHaveCount(0)
|
||||
await expect(patch.locator('[data-slot="message-part-actions"]')).toHaveCount(0)
|
||||
const edit = page.locator('[data-timeline-part-id="prt_edit"]')
|
||||
await expect(edit).toContainText("Edit")
|
||||
await expect(page.locator('[data-timeline-part-id="prt_todo"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("combines adjacent patch calls and repeated files into one group", async ({ page }) => {
|
||||
const first = "prt_patch_first"
|
||||
const second = "prt_patch_second"
|
||||
@@ -81,6 +158,43 @@ test.describe("session timeline projection", () => {
|
||||
await expect(page.locator(`[data-timeline-part-id="${first}"], [data-timeline-part-id="${second}"]`)).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("combines adjacent edit calls and repeated files into one group", async ({ page }) => {
|
||||
const first = "prt_edit_first"
|
||||
const second = "prt_edit_second"
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(
|
||||
first,
|
||||
"edit",
|
||||
"completed",
|
||||
{ path: "src/first.ts", oldString: "one", newString: "two" },
|
||||
{
|
||||
metadata: { files: [patchFile("src/first.ts", "modified")] },
|
||||
},
|
||||
),
|
||||
toolPart(
|
||||
second,
|
||||
"edit",
|
||||
"completed",
|
||||
{ path: "src/first.ts", oldString: "two", newString: "three" },
|
||||
{
|
||||
metadata: { files: [patchFile("src/first.ts", "modified")] },
|
||||
},
|
||||
),
|
||||
]),
|
||||
],
|
||||
settings: { editToolPartsExpanded: true },
|
||||
})
|
||||
|
||||
const group = page.locator(`[data-timeline-part-ids="${first},${second}"]`)
|
||||
await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toContainText("Edit")
|
||||
await expect(group.getByText("1 file", { exact: true })).toBeVisible()
|
||||
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["first.ts"])
|
||||
await expect(group.locator('[data-scope="apply-patch"] button')).toHaveAttribute("aria-expanded", "true")
|
||||
})
|
||||
|
||||
test("projects gaps, dividers, assistant parts, and errors together", async ({ page }) => {
|
||||
const firstUser = userMessage(
|
||||
[
|
||||
@@ -122,6 +236,25 @@ test.describe("session timeline projection", () => {
|
||||
await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test("renders interruption independently when the turn is not compacted", async ({ page }) => {
|
||||
const user = userMessage()
|
||||
const before = assistantMessage([{ id: "prt_before", type: "text", text: "Before" }], {
|
||||
id: "msg_1001_before",
|
||||
error: { type: "MessageAbortedError", message: "Stopped" },
|
||||
})
|
||||
const after = assistantMessage([{ id: "prt_after", type: "text", text: "After" }], {
|
||||
id: "msg_1002_after",
|
||||
created: 1700000003000,
|
||||
})
|
||||
await setupTimeline(page, { messages: [user, before, after] })
|
||||
|
||||
await expect(page.getByText("Interrupted", { exact: true })).toBeVisible()
|
||||
const rows = await page
|
||||
.locator('[data-timeline-row="AssistantPart"], [data-timeline-row="TurnDivider"]')
|
||||
.evaluateAll((elements) => elements.map((element) => element.getAttribute("data-timeline-row")))
|
||||
expect(rows).toEqual(["AssistantPart", "TurnDivider", "AssistantPart"])
|
||||
})
|
||||
|
||||
test("renders aliased and long custom model notices", async ({ page }) => {
|
||||
const shortName = "GPT-5.4 nano"
|
||||
const longName = "Company Gateway Extra Long Context Model for Narrow Timeline Layouts"
|
||||
@@ -158,8 +291,77 @@ test.describe("session timeline projection", () => {
|
||||
await expect(longNotice.locator("[title]")).toHaveAttribute("title", `Switched to ${longName}`)
|
||||
await expect.poll(() => longNotice.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
|
||||
})
|
||||
|
||||
test("renders user image, file attachment, file reference, and agent reference", async ({ page }) => {
|
||||
const text = "Use @explore with @src/a.ts and inspect the attachments"
|
||||
const parts: PartSeed<"user">[] = [
|
||||
userText(text, { id: "prt_user_rich" }),
|
||||
{
|
||||
id: "prt_user_image",
|
||||
type: "file",
|
||||
mime: "image/png",
|
||||
filename: "pixel.png",
|
||||
url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
||||
},
|
||||
{
|
||||
id: "prt_user_attachment",
|
||||
type: "file",
|
||||
mime: "application/json",
|
||||
filename: "tsconfig.json",
|
||||
url: "data:application/json;base64,e30=",
|
||||
},
|
||||
{
|
||||
id: "prt_user_reference",
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
filename: "a.ts",
|
||||
url: "src/a.ts",
|
||||
source: { type: "file", path: "src/a.ts", text: { value: "@src/a.ts", start: 18, end: 27 } },
|
||||
},
|
||||
{
|
||||
id: "prt_user_agent",
|
||||
type: "agent",
|
||||
name: "explore",
|
||||
source: { value: "@explore", start: 4, end: 12 },
|
||||
},
|
||||
]
|
||||
await setupTimeline(page, { messages: [userMessage(parts), assistantMessage()] })
|
||||
|
||||
await expect(page.getByAltText("pixel.png")).toBeVisible()
|
||||
await expect(page.getByText("tsconfig.json")).toBeVisible()
|
||||
await expect(page.getByText("@src/a.ts", { exact: true })).toBeVisible()
|
||||
await expect(page.getByText("@explore", { exact: true })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
function editPart(id: string) {
|
||||
return toolPart(
|
||||
id,
|
||||
"edit",
|
||||
"completed",
|
||||
{ path: "src/a.ts", oldString: "export const value = 1", newString: "export const value = 2" },
|
||||
{
|
||||
metadata: {
|
||||
files: [patchFile("src/a.ts", "modified")],
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function patchPart(id: string) {
|
||||
return toolPart(
|
||||
id,
|
||||
"patch",
|
||||
"completed",
|
||||
{ patchText: "Update the projected files" },
|
||||
{
|
||||
metadata: {
|
||||
files: [patchFile("src/a.ts", "modified")],
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function patchFile(file: string, status: "added" | "modified" | "deleted") {
|
||||
return {
|
||||
file,
|
||||
|
||||
@@ -7,25 +7,33 @@ import {
|
||||
renderedPartID,
|
||||
setupTimeline,
|
||||
shell,
|
||||
toolPart,
|
||||
status,
|
||||
textPart,
|
||||
toolPart,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => {
|
||||
const textID = "prt_event_order_text"
|
||||
const assistant = assistantMessage([textPart(textID, "Partial")], { completed: false })
|
||||
const timeline = await setupTimeline(page, { messages: [userMessage(), assistant] })
|
||||
await timeline.send(status("busy"), 100)
|
||||
await timeline.send(status("idle"), 100)
|
||||
await timeline.send(partUpdated(textPart(textID, "Final after early idle")), 120)
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 250)
|
||||
test("groups every collapsed tool until visible text separates the stack", async ({ page }) => {
|
||||
const parts = [
|
||||
toolPart("prt_boundary_01_read", "read", "completed", { path: "src/a.ts" }),
|
||||
textPart("prt_boundary_02_text", "Boundary text"),
|
||||
toolPart("prt_boundary_03_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }),
|
||||
toolPart("prt_boundary_04_grep", "grep", "completed", { path: ".", pattern: "stable" }),
|
||||
shell("prt_boundary_05_shell", "completed", "done"),
|
||||
toolPart("prt_boundary_06_list", "list", "completed", { path: "src" }),
|
||||
]
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
||||
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(textID)}"]`)).toContainText(
|
||||
"Final after early idle",
|
||||
await expect(page.locator('[data-timeline-part-ids="prt_boundary_01_read"]')).toBeVisible()
|
||||
const group = page.locator(
|
||||
'[data-timeline-part-ids="prt_boundary_03_glob,prt_boundary_04_grep,prt_boundary_05_shell,prt_boundary_06_list"]',
|
||||
)
|
||||
await expect(group).toBeVisible()
|
||||
await expect(group.getByRole("button")).toHaveAccessibleName("Used Glob, Grep, Shell, List")
|
||||
await expect(group.locator('[data-component="tag"]')).toHaveText("4")
|
||||
await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(3)
|
||||
await expect(page.locator('[data-timeline-spacing="content"]')).toHaveCount(2)
|
||||
await expect(page.locator('[data-timeline-spacing="content"]').nth(0)).toHaveCSS("padding-top", "16px")
|
||||
})
|
||||
|
||||
test("expands a mixed collapsed tool stack without expanding its individual calls", async ({ page }) => {
|
||||
@@ -124,3 +132,18 @@ test("keeps failed search calls and their error cards inside the collapsed stack
|
||||
"Search timed out after 30 seconds",
|
||||
)
|
||||
})
|
||||
|
||||
test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => {
|
||||
const textID = "prt_event_order_text"
|
||||
const assistant = assistantMessage([textPart(textID, "Partial")], { completed: false })
|
||||
const timeline = await setupTimeline(page, { messages: [userMessage(), assistant] })
|
||||
await timeline.send(status("busy"), 100)
|
||||
await timeline.send(status("idle"), 100)
|
||||
await timeline.send(partUpdated(textPart(textID, "Final after early idle")), 120)
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 250)
|
||||
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(textID)}"]`)).toContainText(
|
||||
"Final after early idle",
|
||||
)
|
||||
})
|
||||
|
||||
@@ -7,6 +7,31 @@ import {
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("renders every tool error outcome without leaking hidden tools", async ({ page }) => {
|
||||
const ordinary = ["shell", "edit", "write", "patch", "webfetch", "websearch", "subagent", "skill", "mcp_probe"]
|
||||
const parts = ordinary.map((tool, index) =>
|
||||
toolPart(`prt_error_${index}`, tool, "error", errorInput(tool), { error: `${tool} failed visibly` }),
|
||||
)
|
||||
parts.push(
|
||||
toolPart("prt_question_dismissed", "question", "error", questionInput(), {
|
||||
error: "The user dismissed this question",
|
||||
}),
|
||||
toolPart("prt_question_error", "question", "error", questionInput(), { error: "Question transport failed" }),
|
||||
toolPart("prt_todo_error", "todowrite", "error", { todos: [] }, { error: "Hidden todo failure" }),
|
||||
)
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
||||
|
||||
const group = page.locator(`[data-timeline-part-ids="${ordinary.map((_, index) => `prt_error_${index}`).join(",")}"]`)
|
||||
await expect(group.locator('[data-component="tag"]')).toHaveText(String(ordinary.length))
|
||||
await group.getByRole("button").click()
|
||||
await expect(page.locator('[data-kind="tool-error-card"]')).toHaveCount(ordinary.length + 1)
|
||||
await expect(page.getByText(/dismissed/i)).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-part-id="prt_todo_error"]')).toHaveCount(0)
|
||||
for (let index = 0; index < ordinary.length; index++) {
|
||||
await expect(page.locator(`[data-timeline-part-id="prt_error_${index}"]`)).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test("transitions shell and question through running error outcomes", async ({ page }) => {
|
||||
const shellID = "prt_transition_error_shell"
|
||||
const questionID = "prt_transition_error_question"
|
||||
@@ -113,6 +138,62 @@ test("preserves surviving grouped patch state when its first patch fails", async
|
||||
.toBeGreaterThanOrEqual(-0.5)
|
||||
})
|
||||
|
||||
test("labels all web search provider variants", async ({ page }) => {
|
||||
const parts = [
|
||||
toolPart(
|
||||
"prt_search_parallel",
|
||||
"websearch",
|
||||
"completed",
|
||||
{ query: "parallel" },
|
||||
{ metadata: { provider: "parallel" } },
|
||||
),
|
||||
toolPart("prt_search_exa", "websearch", "completed", { query: "exa" }, { metadata: { provider: "exa" } }),
|
||||
toolPart("prt_search_generic", "websearch", "completed", { query: "generic" }),
|
||||
]
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
||||
await page.getByRole("button", { name: "Used Parallel Web Search, Exa Web Search, Web Search" }).click()
|
||||
|
||||
const tools = page.locator('[data-component="context-tool-group-list"]')
|
||||
await expect(tools.getByRole("button", { name: /Parallel Web Search/ })).toBeVisible()
|
||||
await expect(tools.getByRole("button", { name: /Exa Web Search/ })).toBeVisible()
|
||||
await expect(tools.getByRole("button", { name: /^Web Search/ })).toBeVisible()
|
||||
})
|
||||
|
||||
test("labels completed searches with result counts", async ({ page }) => {
|
||||
const glob = "prt_glob_count"
|
||||
const grep = "prt_grep_count"
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(glob, "glob", "completed", { path: ".", pattern: "**/*.ts" }, { metadata: { count: 1 } }),
|
||||
toolPart(grep, "grep", "completed", { path: ".", pattern: "value" }, { metadata: { matches: 12 } }),
|
||||
]),
|
||||
],
|
||||
})
|
||||
|
||||
const group = page.locator(`[data-timeline-part-ids="${glob},${grep}"]`)
|
||||
await group.locator('[data-slot="collapsible-trigger"]').click()
|
||||
const rows = group.locator('[data-component="context-tool-group-list"] [data-component="tool-trigger"]')
|
||||
await expect(rows.filter({ hasText: "Glob" })).toContainText("(1 match)")
|
||||
await expect(rows.filter({ hasText: "Grep" })).toContainText("(12 matches)")
|
||||
})
|
||||
|
||||
test("labels read tools from their path input", async ({ page }) => {
|
||||
const id = "prt_read_path"
|
||||
await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([toolPart(id, "read", "completed", { path: "src/a.ts" })])],
|
||||
})
|
||||
|
||||
const group = page.locator(`[data-timeline-part-ids="${id}"]`)
|
||||
await group.locator('[data-slot="collapsible-trigger"]').click()
|
||||
await expect(
|
||||
group
|
||||
.locator('[data-component="context-tool-group-list"] [data-component="tool-trigger"]')
|
||||
.filter({ hasText: "Read" }),
|
||||
).toContainText("a.ts")
|
||||
})
|
||||
|
||||
test("groups instruction files loaded by the same read", async ({ page }) => {
|
||||
const id = "prt_read_instructions"
|
||||
await setupTimeline(page, {
|
||||
@@ -140,6 +221,36 @@ test("groups instruction files loaded by the same read", async ({ page }) => {
|
||||
await expect(loaded.locator('[data-slot="tool-loaded-kind"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("labels skill tools from IDs and result metadata", async ({ page }) => {
|
||||
const pending = "prt_skill_id"
|
||||
const completed = "prt_skill_name"
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(pending, "skill", "running", { id: "frontend-design" }),
|
||||
toolPart(completed, "skill", "completed", { id: "opencode" }, { metadata: { name: "OpenCode" } }),
|
||||
]),
|
||||
],
|
||||
})
|
||||
|
||||
const group = page.locator(`[data-timeline-part-ids="${pending},${completed}"]`)
|
||||
await expect(group.getByRole("button")).toHaveAccessibleName("Used Skill")
|
||||
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
|
||||
await group.getByRole("button").click()
|
||||
|
||||
const loaded = group.locator('[data-component="tool-loaded-item"]')
|
||||
await expect(loaded).toHaveCount(1)
|
||||
await expect(loaded).toHaveAttribute("aria-label", "Loaded frontend-design, OpenCode skills")
|
||||
await expect(loaded).toHaveCSS("line-height", "16px")
|
||||
await expect(loaded.locator('[data-slot="tool-loaded-label"]')).toHaveText("Loaded")
|
||||
await expect(loaded.locator('[data-slot="tool-loaded-kind"]')).toHaveText("skills")
|
||||
const names = loaded.locator('[data-component="text-shimmer"]')
|
||||
await expect(names).toHaveCount(2)
|
||||
await expect(names.nth(0)).toHaveAttribute("aria-label", "frontend-design")
|
||||
await expect(names.nth(1)).toHaveAttribute("aria-label", "OpenCode")
|
||||
})
|
||||
|
||||
test("groups only consecutive successful skill tools", async ({ page }) => {
|
||||
const parts = [
|
||||
toolPart("prt_skill_first", "skill", "completed", { id: "ocpr" }),
|
||||
@@ -162,3 +273,14 @@ test("groups only consecutive successful skill tools", async ({ page }) => {
|
||||
function questionInput() {
|
||||
return { questions: [{ header: "Stability", question: "Keep it stable?", options: [] }] }
|
||||
}
|
||||
|
||||
function errorInput(tool: string) {
|
||||
if (tool === "shell") return { command: "exit 1" }
|
||||
if (["edit", "write"].includes(tool)) return { path: "src/error.ts", content: "" }
|
||||
if (tool === "patch") return { patchText: "Update src/error.ts" }
|
||||
if (tool === "webfetch") return { url: "https://example.com" }
|
||||
if (tool === "websearch") return { query: "failure" }
|
||||
if (tool === "subagent") return { description: "Fail subagent", agent: "explore", prompt: "Inspect the failure." }
|
||||
if (tool === "skill") return { name: "failure" }
|
||||
return { target: "failure" }
|
||||
}
|
||||
|
||||
@@ -87,7 +87,6 @@ test("clears the terminal line with Command+Delete", async ({ page }) => {
|
||||
const terminal = page.locator('[data-component="terminal"]')
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(terminal.locator("textarea")).toHaveCount(1)
|
||||
await expect.poll(() => sendPtyOutput).toBeDefined()
|
||||
|
||||
await page.keyboard.press("Meta+Backspace")
|
||||
|
||||
|
||||
@@ -127,6 +127,7 @@ test("animates review and terminal panels while caching hidden terminal content"
|
||||
await expectStackedGeometry(page)
|
||||
await expectPanelGapHeld(page)
|
||||
|
||||
await resetTerminalTopMotion(page)
|
||||
await resetTerminalBottomMotion(page)
|
||||
await resetTerminalAnchorGaps(page)
|
||||
await resetPanelGaps(page)
|
||||
@@ -138,6 +139,7 @@ test("animates review and terminal panels while caching hidden terminal content"
|
||||
await expect(panel).toBeVisible()
|
||||
await expectHeightMotions(page, "session-side-region", 2)
|
||||
await expectHeightMotions(page, "session-side-terminal-region", 2)
|
||||
await expectTerminalTopMotion(page)
|
||||
await expectTerminalBottomFixed(page)
|
||||
await expectTerminalTopAnchored(page)
|
||||
await expectPanelGapHeld(page)
|
||||
@@ -223,6 +225,7 @@ type MotionProbe = {
|
||||
terminalAnchorGaps: number[]
|
||||
resetAnchorOnMotion: boolean
|
||||
panelGaps: number[]
|
||||
terminalTops: number[]
|
||||
terminalBottoms: number[]
|
||||
heights: string[]
|
||||
animations: string[]
|
||||
@@ -240,6 +243,7 @@ async function installMotionProbe(page: Page) {
|
||||
terminalAnchorGaps: [],
|
||||
resetAnchorOnMotion: false,
|
||||
panelGaps: [],
|
||||
terminalTops: [],
|
||||
terminalBottoms: [],
|
||||
heights: [],
|
||||
animations: [],
|
||||
@@ -266,6 +270,7 @@ async function installMotionProbe(page: Page) {
|
||||
const terminalContent = document.querySelector<HTMLElement>('[data-slot="terminal-panel-content"]')
|
||||
const panelGap = document.querySelector<HTMLElement>('[data-slot="session-side-panel-gap"]')
|
||||
if (!terminal || !terminalContent) return
|
||||
probe.terminalTops.push(terminal.getBoundingClientRect().top)
|
||||
probe.terminalBottoms.push(terminal.getBoundingClientRect().bottom)
|
||||
probe.terminalContentSizes.push({
|
||||
width: terminalContent.getBoundingClientRect().width,
|
||||
@@ -441,6 +446,13 @@ async function expectStackPainted(page: Page) {
|
||||
expect(Math.max(...gaps.map((gap) => gap.terminalSurface)), JSON.stringify(gaps)).toBeLessThanOrEqual(1)
|
||||
}
|
||||
|
||||
async function resetTerminalTopMotion(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
|
||||
if (probe) probe.terminalTops = []
|
||||
})
|
||||
}
|
||||
|
||||
async function resetTerminalBottomMotion(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
|
||||
@@ -504,6 +516,17 @@ async function expectTerminalContentCachedSize(page: Page) {
|
||||
expect(Math.min(...sizes.map((size) => size.height))).toBeGreaterThan(100)
|
||||
}
|
||||
|
||||
async function expectTerminalTopMotion(page: Page) {
|
||||
const tops = await page.evaluate(
|
||||
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.terminalTops.map(Math.round) ?? [],
|
||||
)
|
||||
const unique = [...new Set(tops)]
|
||||
const range = Math.max(...unique) - Math.min(...unique)
|
||||
const maxDelta = Math.max(...unique.slice(1).map((value, index) => Math.abs(value - unique[index])))
|
||||
expect(unique.length, JSON.stringify(unique)).toBeGreaterThan(6)
|
||||
expect(maxDelta, JSON.stringify({ unique, range, maxDelta })).toBeLessThan(range * 0.3)
|
||||
}
|
||||
|
||||
async function expectHeightMotions(page: Page, slot: string, count: number) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"rootDir": "..",
|
||||
"types": ["node", "bun"]
|
||||
},
|
||||
"include": ["./**/*.ts", "./**/*.tsx", "../component-tests/**/*.ts", "../src/types.ts"]
|
||||
"include": ["./**/*.ts", "./**/*.tsx", "../src/types.ts"]
|
||||
}
|
||||
|
||||
@@ -26,8 +26,6 @@
|
||||
"test:unit:watch": "bun test --conditions=solid --watch --preload ./happydom.ts ./src",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:local": "playwright test",
|
||||
"test:components": "playwright test --config playwright.components.config.ts",
|
||||
"test:components:ui": "playwright test --config playwright.components.config.ts --ui",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:report": "playwright show-report e2e/playwright-report",
|
||||
"test:service-worker": "bun run build && playwright test --config e2e/service-worker/playwright.config.ts",
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { componentConfig } from "../storybook/playwright/config"
|
||||
|
||||
export default componentConfig(fileURLToPath(new URL(".", import.meta.url)))
|
||||
@@ -21,12 +21,6 @@
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
"component-tests",
|
||||
"playwright.components.config.ts",
|
||||
"../storybook/playwright/*.ts",
|
||||
"package.json"
|
||||
],
|
||||
"include": ["src", "package.json"],
|
||||
"exclude": ["dist", "ts-dist"]
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/pty": "0.1.11",
|
||||
"@opencode-ai/pty": "0.1.10",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
"@opencode-ai/tui": "workspace:*",
|
||||
|
||||
@@ -4,14 +4,11 @@ import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
import { ServerConnection } from "../../../services/server-connection"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.service.commands.restart,
|
||||
Effect.fn("cli.service.restart")(function* () {
|
||||
const options = yield* ServiceConfig.options()
|
||||
// Keep this explicit: automatic service replacement must preserve terminals.
|
||||
yield* ServerConnection.shutdownPersistentPty(options).pipe(Effect.ignore)
|
||||
yield* Service.stop(options)
|
||||
const transport = yield* Service.ensure(options)
|
||||
process.stdout.write(transport.url + EOL)
|
||||
|
||||
@@ -514,115 +514,6 @@ test("event.subscribe exposes the Promise event stream wire projection", async (
|
||||
expect(events[1]?.type === "session.model.selected" && events[1].created).toBe(1_717_171_717_000)
|
||||
})
|
||||
|
||||
// Moved from packages/app/e2e/regression/session-timeline-transport.spec.ts
|
||||
test("event.subscribe keeps one request open while delivering multiple events", async () => {
|
||||
const requests: Request[] = []
|
||||
const events = [
|
||||
{ id: "evt_first", created: 1, type: "server.connected", data: {} },
|
||||
{ id: "evt_second", created: 2, type: "server.connected", data: {} },
|
||||
]
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input, init) => {
|
||||
requests.push(input instanceof Request ? input : new Request(input, init))
|
||||
return new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
},
|
||||
})
|
||||
const received = []
|
||||
for await (const event of client.event.subscribe()) received.push(event)
|
||||
expect(received).toEqual(events)
|
||||
expect(requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
// Moved from packages/app/e2e/regression/session-timeline-transport.spec.ts
|
||||
test("event.subscribe delivers every event from one stream chunk", async () => {
|
||||
const events = Array.from({ length: 4 }, (_, index) => ({
|
||||
id: `evt_burst_${index}`,
|
||||
created: index,
|
||||
type: "server.connected",
|
||||
data: {},
|
||||
}))
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async () =>
|
||||
new Response(new TextEncoder().encode(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("")), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
}),
|
||||
})
|
||||
const received = []
|
||||
for await (const event of client.event.subscribe()) received.push(event)
|
||||
expect(received).toEqual(events)
|
||||
expect(new Set(received.map((event) => event.id)).size).toBe(4)
|
||||
})
|
||||
|
||||
// Moved from packages/app/e2e/regression/session-timeline-transport.spec.ts
|
||||
test("event.subscribe parses split JSON and a split multibyte code point", async () => {
|
||||
const event = {
|
||||
id: "evt_split",
|
||||
created: 1,
|
||||
type: "server.connected",
|
||||
data: { text: "split snowman \u2603\u2603\u2603" },
|
||||
}
|
||||
const encoded = new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`)
|
||||
const multibyte = encoded.indexOf(new TextEncoder().encode("\u2603")[0]!)
|
||||
const boundaries = [9, multibyte + 1, multibyte + 2, encoded.length]
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async () =>
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
boundaries.forEach((end, index) =>
|
||||
controller.enqueue(encoded.slice(index ? boundaries[index - 1] : 0, end)),
|
||||
)
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
),
|
||||
})
|
||||
await expect(client.event.subscribe()[Symbol.asyncIterator]().next()).resolves.toEqual({ done: false, value: event })
|
||||
})
|
||||
|
||||
// Moved from packages/app/e2e/regression/session-timeline-transport.spec.ts
|
||||
test("event.subscribe ignores server heartbeat comments", async () => {
|
||||
const event = { id: "evt_sentinel", created: 1, type: "server.connected", data: {} }
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async () =>
|
||||
new Response(`: heartbeat\n\ndata: ${JSON.stringify(event)}\n\n: heartbeat\n\n`, {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
}),
|
||||
})
|
||||
const received = []
|
||||
for await (const item of client.event.subscribe()) received.push(item)
|
||||
expect(received).toEqual([event])
|
||||
})
|
||||
|
||||
// Moved from packages/app/e2e/regression/session-timeline-transport.spec.ts
|
||||
test("event transport passes through ordinary health requests", async () => {
|
||||
const requests: string[] = []
|
||||
const event = { id: "evt_connected", created: 1, type: "server.connected", data: {} }
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input, init) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
requests.push(new URL(request.url).pathname)
|
||||
if (new URL(request.url).pathname === "/api/event") {
|
||||
return new Response(`data: ${JSON.stringify(event)}\n\n`, {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
}
|
||||
return Response.json({ healthy: true, version: "2.0.0", pid: 1 })
|
||||
},
|
||||
})
|
||||
await expect(client.event.subscribe()[Symbol.asyncIterator]().next()).resolves.toEqual({ done: false, value: event })
|
||||
await expect(client.health.get()).resolves.toEqual({ healthy: true, version: "2.0.0", pid: 1 })
|
||||
expect(requests).toEqual(["/api/event", "/api/health"])
|
||||
})
|
||||
|
||||
test("event.subscribe terminates on malformed Promise SSE data", async () => {
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
|
||||
@@ -118,7 +118,7 @@
|
||||
"@ff-labs/fff-node": "0.10.5",
|
||||
"@opencode-ai/codemode": "workspace:*",
|
||||
"@opencode-ai/ai": "workspace:*",
|
||||
"@opencode-ai/pty": "0.1.11",
|
||||
"@opencode-ai/pty": "0.1.10",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
|
||||
@@ -80,11 +80,10 @@ export function importLegacyCredentials(tx: Parameters<DatabaseMigration.Migrati
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
const label = credential.type === "oauth" ? "OAuth" : "API key"
|
||||
const now = Date.now()
|
||||
yield* tx.run(sql`
|
||||
INSERT INTO credential (id, integration_id, label, value, time_created, time_updated)
|
||||
VALUES (${Credential.ID.create()}, ${integrationID}, ${label}, ${JSON.stringify(credential)}, ${now}, ${now})
|
||||
VALUES (${Credential.ID.create()}, ${integrationID}, 'default', ${JSON.stringify(credential)}, ${now}, ${now})
|
||||
`)
|
||||
}
|
||||
|
||||
|
||||
+94
-167
@@ -1,41 +1,11 @@
|
||||
export * as Job from "./job.js"
|
||||
|
||||
import { Array, Cause, Clock, Context, Deferred, Effect, Exit, Layer, Schema, Scope, SynchronizedRef } from "effect"
|
||||
import { Cause, Clock, Context, Deferred, Effect, Exit, Layer, Scope, SynchronizedRef } from "effect"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Identifier } from "./id/id.js"
|
||||
import { KV } from "./kv.js"
|
||||
import { SessionMessage } from "./session/message.js"
|
||||
import { SessionSchema } from "./session/schema.js"
|
||||
|
||||
const Background = Schema.Struct({
|
||||
id: Schema.String,
|
||||
notificationID: SessionMessage.ID,
|
||||
recovery: Schema.Union([
|
||||
Schema.Struct({
|
||||
kind: Schema.Literal("shell"),
|
||||
sessionID: SessionSchema.ID,
|
||||
shellID: Schema.String,
|
||||
command: Schema.String,
|
||||
}),
|
||||
Schema.Struct({
|
||||
kind: Schema.Literal("subagent"),
|
||||
parentSessionID: SessionSchema.ID,
|
||||
childSessionID: SessionSchema.ID,
|
||||
agent: Schema.String,
|
||||
description: Schema.String,
|
||||
}),
|
||||
]),
|
||||
status: Schema.Literals(["running", "completed", "error", "cancelled"]),
|
||||
output: Schema.optionalKey(Schema.String),
|
||||
error: Schema.optionalKey(Schema.String),
|
||||
})
|
||||
|
||||
export type Background = typeof Background.Type
|
||||
export type Recovery = Background["recovery"]
|
||||
export type Status = Background["status"]
|
||||
|
||||
const decodeBackground = Schema.decodeUnknownResult(Background)
|
||||
const backgroundPrefix = "job.background/"
|
||||
export type Status = "running" | "completed" | "error" | "cancelled"
|
||||
|
||||
export type Info = {
|
||||
id: string
|
||||
@@ -47,7 +17,6 @@ export type Info = {
|
||||
output?: string
|
||||
error?: string
|
||||
metadata?: Record<string, unknown>
|
||||
notificationID?: SessionMessage.ID
|
||||
}
|
||||
|
||||
type Active = {
|
||||
@@ -58,7 +27,6 @@ type Active = {
|
||||
token: object
|
||||
blockingSessions: Map<SessionSchema.ID, number>
|
||||
isBackgrounded: boolean
|
||||
recovery?: Recovery
|
||||
}
|
||||
|
||||
type State = {
|
||||
@@ -95,8 +63,6 @@ export type StartInput = {
|
||||
type: string
|
||||
title?: string
|
||||
metadata?: Record<string, unknown>
|
||||
recovery?: Recovery
|
||||
notificationID?: SessionMessage.ID
|
||||
run: Effect.Effect<string, unknown>
|
||||
}
|
||||
|
||||
@@ -130,8 +96,6 @@ export interface Interface {
|
||||
readonly background: (id: string) => Effect.Effect<Info | undefined>
|
||||
readonly backgroundAll: (input: BackgroundAllInput) => Effect.Effect<Info[]>
|
||||
readonly cancel: (id: string) => Effect.Effect<Info | undefined>
|
||||
readonly pendingBackground: Effect.Effect<readonly Background[]>
|
||||
readonly completeBackground: (notificationID: SessionMessage.ID) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Job") {}
|
||||
@@ -162,57 +126,43 @@ function decrementSession(input: Map<SessionSchema.ID, number>, sessionID: Sessi
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes one scoped, process-local registry. Explicitly recoverable background
|
||||
* work also owns a durable notification marker until its notification is admitted.
|
||||
* Makes one scoped, process-local registry. Entries are intentionally not
|
||||
* durable: process restart or owner-scope closure loses status and interrupts
|
||||
* live work. Persisted observation, restart recovery, and remote workers need a
|
||||
* separate durable ownership slice rather than pretending this registry has
|
||||
* those semantics.
|
||||
*/
|
||||
export const make = Effect.gen(function* () {
|
||||
const kv = yield* KV.Service
|
||||
const state: State = {
|
||||
jobs: yield* SynchronizedRef.make(new Map()),
|
||||
scope: yield* Scope.Scope,
|
||||
}
|
||||
|
||||
const persistBackground = Effect.fnUntraced(function* (job: Active) {
|
||||
if (!job.recovery || !job.info.notificationID) return
|
||||
yield* kv.set(`${backgroundPrefix}${job.info.notificationID}`, {
|
||||
id: job.info.id,
|
||||
notificationID: job.info.notificationID,
|
||||
recovery: job.recovery,
|
||||
status: job.info.status,
|
||||
...(job.info.output !== undefined ? { output: job.info.output } : {}),
|
||||
...(job.info.error !== undefined ? { error: job.info.error } : {}),
|
||||
})
|
||||
})
|
||||
|
||||
const settle = Effect.fnUntraced(function* (id: string, token: object, exit: Exit.Exit<string, unknown>) {
|
||||
const completed_at = yield* Clock.currentTimeMillis
|
||||
const result = yield* SynchronizedRef.modifyEffect(
|
||||
state.jobs,
|
||||
Effect.fnUntraced(function* (jobs): Effect.fn.Return<readonly [FinishResult, Map<string, Active>]> {
|
||||
const job = jobs.get(id)
|
||||
if (!job) return [{}, jobs]
|
||||
if (job.token !== token) return [{}, jobs]
|
||||
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
|
||||
const status: Exclude<Status, "running"> = Exit.isSuccess(exit)
|
||||
? "completed"
|
||||
: Cause.hasInterruptsOnly(exit.cause)
|
||||
? "cancelled"
|
||||
: "error"
|
||||
const next = {
|
||||
...job,
|
||||
blockingSessions: new Map<SessionSchema.ID, number>(),
|
||||
info: {
|
||||
...job.info,
|
||||
status,
|
||||
completed_at,
|
||||
...(Exit.isSuccess(exit) ? { output: exit.value } : {}),
|
||||
...(Exit.isFailure(exit) ? { error: errorText(Cause.squash(exit.cause)) } : {}),
|
||||
},
|
||||
}
|
||||
if (status !== "cancelled") yield* persistBackground(next)
|
||||
return [{ info: snapshot(next), done: job.done, scope: job.scope }, new Map(jobs).set(id, next)]
|
||||
}),
|
||||
)
|
||||
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [FinishResult, Map<string, Active>] => {
|
||||
const job = jobs.get(id)
|
||||
if (!job) return [{}, jobs]
|
||||
if (job.token !== token) return [{}, jobs]
|
||||
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
|
||||
const status: Exclude<Status, "running"> = Exit.isSuccess(exit)
|
||||
? "completed"
|
||||
: Cause.hasInterruptsOnly(exit.cause)
|
||||
? "cancelled"
|
||||
: "error"
|
||||
const next = {
|
||||
...job,
|
||||
blockingSessions: new Map<SessionSchema.ID, number>(),
|
||||
info: {
|
||||
...job.info,
|
||||
status,
|
||||
completed_at,
|
||||
...(Exit.isSuccess(exit) ? { output: exit.value } : {}),
|
||||
...(Exit.isFailure(exit) ? { error: errorText(Cause.squash(exit.cause)) } : {}),
|
||||
},
|
||||
}
|
||||
return [{ info: snapshot(next), done: job.done, scope: job.scope }, new Map(jobs).set(id, next)]
|
||||
})
|
||||
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info).pipe(Effect.ignore)
|
||||
if (result.scope) {
|
||||
yield* Scope.close(result.scope, Exit.void).pipe(Effect.forkIn(state.scope, { startImmediately: true }))
|
||||
@@ -220,6 +170,22 @@ export const make = Effect.gen(function* () {
|
||||
return result.info
|
||||
})
|
||||
|
||||
const fork = Effect.fnUntraced(function* (
|
||||
scope: Scope.Scope,
|
||||
id: string,
|
||||
token: object,
|
||||
run: Effect.Effect<string, unknown>,
|
||||
) {
|
||||
return yield* run.pipe(
|
||||
Effect.matchCauseEffect({
|
||||
onSuccess: (output) => settle(id, token, Exit.succeed(output)),
|
||||
onFailure: (cause) => settle(id, token, Exit.failCause(cause)),
|
||||
}),
|
||||
Effect.asVoid,
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
)
|
||||
})
|
||||
|
||||
const get: Interface["get"] = Effect.fn("Job.get")(function* (id) {
|
||||
const job = (yield* SynchronizedRef.get(state.jobs)).get(id)
|
||||
if (!job) return undefined
|
||||
@@ -235,10 +201,10 @@ export const make = Effect.gen(function* () {
|
||||
const backgrounded = yield* Deferred.make<Info>()
|
||||
const result = yield* SynchronizedRef.modifyEffect(
|
||||
state.jobs,
|
||||
Effect.fnUntraced(function* (jobs): Effect.fn.Return<readonly [StartResult, Map<string, Active>]> {
|
||||
Effect.fnUntraced(function* (jobs) {
|
||||
const existing = jobs.get(id)
|
||||
if (existing?.info.status === "running") {
|
||||
return [{ info: snapshot(existing) }, jobs]
|
||||
return [{ info: snapshot(existing) }, jobs] as readonly [StartResult, Map<string, Active>]
|
||||
}
|
||||
const scope = yield* Scope.fork(state.scope, "parallel")
|
||||
const token = {}
|
||||
@@ -250,7 +216,6 @@ export const make = Effect.gen(function* () {
|
||||
status: "running" as const,
|
||||
started_at,
|
||||
metadata: input.metadata,
|
||||
...(input.notificationID ? { notificationID: input.notificationID } : {}),
|
||||
},
|
||||
done,
|
||||
backgrounded,
|
||||
@@ -258,18 +223,14 @@ export const make = Effect.gen(function* () {
|
||||
token,
|
||||
blockingSessions: new Map<SessionSchema.ID, number>(),
|
||||
isBackgrounded: false,
|
||||
recovery: input.recovery,
|
||||
}
|
||||
return [{ info: snapshot(job), scope, token }, new Map(jobs).set(id, job)]
|
||||
return [{ info: snapshot(job), scope, token }, new Map(jobs).set(id, job)] as readonly [
|
||||
StartResult,
|
||||
Map<string, Active>,
|
||||
]
|
||||
}),
|
||||
)
|
||||
if ("scope" in result)
|
||||
yield* restore(input.run).pipe(
|
||||
Effect.exit,
|
||||
Effect.flatMap((exit) => settle(id, result.token, exit)),
|
||||
Effect.asVoid,
|
||||
Effect.forkIn(result.scope, { startImmediately: true }),
|
||||
)
|
||||
if ("scope" in result) yield* fork(result.scope, id, result.token, restore(input.run))
|
||||
return result.info
|
||||
}),
|
||||
)
|
||||
@@ -320,31 +281,20 @@ export const make = Effect.gen(function* () {
|
||||
).pipe(Effect.ensuring(removeBlock(input)))
|
||||
})
|
||||
|
||||
const markBackground = Effect.fnUntraced(function* (job: Active) {
|
||||
const next = {
|
||||
...job,
|
||||
isBackgrounded: true,
|
||||
blockingSessions: new Map<SessionSchema.ID, number>(),
|
||||
info: {
|
||||
...job.info,
|
||||
...(job.recovery ? { notificationID: job.info.notificationID ?? SessionMessage.ID.create() } : {}),
|
||||
},
|
||||
}
|
||||
yield* persistBackground(next)
|
||||
return next
|
||||
})
|
||||
|
||||
const background: Interface["background"] = Effect.fn("Job.background")(function* (id) {
|
||||
const result = yield* SynchronizedRef.modifyEffect(
|
||||
const result = yield* SynchronizedRef.modify(
|
||||
state.jobs,
|
||||
Effect.fnUntraced(function* (jobs): Effect.fn.Return<readonly [BackgroundResult, Map<string, Active>]> {
|
||||
(jobs): readonly [BackgroundResult, Map<string, Active>] => {
|
||||
const job = jobs.get(id)
|
||||
// Recoverable work may finish before the caller backgrounds it.
|
||||
if (!job || (job.info.status !== "running" && !job.recovery)) return [{}, jobs]
|
||||
if (!job || job.info.status !== "running") return [{}, jobs]
|
||||
if (job.isBackgrounded) return [{ info: snapshot(job) }, jobs]
|
||||
const next = yield* markBackground(job)
|
||||
const next = {
|
||||
...job,
|
||||
isBackgrounded: true,
|
||||
blockingSessions: new Map<SessionSchema.ID, number>(),
|
||||
}
|
||||
return [{ info: snapshot(next), backgrounded: job.backgrounded }, new Map(jobs).set(id, next)]
|
||||
}),
|
||||
},
|
||||
)
|
||||
if (result.info && result.backgrounded)
|
||||
yield* Deferred.succeed(result.backgrounded, result.info).pipe(Effect.ignore)
|
||||
@@ -352,83 +302,60 @@ export const make = Effect.gen(function* () {
|
||||
})
|
||||
|
||||
const backgroundAll: Interface["backgroundAll"] = Effect.fn("Job.backgroundAll")(function* (input) {
|
||||
const result = yield* SynchronizedRef.modifyEffect(
|
||||
const result = yield* SynchronizedRef.modify(
|
||||
state.jobs,
|
||||
Effect.fnUntraced(function* (jobs): Effect.fn.Return<
|
||||
readonly [Required<BackgroundResult>[], Map<string, Active>]
|
||||
> {
|
||||
const results: Required<BackgroundResult>[] = []
|
||||
(jobs): readonly [BackgroundResult[], Map<string, Active>] => {
|
||||
const results: BackgroundResult[] = []
|
||||
const next = new Map(jobs)
|
||||
for (const [id, job] of jobs) {
|
||||
if (job.info.status !== "running") continue
|
||||
if (job.isBackgrounded) continue
|
||||
if (input.type !== undefined && job.info.type !== input.type) continue
|
||||
if (!job.blockingSessions.has(input.sessionID)) continue
|
||||
const updated = yield* markBackground(job)
|
||||
const updated = {
|
||||
...job,
|
||||
isBackgrounded: true,
|
||||
blockingSessions: new Map<SessionSchema.ID, number>(),
|
||||
}
|
||||
results.push({ info: snapshot(updated), backgrounded: job.backgrounded })
|
||||
next.set(id, updated)
|
||||
}
|
||||
return [results, next]
|
||||
}),
|
||||
},
|
||||
)
|
||||
yield* Effect.forEach(result, (item) => Deferred.succeed(item.backgrounded, item.info), { discard: true })
|
||||
return result.map((item) => item.info)
|
||||
yield* Effect.forEach(
|
||||
result,
|
||||
(item) => (item.info && item.backgrounded ? Deferred.succeed(item.backgrounded, item.info) : Effect.void),
|
||||
{ discard: true },
|
||||
)
|
||||
return result.flatMap((item) => (item.info ? [item.info] : []))
|
||||
})
|
||||
|
||||
const cancel: Interface["cancel"] = Effect.fn("Job.cancel")(function* (id) {
|
||||
const completed_at = yield* Clock.currentTimeMillis
|
||||
const result = yield* SynchronizedRef.modifyEffect(
|
||||
state.jobs,
|
||||
Effect.fnUntraced(function* (jobs): Effect.fn.Return<readonly [FinishResult, Map<string, Active>]> {
|
||||
const job = jobs.get(id)
|
||||
if (!job) return [{}, jobs]
|
||||
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
|
||||
const next = {
|
||||
...job,
|
||||
blockingSessions: new Map<SessionSchema.ID, number>(),
|
||||
info: {
|
||||
...job.info,
|
||||
status: "cancelled" as const,
|
||||
completed_at,
|
||||
},
|
||||
}
|
||||
yield* persistBackground(next)
|
||||
return [{ info: snapshot(next), done: job.done, scope: job.scope }, new Map(jobs).set(id, next)]
|
||||
}),
|
||||
)
|
||||
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [FinishResult, Map<string, Active>] => {
|
||||
const job = jobs.get(id)
|
||||
if (!job) return [{}, jobs]
|
||||
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
|
||||
const next = {
|
||||
...job,
|
||||
blockingSessions: new Map<SessionSchema.ID, number>(),
|
||||
info: {
|
||||
...job.info,
|
||||
status: "cancelled" as const,
|
||||
completed_at,
|
||||
},
|
||||
}
|
||||
return [{ info: snapshot(next), done: job.done, scope: job.scope }, new Map(jobs).set(id, next)]
|
||||
})
|
||||
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info).pipe(Effect.ignore)
|
||||
if (result.scope) yield* Scope.close(result.scope, Exit.void)
|
||||
return result.info
|
||||
})
|
||||
|
||||
const pendingBackground: Interface["pendingBackground"] = Effect.gen(function* () {
|
||||
const recovered: Background[] = []
|
||||
let after: string | undefined
|
||||
do {
|
||||
const page = yield* kv.scan({ prefix: backgroundPrefix, after })
|
||||
recovered.push(...Array.filterMap(page.entries, (entry) => decodeBackground(entry.value)))
|
||||
after = page.next
|
||||
} while (after)
|
||||
return recovered
|
||||
}).pipe(Effect.withSpan("Job.pendingBackground"))
|
||||
|
||||
const completeBackground: Interface["completeBackground"] = Effect.fn("Job.completeBackground")((notificationID) =>
|
||||
kv.remove(`${backgroundPrefix}${notificationID}`),
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
get,
|
||||
start,
|
||||
wait,
|
||||
block,
|
||||
background,
|
||||
backgroundAll,
|
||||
cancel,
|
||||
pendingBackground,
|
||||
completeBackground,
|
||||
})
|
||||
return Service.of({ get, start, wait, block, background, backgroundAll, cancel })
|
||||
})
|
||||
|
||||
const layer = Layer.effect(Service, make)
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [KV.node] })
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
|
||||
|
||||
@@ -207,10 +207,7 @@ export const connect = Effect.fnUntraced(function* (
|
||||
}
|
||||
if (!URL.canParse(config.url))
|
||||
return yield* new ConnectError({ server, message: `Invalid MCP URL for "${server}"` })
|
||||
// Prefer raw tools for our Code Mode without changing the configured URL used for OAuth identity.
|
||||
const url = new URL(config.url)
|
||||
if (config.codemode !== false && !url.searchParams.has("codemode")) url.searchParams.set("codemode", "false")
|
||||
return new StreamableHTTPClientTransport(url, {
|
||||
return new StreamableHTTPClientTransport(new URL(config.url), {
|
||||
requestInit: config.headers ? { headers: config.headers } : undefined,
|
||||
authProvider,
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Cause, Context, Duration, Effect, Layer, Option, Schedule, Schema, Semaphore } from "effect"
|
||||
import { Cause, Context, Duration, Effect, Fiber, Layer, Schedule, Schema, Semaphore } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { ModelsDev } from "@opencode-ai/schema/models-dev"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
@@ -10,7 +10,7 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { Model } from "./model.js"
|
||||
import { Provider } from "./provider.js"
|
||||
import { KV } from "./kv.js"
|
||||
import { ModelsDevCache } from "./models-dev/cache.js"
|
||||
import snapshotText from "./models-dev/snapshot.txt" with { type: "text" }
|
||||
|
||||
export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
|
||||
@@ -539,13 +539,6 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Mo
|
||||
const CatalogJson = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown))
|
||||
const decodeCatalog = (text: string) =>
|
||||
Schema.decodeUnknownEffect(CatalogJson)(text).pipe(Effect.map((catalog) => catalog as Record<string, SourceProvider>))
|
||||
const Cache = Schema.Struct({
|
||||
updatedAt: Schema.Number,
|
||||
// Digest of the raw body, persisted so refresh() can skip republishing a
|
||||
// byte-identical catalog. Optional for entries written before it existed.
|
||||
digest: Schema.optional(Schema.String),
|
||||
body: CatalogJson,
|
||||
})
|
||||
const defaultSource = "https://models.opencode.ai"
|
||||
|
||||
// Bundled snapshot of https://models.opencode.ai/api.json, committed at
|
||||
@@ -554,23 +547,18 @@ const defaultSource = "https://models.opencode.ai"
|
||||
// isolate: the snapshot is a multi-MB module-level constant and one isolate can
|
||||
// host many runtimes (Cloudflare colocates Durable Object instances), so
|
||||
// per-runtime decoding would multiply the cost.
|
||||
let bundledCache: readonly Snapshot[] | undefined
|
||||
let bundledCache: { data: readonly Snapshot[]; digest: string } | undefined
|
||||
const bundledSnapshot = Effect.suspend(() =>
|
||||
bundledCache
|
||||
? Effect.succeed(bundledCache)
|
||||
: decodeCatalog(snapshotText).pipe(
|
||||
Effect.map((catalog) => {
|
||||
bundledCache = normalize(catalog)
|
||||
bundledCache = { data: normalize(catalog), digest: bodyDigest(snapshotText) }
|
||||
return bundledCache
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
function cacheKey(source: string) {
|
||||
if (source === defaultSource) return "models-dev:catalog"
|
||||
return `models-dev:catalog:${Hash.fast(source)}`
|
||||
}
|
||||
|
||||
export function bodyDigest(text: string) {
|
||||
return Hash.sha256(text)
|
||||
}
|
||||
@@ -582,7 +570,7 @@ export const layer = (options?: Options) =>
|
||||
const fs = yield* FSUtil.Service
|
||||
const bus = yield* Bus.Service
|
||||
const app = yield* App.Metadata
|
||||
const kv = yield* KV.Service
|
||||
const cache = yield* ModelsDevCache.Service
|
||||
const http = HttpClient.filterStatusOk(
|
||||
(yield* HttpClient.HttpClient).pipe(
|
||||
HttpClient.retryTransient({
|
||||
@@ -596,21 +584,9 @@ export const layer = (options?: Options) =>
|
||||
const source = options?.url || defaultSource
|
||||
const fetch = options?.fetch ?? true
|
||||
const userAgent = App.useragent(app)
|
||||
const key = cacheKey(source)
|
||||
const ttl = Duration.minutes(5)
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
|
||||
const loadFromCache = Effect.fnUntraced(function* () {
|
||||
const value = yield* kv.get(key)
|
||||
const cached = Schema.decodeUnknownOption(Cache)(value)
|
||||
if (Option.isSome(cached))
|
||||
return {
|
||||
catalog: cached.value.body as Record<string, SourceProvider>,
|
||||
updatedAt: cached.value.updatedAt,
|
||||
digest: cached.value.digest,
|
||||
}
|
||||
if (value !== undefined) yield* kv.remove(key)
|
||||
})
|
||||
const state: { data?: readonly Snapshot[]; digest?: string; checkedAt: number } = { checkedAt: 0 }
|
||||
|
||||
const fetchApi = Effect.fn("ModelsDev.fetchApi")(function* () {
|
||||
return yield* HttpClientRequest.get(`${source}/api.json`).pipe(
|
||||
@@ -621,79 +597,82 @@ export const layer = (options?: Options) =>
|
||||
)
|
||||
})
|
||||
|
||||
const loadFromFile = options?.file
|
||||
? fs.readJson(options.file).pipe(
|
||||
Effect.map((input) => input as Record<string, SourceProvider>),
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
// Persistence only seeds a runtime. Refresh never reloads this seed over
|
||||
// a catalog that was successfully fetched but could not be saved.
|
||||
// The service owns initialization so cancelling a reader cannot cancel it.
|
||||
const initialized = yield* Effect.forkScoped(
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.gen(function* () {
|
||||
const stored = options?.file
|
||||
? { body: yield* fs.readFileString(options.file), updatedAt: Date.now() }
|
||||
: yield* cache.read(source)
|
||||
if (!stored) return
|
||||
const data = normalize(yield* decodeCatalog(stored.body))
|
||||
Object.assign(state, { data, digest: bodyDigest(stored.body), checkedAt: stored.updatedAt })
|
||||
}).pipe(
|
||||
Effect.catchCauseIf(
|
||||
(cause) => !Cause.hasInterruptsOnly(cause),
|
||||
(cause) => Effect.logWarning("Failed to load models.dev catalog cache", { cause }),
|
||||
),
|
||||
)
|
||||
: Effect.undefined
|
||||
if (state.data) return
|
||||
if (options?.snapshot !== false) {
|
||||
Object.assign(state, yield* bundledSnapshot)
|
||||
return
|
||||
}
|
||||
if (!fetch) state.data = []
|
||||
}).pipe(Effect.orDie),
|
||||
)
|
||||
|
||||
// The bundled snapshot is the boot-time floor for the catalog; the
|
||||
// periodic fetch below still refreshes on top.
|
||||
const loadSnapshot = options?.snapshot === false ? Effect.undefined : bundledSnapshot
|
||||
|
||||
// Best-effort: a cache-write failure must never kill catalog
|
||||
// population. The payload has outgrown some KV backends' per-value
|
||||
// limits (Durable Object SQLite caps values at 2 MB and api.json
|
||||
// passed it in Aug 2026); a boot without a cache hit just refetches.
|
||||
const writeCache = Effect.fn("ModelsDev.writeCache")(function* (text: string) {
|
||||
yield* kv.set(key, { updatedAt: Date.now(), digest: bodyDigest(text), body: text }).pipe(
|
||||
Effect.catchCauseIf(
|
||||
(cause) => !Cause.hasInterruptsOnly(cause),
|
||||
(cause) => Effect.logWarning("Failed to cache models.dev catalog", { cause }),
|
||||
),
|
||||
)
|
||||
const update = Effect.fn("ModelsDev.update")(function* (force = false) {
|
||||
const text = options?.file ? yield* fs.readFileString(options.file) : yield* fetchApi()
|
||||
const digest = bodyDigest(text)
|
||||
if (!force && state.data && state.digest === digest) {
|
||||
state.checkedAt = Date.now()
|
||||
return state.data
|
||||
}
|
||||
const data = normalize(yield* decodeCatalog(text))
|
||||
Object.assign(state, { data, digest, checkedAt: Date.now() })
|
||||
yield* bus.publish(ModelsDev.Event.Refreshed, {})
|
||||
// Adopt and publish before attempting persistence. A missing or broken
|
||||
// cache must not prevent live updates, including in filesystem-less runtimes.
|
||||
if (!options?.file)
|
||||
yield* cache.write(source, text).pipe(
|
||||
Effect.catchCauseIf(
|
||||
(cause) => !Cause.hasInterruptsOnly(cause),
|
||||
(cause) => Effect.logWarning("Failed to cache models.dev catalog", { cause }),
|
||||
),
|
||||
)
|
||||
return data
|
||||
})
|
||||
|
||||
const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
|
||||
const text = yield* fetchApi()
|
||||
const catalog = yield* decodeCatalog(text)
|
||||
yield* writeCache(text)
|
||||
return catalog
|
||||
const get = Effect.fn("ModelsDev.get")(function* () {
|
||||
yield* Fiber.join(initialized)
|
||||
if (state.data) return state.data
|
||||
return yield* lock
|
||||
.withPermit(
|
||||
Effect.gen(function* () {
|
||||
return state.data ?? (yield* update())
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const populate = Effect.gen(function* () {
|
||||
const fromFile = yield* loadFromFile
|
||||
if (fromFile) return normalize(fromFile)
|
||||
const cached = options?.file ? undefined : yield* loadFromCache()
|
||||
if (cached) return normalize(cached.catalog)
|
||||
const bundled = yield* loadSnapshot
|
||||
if (bundled) return bundled
|
||||
if (!fetch) return []
|
||||
const catalog = yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const stored = options?.file ? undefined : yield* loadFromCache()
|
||||
if (stored) return stored.catalog
|
||||
return yield* fetchAndWrite()
|
||||
}),
|
||||
)
|
||||
return normalize(catalog)
|
||||
}).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie)
|
||||
|
||||
const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity)
|
||||
|
||||
const get = (): Effect.Effect<readonly Snapshot[]> => cachedGet
|
||||
|
||||
const refresh = Effect.fn("ModelsDev.refresh")(function* (force = false) {
|
||||
yield* lock
|
||||
.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const stored = yield* loadFromCache()
|
||||
if (!force && stored && Date.now() - stored.updatedAt < Duration.toMillis(ttl)) return
|
||||
const text = yield* fetchApi()
|
||||
// models.dev rarely changes between polls; skip the cache write,
|
||||
// invalidation, and Refreshed event for a byte-identical body so
|
||||
// downstream catalog.updated listeners stay quiet.
|
||||
if (!force && stored?.digest === bodyDigest(text)) return
|
||||
yield* decodeCatalog(text)
|
||||
yield* writeCache(text)
|
||||
yield* invalidate
|
||||
yield* bus.publish(ModelsDev.Event.Refreshed, {})
|
||||
yield* Fiber.join(initialized)
|
||||
if (!force && Date.now() - state.checkedAt < Duration.toMillis(ttl)) return
|
||||
yield* update(force)
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause: cause })),
|
||||
Effect.ignore,
|
||||
Effect.orDie,
|
||||
Effect.catchCauseIf(
|
||||
(cause) => !Cause.hasInterruptsOnly(cause),
|
||||
(cause) => Effect.logError("Failed to refresh models.dev", { cause }),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -710,7 +689,7 @@ export function configured(options?: Options) {
|
||||
return makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer(options),
|
||||
deps: [FSUtil.node, Bus.node, App.node, KV.node, httpClient],
|
||||
deps: [FSUtil.node, Bus.node, App.node, ModelsDevCache.node, httpClient],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
export * as ModelsDevCache from "./cache.js"
|
||||
|
||||
import path from "path"
|
||||
import { Context, Effect, FileSystem, Layer, Option } from "effect"
|
||||
import type { PlatformError } from "effect/PlatformError"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
|
||||
export interface Entry {
|
||||
readonly body: string
|
||||
readonly updatedAt: number
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly read: (source: string) => Effect.Effect<Entry | undefined, PlatformError>
|
||||
readonly write: (source: string, body: string) => Effect.Effect<void, PlatformError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ModelsDevCache") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const global = yield* Global.Service
|
||||
const directory = path.join(global.cache, "models-dev")
|
||||
|
||||
const read = Effect.fn("ModelsDevCache.read")(
|
||||
function* (source: string) {
|
||||
const file = path.join(directory, `${Hash.fast(source)}.json`)
|
||||
const body = yield* fs.readFileString(file)
|
||||
const info = yield* fs.stat(file)
|
||||
return { body, updatedAt: Option.getOrUndefined(info.mtime)?.getTime() ?? 0 }
|
||||
},
|
||||
Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined),
|
||||
)
|
||||
|
||||
const write = Effect.fn("ModelsDevCache.write")(function* (source: string, body: string) {
|
||||
yield* fs.makeDirectory(directory, { recursive: true })
|
||||
const temporary = yield* fs.makeTempFileScoped({ directory, prefix: ".tmp-" })
|
||||
yield* fs.writeFileString(temporary, body)
|
||||
yield* fs.rename(temporary, path.join(directory, `${Hash.fast(source)}.json`))
|
||||
}, Effect.scoped)
|
||||
|
||||
return Service.of({ read, write })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [LayerNodePlatform.filesystem, Global.node],
|
||||
})
|
||||
|
||||
export const disabledLayer = Layer.succeed(
|
||||
Service,
|
||||
Service.of({ read: () => Effect.undefined, write: () => Effect.void }),
|
||||
)
|
||||
@@ -377,8 +377,14 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
|
||||
hook: (name, callback) => hooks.register("shell", name, callback),
|
||||
},
|
||||
tool: {
|
||||
transform: tools.transform,
|
||||
reload: tools.reload,
|
||||
transform: (callback) =>
|
||||
tools
|
||||
.transform((draft) =>
|
||||
callback({
|
||||
add: (tool) => draft.add(tool),
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie, Effect.as({ dispose: Effect.void })),
|
||||
hook: (name, callback) => hooks.register("tool", name, callback),
|
||||
},
|
||||
vcs: {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect } from "effect"
|
||||
|
||||
// These servers provide Code Mode, so expose them directly instead of nesting them inside OpenCode Code Mode.
|
||||
const urls = [/^https:\/\/executor\.sh\/[^/]+\/mcp$/]
|
||||
const urls = [/^https:\/\/mcp\.cloudflare\.com\/mcp$/, /^https:\/\/executor\.sh\/[^/]+\/mcp$/]
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.mcp.codemode.exclusion",
|
||||
|
||||
@@ -29,7 +29,7 @@ export interface Interface {
|
||||
| "wait"
|
||||
| "context"
|
||||
>
|
||||
readonly job: Pick<Job.Interface, "start" | "wait" | "block" | "background" | "cancel" | "completeBackground">
|
||||
readonly job: Pick<Job.Interface, "start" | "wait" | "block" | "background" | "cancel">
|
||||
readonly location: {
|
||||
readonly agent: {
|
||||
readonly list: (
|
||||
@@ -92,8 +92,6 @@ export const layerWithCell = (cell: Cell) =>
|
||||
block: (input) => require(cell, (runtime) => runtime.job.block(input)),
|
||||
background: (id) => require(cell, (runtime) => runtime.job.background(id)),
|
||||
cancel: (id) => require(cell, (runtime) => runtime.job.cancel(id)),
|
||||
completeBackground: (notificationID) =>
|
||||
require(cell, (runtime) => runtime.job.completeBackground(notificationID)),
|
||||
},
|
||||
location: {
|
||||
agent: {
|
||||
|
||||
@@ -3,7 +3,6 @@ export * as SessionExecution from "./execution.js"
|
||||
import { Cause, Context, Effect, Exit, Layer } from "effect"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Database } from "../database/database.js"
|
||||
import { Job } from "../job.js"
|
||||
import { LocationServiceMap } from "../location-service-map.js"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { SessionEvent } from "./event.js"
|
||||
@@ -53,7 +52,6 @@ export const layer = Layer.effect(
|
||||
const store = yield* SessionStore.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const bus = yield* Bus.Service
|
||||
const jobs = yield* Job.Service
|
||||
const db = (yield* Database.Service).db
|
||||
const reportLifecycle = <A>(sessionID: SessionSchema.ID, effect: Effect.Effect<A>) =>
|
||||
effect.pipe(
|
||||
@@ -120,7 +118,6 @@ export const layer = Layer.effect(
|
||||
if (outcome.type === "interrupted") {
|
||||
// A user cancel releases the claim: the turn must not resurrect at the next
|
||||
// boot. Shutdown interruption keeps it for restart continuity.
|
||||
if (outcome.reason === "user") yield* jobs.cancel(sessionID)
|
||||
yield* bus.publish(
|
||||
SessionEvent.Execution.Interrupted,
|
||||
{ sessionID, reason: outcome.reason },
|
||||
@@ -170,7 +167,7 @@ export const layer = Layer.effect(
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [SessionStore.node, LocationServiceMap.node, Bus.node, Database.node, Job.node],
|
||||
deps: [SessionStore.node, LocationServiceMap.node, Bus.node, Database.node],
|
||||
})
|
||||
|
||||
/** Low-level compatibility layer for callers that only need durable Session recording. */
|
||||
|
||||
@@ -3,8 +3,6 @@ export * as SessionRestart from "./restart.js"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Bus } from "../../bus.js"
|
||||
import { Job } from "../../job.js"
|
||||
import { Session } from "../../session.js"
|
||||
import { SessionEvent } from "../event.js"
|
||||
import { SessionExecution } from "../execution.js"
|
||||
import { SessionSchema } from "../schema.js"
|
||||
@@ -47,9 +45,6 @@ export interface Interface {
|
||||
* process: crash, SIGKILL, isolate eviction, and graceful restart all leave
|
||||
* the same durable signature.
|
||||
*
|
||||
* Recovery is at-least-once: local coordination prevents concurrent drains,
|
||||
* not repeated external side effects after a crash.
|
||||
*
|
||||
* The sweep assumes every orphaned claim's owner is dead. The managed-server
|
||||
* protocol guarantees this: a successor is only spawned after the previous
|
||||
* process is confirmed dead (client service `kill`/`evict` poll the PID), the
|
||||
@@ -67,16 +62,14 @@ export const layer = (options?: Options) =>
|
||||
const store = yield* SessionStore.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const bus = yield* Bus.Service
|
||||
const jobs = yield* Job.Service
|
||||
const sessions = yield* Session.Service
|
||||
const scope = yield* Effect.scope
|
||||
const maxAttempts = options?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS
|
||||
|
||||
const prepareResume = Effect.fnUntraced(function* (sessionID: SessionSchema.ID) {
|
||||
const resumeOne = Effect.fnUntraced(function* (sessionID: SessionSchema.ID) {
|
||||
// Durable before the resume runs, so a crash inside the resumed turn is
|
||||
// counted by the next sweep and the budget cannot be dodged.
|
||||
const attempts = yield* store.countResume(sessionID)
|
||||
if (attempts === undefined) return false
|
||||
if (attempts === undefined) return // the Session was deleted since listing
|
||||
if (attempts > maxAttempts) {
|
||||
// Terminalize instead: the release hook clears the claim and resets the
|
||||
// counter atomically with the terminal event.
|
||||
@@ -85,166 +78,31 @@ export const layer = (options?: Options) =>
|
||||
{ sessionID, error: RESUME_EXHAUSTED },
|
||||
{ commit: () => store.release(sessionID) },
|
||||
)
|
||||
return false
|
||||
return
|
||||
}
|
||||
yield* bus.publish(SessionEvent.Synthetic, {
|
||||
sessionID,
|
||||
text: CONTINUE_AFTER_SERVER_RESTART,
|
||||
description: "Continuing after restart",
|
||||
})
|
||||
return true
|
||||
})
|
||||
|
||||
const recoverShell = Effect.fnUntraced(function* (
|
||||
background: Job.Background,
|
||||
recovery: Extract<Job.Recovery, { kind: "shell" }>,
|
||||
) {
|
||||
const state = background.status === "running" ? "cancelled" : background.status
|
||||
const text =
|
||||
background.status === "running"
|
||||
? "Command cancelled because the server restarted"
|
||||
: state === "completed"
|
||||
? (background.output ?? "Command completed")
|
||||
: state === "error"
|
||||
? (background.error ?? "Command failed")
|
||||
: "Command cancelled"
|
||||
|
||||
yield* sessions
|
||||
.synthetic({
|
||||
id: background.notificationID,
|
||||
sessionID: recovery.sessionID,
|
||||
description: recovery.command,
|
||||
text: `<shell id="${background.id}" state="${state}" command="${recovery.command}">\n${text}\n</shell>`,
|
||||
metadata: {
|
||||
source: "shell",
|
||||
jobID: background.id,
|
||||
shellID: recovery.shellID,
|
||||
state,
|
||||
},
|
||||
resume: false,
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchTag("Session.NotFoundError", () => Effect.void),
|
||||
Effect.orDie,
|
||||
)
|
||||
yield* jobs.completeBackground(background.notificationID)
|
||||
})
|
||||
|
||||
const recoverSubagent = Effect.fnUntraced(function* (
|
||||
background: Job.Background,
|
||||
recovery: Extract<Job.Recovery, { kind: "subagent" }>,
|
||||
suspended: ReadonlySet<SessionSchema.ID>,
|
||||
) {
|
||||
const child = yield* store.get(recovery.childSessionID)
|
||||
if (!child || child.parentID !== recovery.parentSessionID || !(yield* store.get(recovery.parentSessionID))) {
|
||||
yield* jobs.completeBackground(background.notificationID)
|
||||
return
|
||||
}
|
||||
|
||||
const notify = Effect.fnUntraced(function* (result: Pick<Job.Background, "status" | "output" | "error">) {
|
||||
if (result.status === "running") return
|
||||
const text =
|
||||
result.status === "completed"
|
||||
? (result.output ?? "Subagent completed without a text response.")
|
||||
: result.status === "error"
|
||||
? (result.error ?? "Subagent failed")
|
||||
: "Subagent cancelled"
|
||||
yield* sessions
|
||||
.synthetic({
|
||||
id: background.notificationID,
|
||||
sessionID: recovery.parentSessionID,
|
||||
...(suspended.has(recovery.parentSessionID) ? { resume: false } : {}),
|
||||
description: recovery.description,
|
||||
text: `<subagent sessionID="${recovery.childSessionID}" state="${result.status}" description="${recovery.description}">\n${text}\n</subagent>`,
|
||||
metadata: {
|
||||
source: "subagent",
|
||||
childID: recovery.childSessionID,
|
||||
agent: recovery.agent,
|
||||
state: result.status,
|
||||
},
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
yield* jobs.completeBackground(background.notificationID)
|
||||
})
|
||||
|
||||
if (background.status !== "running") {
|
||||
yield* notify(background)
|
||||
return
|
||||
}
|
||||
if ((yield* execution.active).has(recovery.childSessionID)) return
|
||||
if (!(yield* prepareResume(recovery.childSessionID))) {
|
||||
yield* notify({ status: "error", error: RESUME_EXHAUSTED.message })
|
||||
return
|
||||
}
|
||||
|
||||
yield* jobs.start({
|
||||
id: background.id,
|
||||
type: "subagent",
|
||||
title: recovery.description,
|
||||
notificationID: background.notificationID,
|
||||
recovery,
|
||||
run: execution.resume(recovery.childSessionID).pipe(
|
||||
Effect.andThen(store.context(recovery.childSessionID)),
|
||||
Effect.map((messages) => {
|
||||
const assistant = messages.findLast(
|
||||
(message) =>
|
||||
message.type === "assistant" && message.time.completed !== undefined && message.error === undefined,
|
||||
)
|
||||
if (assistant?.type !== "assistant") return "Subagent completed without a text response."
|
||||
return (
|
||||
assistant.content
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("") || "Subagent completed without a text response."
|
||||
)
|
||||
}),
|
||||
),
|
||||
})
|
||||
yield* jobs.background(background.id)
|
||||
yield* jobs.wait({ id: background.id }).pipe(
|
||||
Effect.flatMap((result) => (result.info ? notify(result.info) : Effect.void)),
|
||||
Effect.ignore,
|
||||
Effect.forkIn(scope),
|
||||
)
|
||||
// Forked into the service scope so boot never waits on resumed turns;
|
||||
// resuming an already-live Session joins its execution. Drain failures
|
||||
// are logged and durably recorded by the execution layer.
|
||||
yield* execution.resume(sessionID).pipe(Effect.ignore, Effect.forkIn(scope))
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
resumeSuspendedSessions: Effect.gen(function* () {
|
||||
// Child claims never drive recovery (children are not resumed), so a
|
||||
// dead child's claim is noise no terminal will ever release. Clearing
|
||||
// is safe even against a live child: claims are recovery markers, not
|
||||
// locks, and children are excluded from that recovery.
|
||||
yield* store.releaseChildClaims
|
||||
const active = yield* execution.active
|
||||
// Early notices wait for root recovery's accounting, including roots that exhaust their budget.
|
||||
const suspended = new Set((yield* store.listSuspended()).filter((sessionID) => !active.has(sessionID)))
|
||||
const pending = yield* jobs.pendingBackground
|
||||
yield* store.releaseChildClaims(
|
||||
pending.flatMap((background) =>
|
||||
background.status === "running" && background.recovery.kind === "subagent"
|
||||
? [background.recovery.childSessionID]
|
||||
: [],
|
||||
),
|
||||
)
|
||||
yield* Effect.forEach(
|
||||
pending,
|
||||
Effect.fnUntraced(function* (background) {
|
||||
if ((yield* jobs.get(background.id))?.status === "running") return
|
||||
const recovery = background.recovery
|
||||
yield* recovery.kind === "shell"
|
||||
? recoverShell(background, recovery)
|
||||
: recoverSubagent(background, recovery, suspended)
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
|
||||
// Background completion can wake a parent, so inspect local ownership only after recovery.
|
||||
const resumed = yield* execution.active
|
||||
yield* Effect.forEach(
|
||||
(yield* store.listSuspended()).filter((sessionID) => !resumed.has(sessionID)),
|
||||
(sessionID) =>
|
||||
execution
|
||||
.resume(sessionID)
|
||||
.pipe(Effect.ignore, Effect.forkIn(scope), Effect.when(prepareResume(sessionID))),
|
||||
{ concurrency: "unbounded", discard: true },
|
||||
)
|
||||
// Async observers consult this set at delivery; later completions wake parents normally.
|
||||
suspended.clear()
|
||||
// Sessions already draining in this process keep their claim; resuming
|
||||
// them would only inject a stray continuation into a live turn.
|
||||
const orphaned = (yield* store.listSuspended()).filter((sessionID) => !active.has(sessionID))
|
||||
yield* Effect.forEach(orphaned, resumeOne, { concurrency: "unbounded", discard: true })
|
||||
}),
|
||||
})
|
||||
}),
|
||||
@@ -253,5 +111,5 @@ export const layer = (options?: Options) =>
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer(),
|
||||
deps: [SessionStore.node, SessionExecution.node, Bus.node, Job.node, Session.node],
|
||||
deps: [SessionStore.node, SessionExecution.node, Bus.node],
|
||||
})
|
||||
|
||||
@@ -78,143 +78,83 @@ const layer = Layer.effect(
|
||||
readonly continuation?: Continuation
|
||||
readonly promotable?: SessionInbox.Promotable
|
||||
}) {
|
||||
const sessionID = input.sessionID
|
||||
let force = input.force
|
||||
let continuing = input.continuation !== undefined
|
||||
let step = input.continuation?.step ?? 1
|
||||
let entering = true
|
||||
let continuation = input.continuation
|
||||
const promotable = input.promotable ?? "input"
|
||||
if (!force && !continuing) {
|
||||
const pending = yield* SessionInbox.nextPromotable(db, sessionID, "input")
|
||||
if (
|
||||
!pending ||
|
||||
(pending.delivery === "queue" &&
|
||||
promotable === "steer" &&
|
||||
pending.type !== "compaction" &&
|
||||
pending.type !== "move")
|
||||
)
|
||||
return DrainResult.Complete()
|
||||
}
|
||||
if (!force && !continuation && !(yield* eligible(input.sessionID, promotable))) return DrainResult.Complete()
|
||||
yield* plugins.flush
|
||||
yield* settleStaleToolCalls(sessionID)
|
||||
|
||||
const advanceToStep = Effect.fn("SessionRunner.advanceToStep")(() =>
|
||||
Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
while (true) {
|
||||
// Location entry and idle boundaries allow queued controls, not necessarily queued prompts.
|
||||
const pending = yield* SessionInbox.serialized(
|
||||
sessionID,
|
||||
Effect.gen(function* () {
|
||||
const next = yield* SessionInbox.nextPromotable(
|
||||
db,
|
||||
sessionID,
|
||||
entering || !continuing ? "input" : "steer",
|
||||
)
|
||||
if (next?.type === "compaction")
|
||||
yield* bus.publishAll([
|
||||
[SessionEvent.InboxDelivered, { sessionID, inboxID: next.id }],
|
||||
[SessionEvent.Compaction.Started, { sessionID, reason: "manual", recent: "", inputID: next.id }],
|
||||
])
|
||||
if (next?.type === "move")
|
||||
yield* restore(
|
||||
Effect.gen(function* () {
|
||||
yield* modelTransport.close(sessionID)
|
||||
yield* bus.publishAll([
|
||||
[SessionEvent.InboxDelivered, { sessionID, inboxID: next.id }],
|
||||
[SessionEvent.Moved, { sessionID, ...next.payload }],
|
||||
])
|
||||
}),
|
||||
)
|
||||
return next
|
||||
}),
|
||||
)
|
||||
if (!continuing && pending?.delivery !== "steer") {
|
||||
entering = true
|
||||
step = 1
|
||||
}
|
||||
if (pending?.type === "move")
|
||||
return DrainResult.Moved({ continuation: !entering && continuing ? { step } : undefined })
|
||||
if (pending?.type === "compaction") {
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
|
||||
const compacted = yield* restore(
|
||||
Effect.gen(function* () {
|
||||
return yield* compaction.compactManual({
|
||||
session,
|
||||
messages: yield* store.context(sessionID),
|
||||
inputID: pending.id,
|
||||
started: true,
|
||||
})
|
||||
}),
|
||||
).pipe(Effect.exit)
|
||||
if (Exit.isFailure(compacted)) {
|
||||
yield* bus.publish(SessionEvent.Compaction.Failed, {
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
error: Cause.hasInterruptsOnly(compacted.cause)
|
||||
? { type: "aborted", message: "Compaction cancelled" }
|
||||
: { type: "compaction.failed", message: Cause.pretty(compacted.cause) },
|
||||
inputID: pending.id,
|
||||
})
|
||||
return yield* Effect.failCause(compacted.cause)
|
||||
}
|
||||
force = false
|
||||
continue
|
||||
}
|
||||
if (!force && !continuing && (!pending || (pending.delivery === "queue" && promotable === "steer")))
|
||||
return DrainResult.Complete()
|
||||
return yield* restore(
|
||||
Effect.gen(function* () {
|
||||
const selected = yield* prepareContext(sessionID)
|
||||
const promoted = yield* SessionInbox.promote(
|
||||
db,
|
||||
bus,
|
||||
sessionID,
|
||||
entering && !continuing ? promotable : "steer",
|
||||
)
|
||||
if (promoted > 0 && !selected.session.parentID && SessionTitle.isUntitled(selected.session))
|
||||
yield* FiberMap.run(titles, sessionID, title.generate(sessionID).pipe(Effect.ignore), {
|
||||
onlyIfMissing: true,
|
||||
})
|
||||
if (promoted > 0) step = 1
|
||||
return { _tag: "Ready" as const, context: yield* context.load(selected) }
|
||||
}),
|
||||
)
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
yield* settleStaleToolCalls(input.sessionID)
|
||||
while (true) {
|
||||
const next = yield* advanceToStep()
|
||||
if (next._tag !== "Ready") return next
|
||||
continuing = yield* runStep(next.context, step)
|
||||
step++
|
||||
// Scope gates input promotion, not a between-step control that is next in line.
|
||||
if (yield* runPendingCompaction(input.sessionID, "input")) {
|
||||
force = false
|
||||
continue
|
||||
}
|
||||
if (yield* runPendingMove(input.sessionID, "input")) return DrainResult.Moved({})
|
||||
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, promotable)))
|
||||
return DrainResult.Complete()
|
||||
const result = yield* runSteps(input.sessionID, continuation, promotable)
|
||||
if (result._tag === "Moved") return result
|
||||
force = false
|
||||
entering = false
|
||||
continuation = undefined
|
||||
}
|
||||
})
|
||||
|
||||
const prepareContext = Effect.fn("SessionRunner.prepareContext")(function* (sessionID: SessionSchema.ID) {
|
||||
const selected = yield* context.select(sessionID)
|
||||
// A blocked initial instruction baseline must leave admitted input pending.
|
||||
yield* InstructionState.prepare(db, bus, selected.instructions, sessionID)
|
||||
return selected
|
||||
const eligible = Effect.fnUntraced(function* (sessionID: SessionSchema.ID, promotable: SessionInbox.Promotable) {
|
||||
if (yield* SessionInbox.has(db, sessionID, promotable)) return true
|
||||
if (promotable === "input") return false
|
||||
const next = yield* SessionInbox.nextPromotable(db, sessionID, "input")
|
||||
return next?.type === "compaction" || next?.type === "move"
|
||||
})
|
||||
|
||||
/** Queued inputs wait until the current model work reaches idle; later Steps absorb only steers. */
|
||||
const runSteps = Effect.fn("SessionRunner.runSteps")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
continuation: Continuation | undefined,
|
||||
drainPromotable: SessionInbox.Promotable,
|
||||
) {
|
||||
let promotable: SessionInbox.Promotable = continuation ? "steer" : drainPromotable
|
||||
let step = continuation?.step ?? 1
|
||||
let next = continuation
|
||||
let first = true
|
||||
while (true) {
|
||||
if (yield* runPendingCompaction(sessionID, "steer")) continue
|
||||
if (yield* runPendingMove(sessionID, "steer")) return DrainResult.Moved({ continuation: next })
|
||||
if (!first && !next && !(yield* SessionInbox.has(db, sessionID, "steer"))) return DrainResult.Complete()
|
||||
const result = yield* runStep(sessionID, promotable, step)
|
||||
first = false
|
||||
promotable = "steer"
|
||||
step = result.step + 1
|
||||
next = result.needsContinuation ? { step } : undefined
|
||||
}
|
||||
})
|
||||
|
||||
/** Owns logical Step policy; each attempt owns its streaming, tools, and durable settlement. */
|
||||
const runStep = Effect.fn("SessionRunner.runStep")(function* (first: SessionContext.Loaded, step: number) {
|
||||
const sessionID = first.session.id
|
||||
const runStep = Effect.fn("SessionRunner.runStep")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
promotable: SessionInbox.Promotable,
|
||||
step: number,
|
||||
) {
|
||||
let assistantMessageID = SessionMessage.ID.create()
|
||||
const retry = yield* Schedule.toStepWithSleep(SessionRunnerRetry.schedule(bus, sessionID))
|
||||
let initial: SessionContext.Loaded | undefined = first
|
||||
let currentPromotable: SessionInbox.Promotable | undefined = promotable
|
||||
let currentStep = step
|
||||
let recoverOverflow = true
|
||||
let recoverContinuation = true
|
||||
while (true) {
|
||||
// Reuse boundary preparation once; retries refresh context without delivering more input.
|
||||
const loaded = initial ?? (yield* prepareContext(sessionID).pipe(Effect.flatMap(context.load)))
|
||||
initial = undefined
|
||||
const selected = yield* context.select(sessionID)
|
||||
// A blocked initial instruction baseline must leave admitted input pending.
|
||||
yield* InstructionState.prepare(db, bus, selected.instructions, selected.session.id)
|
||||
const promoted = currentPromotable
|
||||
? yield* SessionInbox.promote(db, bus, selected.session.id, currentPromotable)
|
||||
: 0
|
||||
if (promoted > 0 && !selected.session.parentID && SessionTitle.isUntitled(selected.session))
|
||||
yield* FiberMap.run(titles, sessionID, title.generate(sessionID).pipe(Effect.ignore), {
|
||||
onlyIfMissing: true,
|
||||
})
|
||||
currentStep = promoted > 0 ? 1 : currentStep
|
||||
currentPromotable = undefined
|
||||
const loaded = yield* context.load(selected)
|
||||
const compactionInput = { session: loaded.session, messages: loaded.messages, resolved: loaded.model }
|
||||
if (compaction.required(compactionInput)) {
|
||||
const compacted = yield* compaction.compact(compactionInput)
|
||||
@@ -222,7 +162,7 @@ const layer = Layer.effect(
|
||||
assistantMessageID = SessionMessage.ID.create()
|
||||
continue
|
||||
}
|
||||
const stepLimitReached = loaded.agent.info.steps !== undefined && step >= loaded.agent.info.steps
|
||||
const stepLimitReached = loaded.agent.info.steps !== undefined && currentStep >= loaded.agent.info.steps
|
||||
const transcript = SessionModelRequest.baseTranscript({
|
||||
agent: loaded.agent.info,
|
||||
model: loaded.model,
|
||||
@@ -257,7 +197,7 @@ const layer = Layer.effect(
|
||||
: Effect.succeed(false),
|
||||
),
|
||||
})
|
||||
if (outcome._tag === "Completed") return outcome.needsContinuation
|
||||
if (outcome._tag === "Completed") return { needsContinuation: outcome.needsContinuation, step: currentStep }
|
||||
if (outcome._tag === "Retry" || outcome._tag === "Continue") {
|
||||
yield* retry({ cause: outcome.cause, error: outcome.error, assistantMessageID }).pipe(
|
||||
Pull.catchDone(() =>
|
||||
@@ -283,6 +223,77 @@ const layer = Layer.effect(
|
||||
}
|
||||
})
|
||||
|
||||
const runPendingCompaction = Effect.fn("SessionRunner.runPendingCompaction")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
promotable: SessionInbox.Promotable,
|
||||
) {
|
||||
return yield* Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
const pending = yield* SessionInbox.serialized(
|
||||
sessionID,
|
||||
Effect.gen(function* () {
|
||||
const selected = yield* SessionInbox.nextPromotable(db, sessionID, promotable)
|
||||
if (selected?.type !== "compaction") return
|
||||
yield* bus.publishAll([
|
||||
[SessionEvent.InboxDelivered, { sessionID, inboxID: selected.id }],
|
||||
[SessionEvent.Compaction.Started, { sessionID, reason: "manual", recent: "", inputID: selected.id }],
|
||||
])
|
||||
return selected
|
||||
}),
|
||||
)
|
||||
if (pending?.type !== "compaction") return false
|
||||
const session = yield* getSession(sessionID)
|
||||
const compacted = yield* restore(
|
||||
Effect.gen(function* () {
|
||||
return yield* compaction.compactManual({
|
||||
session,
|
||||
messages: yield* store.context(sessionID),
|
||||
inputID: pending.id,
|
||||
started: true,
|
||||
})
|
||||
}),
|
||||
).pipe(Effect.exit)
|
||||
if (Exit.isSuccess(compacted)) return true
|
||||
yield* bus.publish(SessionEvent.Compaction.Failed, {
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
error: Cause.hasInterruptsOnly(compacted.cause)
|
||||
? { type: "aborted", message: "Compaction cancelled" }
|
||||
: { type: "compaction.failed", message: Cause.pretty(compacted.cause) },
|
||||
inputID: pending.id,
|
||||
})
|
||||
return yield* Effect.failCause(compacted.cause)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const runPendingMove = Effect.fn("SessionRunner.runPendingMove")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
promotable: SessionInbox.Promotable,
|
||||
) {
|
||||
return yield* SessionInbox.serialized(
|
||||
sessionID,
|
||||
Effect.gen(function* () {
|
||||
const pending = yield* SessionInbox.nextPromotable(db, sessionID, promotable)
|
||||
if (pending?.type !== "move") return false
|
||||
yield* modelTransport.close(sessionID)
|
||||
yield* bus.publishAll([
|
||||
[SessionEvent.InboxDelivered, { sessionID, inboxID: pending.id }],
|
||||
[
|
||||
SessionEvent.Moved,
|
||||
{
|
||||
sessionID,
|
||||
location: pending.payload.location,
|
||||
projectID: pending.payload.projectID,
|
||||
subpath: pending.payload.subpath,
|
||||
},
|
||||
],
|
||||
])
|
||||
return true
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const settleStaleToolCalls = Effect.fn("SessionRunner.settleStaleToolCalls")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
@@ -290,24 +301,23 @@ const layer = Layer.effect(
|
||||
if (message.type !== "assistant") continue
|
||||
for (const tool of message.content) {
|
||||
if (tool.type !== "tool" || (tool.state.status !== "streaming" && tool.state.status !== "running")) continue
|
||||
const metadata = tool.state.status === "running" ? tool.state.metadata : undefined
|
||||
const childID =
|
||||
tool.name === "subagent" && typeof metadata?.sessionID === "string" ? metadata.sessionID : undefined
|
||||
yield* bus.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID,
|
||||
assistantMessageID: message.id,
|
||||
id: tool.id,
|
||||
error: {
|
||||
type: "aborted",
|
||||
message: `Tool execution interrupted: ${tool.name}${childID ? ` (sessionID: ${childID})` : ""}`,
|
||||
},
|
||||
...(metadata && Object.keys(metadata).length > 0 ? { metadata } : {}),
|
||||
error: { type: "aborted", message: `Tool execution interrupted: ${tool.name}` },
|
||||
executed: tool.executed === true,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) {
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
|
||||
return session
|
||||
})
|
||||
|
||||
return Service.of({ drain })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -327,10 +327,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID,
|
||||
id,
|
||||
error:
|
||||
tool.name === "subagent" && error.type === "aborted" && typeof tool.progress?.sessionID === "string"
|
||||
? { ...error, message: `${error.message} (sessionID: ${tool.progress.sessionID})` }
|
||||
: error,
|
||||
error,
|
||||
...failureSnapshot(tool, metadata),
|
||||
executed: tool.providerExecuted,
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SessionStore from "./store.js"
|
||||
|
||||
import { and, eq, isNotNull, isNull, notInArray, sql } from "drizzle-orm"
|
||||
import { and, eq, isNotNull, isNull, sql } from "drizzle-orm"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Database } from "../database/database.js"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
@@ -18,8 +18,9 @@ export interface Interface {
|
||||
messageID: SessionMessage.ID,
|
||||
) => Effect.Effect<{ readonly sessionID: Session.ID; readonly message: SessionMessage.Info } | undefined>
|
||||
/**
|
||||
* Top-level Sessions holding an execution claim. Recoverable background
|
||||
* children are resumed separately through their durable Job records.
|
||||
* Top-level Sessions holding an execution claim. Child (subagent) Sessions
|
||||
* are excluded: a resumed parent re-runs its tool call and spawns fresh
|
||||
* children, so resuming orphaned children would duplicate their work.
|
||||
*/
|
||||
readonly listSuspended: () => Effect.Effect<ReadonlyArray<Session.ID>>
|
||||
/**
|
||||
@@ -32,10 +33,11 @@ export interface Interface {
|
||||
/** Releases the claim and resets resume accounting. Terminal events call this on commit. */
|
||||
readonly release: (sessionID: Session.ID) => Effect.Effect<void>
|
||||
/**
|
||||
* Clears orphaned child claims except children owned by recoverable
|
||||
* background subagent jobs.
|
||||
* Clears orphaned child (subagent) claims. Children are never resumed
|
||||
* independently, so a dead child's claim is noise no terminal will ever
|
||||
* release.
|
||||
*/
|
||||
readonly releaseChildClaims: (recoverable: ReadonlyArray<Session.ID>) => Effect.Effect<void>
|
||||
readonly releaseChildClaims: Effect.Effect<void>
|
||||
/**
|
||||
* Durably counts one more resume of an orphaned claim, returning the new
|
||||
* total — or undefined when the Session no longer exists.
|
||||
@@ -101,20 +103,12 @@ const layer = Layer.effect(
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
releaseChildClaims: Effect.fn("SessionStore.releaseChildClaims")((recoverable) =>
|
||||
db
|
||||
.update(SessionTable)
|
||||
.set({ time_suspended: null, resume_attempts: 0, time_updated: sql`${SessionTable.time_updated}` })
|
||||
.where(
|
||||
and(
|
||||
isNotNull(SessionTable.time_suspended),
|
||||
isNotNull(SessionTable.parent_id),
|
||||
recoverable.length > 0 ? notInArray(SessionTable.id, Array.from(recoverable)) : undefined,
|
||||
),
|
||||
)
|
||||
.run()
|
||||
.pipe(Effect.orDie, Effect.asVoid),
|
||||
),
|
||||
releaseChildClaims: db
|
||||
.update(SessionTable)
|
||||
.set({ time_suspended: null, resume_attempts: 0, time_updated: sql`${SessionTable.time_updated}` })
|
||||
.where(and(isNotNull(SessionTable.time_suspended), isNotNull(SessionTable.parent_id)))
|
||||
.run()
|
||||
.pipe(Effect.orDie, Effect.asVoid, Effect.withSpan("SessionStore.releaseChildClaims")),
|
||||
countResume: Effect.fn("SessionStore.countResume")(function* (sessionID) {
|
||||
const row = yield* db
|
||||
.update(SessionTable)
|
||||
|
||||
@@ -134,8 +134,8 @@ const layer = () =>
|
||||
Effect.gen(function* () {
|
||||
for (const session of sessions.values()) {
|
||||
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
|
||||
// Teardown interrupts pending commands; it is not a terminal command failure.
|
||||
yield* Deferred.interrupt(session.done)
|
||||
// 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
|
||||
|
||||
@@ -7,17 +7,12 @@ import path from "path"
|
||||
import type { Node } from "web-tree-sitter"
|
||||
import { shellParserWasm } from "#shell-parser-wasm"
|
||||
import { ShellSelect } from "./select.js"
|
||||
import { Wildcard } from "../util/wildcard.js"
|
||||
|
||||
type Part = { type: string; text: string }
|
||||
type SourceToken = { raw: string; value: string }
|
||||
const CWD = new Set(["cd", "chdir", "popd", "pushd", "push-location", "set-location"])
|
||||
const POWERSHELL_PATH_FLAGS = new Set(["-literalpath", "-path"])
|
||||
|
||||
export type Result = {
|
||||
commands: Array<{ resource: string; save: string }>
|
||||
directories: string[]
|
||||
}
|
||||
|
||||
const ARITY: Record<string, number> = {
|
||||
cat: 1,
|
||||
cd: 1,
|
||||
@@ -157,7 +152,6 @@ const ARITY: Record<string, number> = {
|
||||
"yarn dlx": 3,
|
||||
"yarn run": 3,
|
||||
}
|
||||
const PREFIX_LENGTH = Math.max(...Object.values(ARITY))
|
||||
|
||||
export const scan = Effect.fnUntraced(function* (
|
||||
command: string,
|
||||
@@ -165,7 +159,7 @@ export const scan = Effect.fnUntraced(function* (
|
||||
cwd: string,
|
||||
options?: { portable?: boolean },
|
||||
) {
|
||||
if (options?.portable) return yield* scanPortable(command, shell, cwd)
|
||||
if (options?.portable) return yield* Effect.promise(() => scanPortable(command, shell, cwd))
|
||||
return yield* scanLegacy(command, shell, cwd)
|
||||
})
|
||||
|
||||
@@ -203,65 +197,416 @@ const scanLegacy = Effect.fnUntraced(function* (command: string, shell: string,
|
||||
)
|
||||
})
|
||||
|
||||
export const scanPortable = Effect.fnUntraced(function* (command: string, shell: string, cwd: string) {
|
||||
const { ShellScan } = yield* Effect.tryPromise({
|
||||
try: () => import("./scan.js"),
|
||||
catch: (cause) => new Error(`Portable shell scanner failed to load: ${cause}`, { cause }),
|
||||
})
|
||||
async function scanPortable(command: string, shell: string, cwd: string) {
|
||||
const { ShellScan } = await import("./scan.js")
|
||||
const powershell = ShellSelect.ps(shell)
|
||||
const result = powershell ? ShellScan.scanPowerShell(command) : ShellScan.scan(command)
|
||||
if (result.kind === "opaque")
|
||||
return yield* Effect.fail(new Error(`Portable shell scanner cannot analyze command: ${result.reason}`))
|
||||
if (result.kind === "opaque") return { commands: [{ resource: command, save: command }], directories: [] }
|
||||
const carriage = powershell ? command.search(/\r(?!\n)/) : -1
|
||||
if (carriage >= 0) return { commands: [], directories: [] }
|
||||
|
||||
const output: Result = { commands: [], directories: [] }
|
||||
for (const item of result.commands) {
|
||||
// The legacy command walk skips declarations, not the substitutions within them.
|
||||
if (item.declaration) continue
|
||||
const words = item.redirectWordCount === undefined ? item.rawWords : item.rawWords.slice(0, item.redirectWordCount)
|
||||
// The shipped PowerShell grammar treats bare statement-head foreach prefixes as control flow.
|
||||
if (powershell && item.statementHead && /^foreach(?:-|$)/i.test(words[0] ?? "")) continue
|
||||
const name = powershell ? words[0]?.toLowerCase() : words[0]
|
||||
if (CWD.has(name)) {
|
||||
output.directories.push(
|
||||
...directoryArgs(
|
||||
words.flatMap((text): Part[] => {
|
||||
const parameter = powershell ? /^(-(?:literalpath|path)):(.*)$/i.exec(text) : undefined
|
||||
if (parameter)
|
||||
return [
|
||||
{ type: "command_parameter", text: parameter[1] },
|
||||
{ type: "word", text: parameter[2] },
|
||||
]
|
||||
return [{ type: powershell && text.startsWith("-") ? "command_parameter" : "word", text }]
|
||||
}),
|
||||
powershell,
|
||||
cwd,
|
||||
shell,
|
||||
),
|
||||
const parsed = result.commands.reduce(
|
||||
(output, item) => {
|
||||
const index = item[ShellScan.Nested] ? -1 : command.indexOf(item.resource, output.cursor)
|
||||
const offset = item[ShellScan.Nested]
|
||||
? command.lastIndexOf(item.resource, output.cursor - 1)
|
||||
: index < 0
|
||||
? command.indexOf(item.resource)
|
||||
: index
|
||||
if (index >= 0) output.cursor = index + item.resource.length
|
||||
const before = command.slice(0, Math.max(0, offset))
|
||||
const name = powershell ? item.words[0]?.toLowerCase() : item.words[0]
|
||||
if (!name) return output
|
||||
if (powershell && name === "<") return output
|
||||
if (
|
||||
powershell &&
|
||||
name === "foreach-object" &&
|
||||
item.words.some((word) => word.startsWith("{")) &&
|
||||
!/\|\s*$/.test(before)
|
||||
)
|
||||
return output
|
||||
const tokens = powershell ? powerShellSourceTokens(item.resource) : sourceTokens(item.resource)
|
||||
const sourceHead = powershell ? item.words[0] : tokens.find((token) => token.value === item.words[0])?.raw
|
||||
if (CWD.has(name) && (powershell || sourceHead === item.words[0])) {
|
||||
output.directories.push(...portableDirectoryArgs(item.words, tokens, powershell, cwd, shell))
|
||||
return output
|
||||
}
|
||||
const save = powershell ? powerShellSourcePrefix(tokens, item.words) : bashSourcePrefix(tokens, item.words)
|
||||
output.commands.push({
|
||||
resource: powershell ? item.resource : bashResource(item.resource, before),
|
||||
save: `${save} *`,
|
||||
})
|
||||
return output
|
||||
},
|
||||
{
|
||||
commands: [] as Array<{ resource: string; save: string }>,
|
||||
directories: [] as string[],
|
||||
cursor: 0,
|
||||
},
|
||||
)
|
||||
return { commands: parsed.commands, directories: parsed.directories }
|
||||
}
|
||||
|
||||
function bashResource(resource: string, before: string) {
|
||||
if (!/(?:&&|\|\||\|&)\s*$|\|\s*$/.test(before)) return resource
|
||||
const redirect = bashRedirect(resource)
|
||||
return redirect < 0 ? resource : resource.slice(0, redirect).replace(/\d+$/, "").trim()
|
||||
}
|
||||
|
||||
function bashRedirect(resource: string) {
|
||||
let quote: "single" | "double" | undefined
|
||||
for (let index = 0; index < resource.length; index++) {
|
||||
const char = resource[index]
|
||||
if (quote === "single") {
|
||||
if (char === "'") quote = undefined
|
||||
continue
|
||||
}
|
||||
const selected = prefix(words.slice(0, PREFIX_LENGTH))
|
||||
const conventional = `${selected.join(" ")} *`
|
||||
const end = item.wordEnds?.[selected.length - 1]
|
||||
// Keep existing grants stable unless normalized spacing loses the original source boundary.
|
||||
const save =
|
||||
!powershell || end === undefined || Wildcard.match(item.resource, conventional)
|
||||
? conventional
|
||||
: (() => {
|
||||
const boundary =
|
||||
item.wordEnds?.find(
|
||||
(value) => value >= end && (value >= item.resource.length || /\s/.test(item.resource[value])),
|
||||
) ?? end
|
||||
const separator = /^\s+(?:`(?:\r\n|\r|\n)\s*)?/.exec(item.resource.slice(boundary))?.[0]
|
||||
return `${item.resource.slice(0, boundary)}${separator ?? " "}*`
|
||||
})()
|
||||
output.commands.push({
|
||||
resource: item.resource,
|
||||
save,
|
||||
})
|
||||
if (char === "\\") {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if (char === '"') {
|
||||
quote = quote === "double" ? undefined : "double"
|
||||
continue
|
||||
}
|
||||
if (quote === "double") {
|
||||
if (char === "$" && resource[index + 1] === "(") index = bashParenthesizedEnd(resource, index + 1)
|
||||
else if (char === "`") index = bashBacktickEnd(resource, index)
|
||||
continue
|
||||
}
|
||||
if (char === "'") {
|
||||
quote = "single"
|
||||
continue
|
||||
}
|
||||
if ((char === "$" || char === "<" || char === ">") && resource[index + 1] === "(") {
|
||||
index = bashParenthesizedEnd(resource, index + 1)
|
||||
continue
|
||||
}
|
||||
if (char === "`") {
|
||||
index = bashBacktickEnd(resource, index)
|
||||
continue
|
||||
}
|
||||
if (char === "<" || char === ">" || (char === "&" && resource[index + 1] === ">")) return index
|
||||
}
|
||||
return output
|
||||
})
|
||||
return -1
|
||||
}
|
||||
|
||||
function bashParenthesizedEnd(resource: string, start: number) {
|
||||
let level = 1
|
||||
let quote: "single" | "double" | undefined
|
||||
for (let index = start + 1; index < resource.length; index++) {
|
||||
const char = resource[index]
|
||||
if (quote === "single") {
|
||||
if (char === "'") quote = undefined
|
||||
continue
|
||||
}
|
||||
if (char === "\\") {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if (char === '"') {
|
||||
quote = quote === "double" ? undefined : "double"
|
||||
continue
|
||||
}
|
||||
if (quote === "double") continue
|
||||
if (char === "'") {
|
||||
quote = "single"
|
||||
continue
|
||||
}
|
||||
if (char === "(") level++
|
||||
if (char === ")" && --level === 0) return index
|
||||
}
|
||||
return resource.length - 1
|
||||
}
|
||||
|
||||
function bashBacktickEnd(resource: string, start: number) {
|
||||
for (let index = start + 1; index < resource.length; index++) {
|
||||
if (resource[index] === "\\") index++
|
||||
else if (resource[index] === "`") return index
|
||||
}
|
||||
return resource.length - 1
|
||||
}
|
||||
|
||||
function portableDirectoryArgs(
|
||||
command: string[],
|
||||
tokens: SourceToken[],
|
||||
powershell: boolean,
|
||||
cwd: string,
|
||||
shell: string,
|
||||
) {
|
||||
if (!powershell) {
|
||||
const start = tokens.findIndex((token) => token.value === command[0])
|
||||
if (start < 0) return []
|
||||
return directoryArgs(
|
||||
tokens.slice(start).map((token) => ({ type: "word", text: token.raw })),
|
||||
false,
|
||||
cwd,
|
||||
shell,
|
||||
)
|
||||
}
|
||||
|
||||
const start = tokens.findIndex((token) => token.value.toLowerCase() === command[0]?.toLowerCase())
|
||||
if (start < 0) return []
|
||||
const directories: string[] = []
|
||||
let expectsPath = false
|
||||
for (const part of tokens.slice(start + 1).map((token) => token.raw)) {
|
||||
if (expectsPath) {
|
||||
const value = directoryArgument(part, true, cwd, shell)
|
||||
if (value) directories.push(value)
|
||||
expectsPath = false
|
||||
continue
|
||||
}
|
||||
if (part.startsWith("-")) {
|
||||
expectsPath = POWERSHELL_PATH_FLAGS.has(part.toLowerCase())
|
||||
continue
|
||||
}
|
||||
const value = directoryArgument(part, true, cwd, shell)
|
||||
if (value) directories.push(value)
|
||||
}
|
||||
return directories
|
||||
}
|
||||
|
||||
function sourceTokens(resource: string) {
|
||||
const tokens: SourceToken[] = []
|
||||
let raw = ""
|
||||
let value = ""
|
||||
let quote: "single" | "double" | "backtick" | undefined
|
||||
let substitution = 0
|
||||
let redirect = false
|
||||
|
||||
const finish = () => {
|
||||
if (!raw) return
|
||||
if (!redirect) tokens.push({ raw, value })
|
||||
raw = ""
|
||||
value = ""
|
||||
redirect = false
|
||||
}
|
||||
|
||||
for (let index = 0; index < resource.length; index++) {
|
||||
const char = resource[index]
|
||||
if (quote === "single") {
|
||||
raw += char
|
||||
if (char === "'") quote = undefined
|
||||
else value += char
|
||||
continue
|
||||
}
|
||||
if (quote === "double") {
|
||||
raw += char
|
||||
if (char === '"') quote = undefined
|
||||
else if (char === "\\" && index + 1 < resource.length) {
|
||||
const next = resource[index + 1]
|
||||
if ('$`"\\\n'.includes(next)) {
|
||||
raw += resource[++index]
|
||||
if (next !== "\n") value += next
|
||||
} else value += char
|
||||
} else value += char
|
||||
continue
|
||||
}
|
||||
if (quote === "backtick") {
|
||||
raw += char
|
||||
value += char
|
||||
if (char === "`" && resource[index - 1] !== "\\") quote = undefined
|
||||
continue
|
||||
}
|
||||
if (char === "'") {
|
||||
raw += char
|
||||
quote = "single"
|
||||
continue
|
||||
}
|
||||
if (char === '"') {
|
||||
raw += char
|
||||
quote = "double"
|
||||
continue
|
||||
}
|
||||
if (char === "`") {
|
||||
raw += char
|
||||
value += char
|
||||
quote = "backtick"
|
||||
continue
|
||||
}
|
||||
if (char === "\\" && index + 1 < resource.length) {
|
||||
if (resource[index + 1] === "\n") {
|
||||
finish()
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if (!raw && /\s/.test(resource[index + 1])) {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
raw += char + resource[++index]
|
||||
value += resource[index]
|
||||
continue
|
||||
}
|
||||
if ((char === "<" || char === ">") && resource[index + 1] === "(") {
|
||||
const end = bashParenthesizedEnd(resource, index + 1)
|
||||
if (raw) {
|
||||
raw += resource.slice(index, end + 1)
|
||||
value += resource.slice(index, end + 1)
|
||||
}
|
||||
index = end
|
||||
continue
|
||||
}
|
||||
if (char === "$" && resource[index + 1] === "(") substitution++
|
||||
if (char === ")" && substitution > 0) substitution--
|
||||
if (substitution === 0 && /\s/.test(char)) {
|
||||
finish()
|
||||
continue
|
||||
}
|
||||
if (substitution === 0 && (char === "<" || char === ">" || (char === "&" && resource[index + 1] === ">"))) {
|
||||
if (/^\d+$/.test(value)) {
|
||||
raw = ""
|
||||
value = ""
|
||||
} else finish()
|
||||
redirect = true
|
||||
if (char === "&") index++
|
||||
while (/[<>&|]/.test(resource[index + 1] ?? "")) index++
|
||||
continue
|
||||
}
|
||||
raw += char
|
||||
value += char
|
||||
}
|
||||
finish()
|
||||
|
||||
return tokens
|
||||
}
|
||||
|
||||
function bashSourcePrefix(tokens: SourceToken[], words: string[]) {
|
||||
const start = tokens.findIndex((token) => token.value === words[0])
|
||||
if (start < 0) {
|
||||
const command = tokens.findIndex((token) => !/^[A-Za-z_][A-Za-z0-9_]*\+?=/.test(token.raw))
|
||||
return prefix(tokens.slice(Math.max(0, command)).map((token) => token.raw)).join(" ")
|
||||
}
|
||||
const source = tokens
|
||||
.slice(start)
|
||||
.map((token) => token.raw)
|
||||
.filter((token) => !/^\$\([\s\S]*\)$/.test(token) && !/^`[\s\S]*`$/.test(token))
|
||||
return prefix(source).join(" ")
|
||||
}
|
||||
|
||||
function powerShellSourcePrefix(tokens: SourceToken[], words: string[]) {
|
||||
const start = tokens.findIndex((token) => token.value.toLowerCase() === words[0]?.toLowerCase())
|
||||
if (start < 0) return prefix(words).join(" ")
|
||||
return prefix(tokens.slice(start).map((token) => token.raw)).join(" ")
|
||||
}
|
||||
|
||||
function powerShellSourceTokens(resource: string) {
|
||||
const tokens: SourceToken[] = []
|
||||
let raw = ""
|
||||
let value = ""
|
||||
let quote: "single" | "double" | undefined
|
||||
let redirect = false
|
||||
|
||||
const finish = () => {
|
||||
if (!raw) return
|
||||
if (!redirect) tokens.push({ raw, value })
|
||||
raw = ""
|
||||
value = ""
|
||||
redirect = false
|
||||
}
|
||||
|
||||
for (let index = 0; index < resource.length; index++) {
|
||||
const char = resource[index]
|
||||
if (quote === "single") {
|
||||
raw += char
|
||||
if (char === "'" && resource[index + 1] === "'") {
|
||||
raw += resource[++index]
|
||||
value += "'"
|
||||
} else if (char === "'") quote = undefined
|
||||
else value += char
|
||||
continue
|
||||
}
|
||||
if (quote === "double") {
|
||||
raw += char
|
||||
if (char === '"') quote = undefined
|
||||
else if (char === "`" && index + 1 < resource.length) {
|
||||
raw += resource[++index]
|
||||
value += resource[index]
|
||||
} else value += char
|
||||
continue
|
||||
}
|
||||
if (char === "'") {
|
||||
raw += char
|
||||
quote = "single"
|
||||
continue
|
||||
}
|
||||
if (char === '"') {
|
||||
raw += char
|
||||
quote = "double"
|
||||
continue
|
||||
}
|
||||
if (char === "`" && index + 1 < resource.length) {
|
||||
raw += char + resource[++index]
|
||||
if (resource[index] !== "\n" && resource[index] !== "\r") value += resource[index]
|
||||
continue
|
||||
}
|
||||
if (char === "{" && !raw) {
|
||||
const end = powerShellBracedEnd(resource, index)
|
||||
raw = resource.slice(index, end + 1)
|
||||
value = raw
|
||||
index = end
|
||||
continue
|
||||
}
|
||||
if (/\s/.test(char)) {
|
||||
finish()
|
||||
continue
|
||||
}
|
||||
if (char === ">") {
|
||||
if (resource[index + 1] && !/[\s>&]/.test(resource[index + 1])) {
|
||||
raw += char
|
||||
value += char
|
||||
continue
|
||||
}
|
||||
if (/^\d+$/.test(value)) {
|
||||
raw = ""
|
||||
value = ""
|
||||
} else if (raw === "*") {
|
||||
raw = ""
|
||||
value = ""
|
||||
} else finish()
|
||||
redirect = true
|
||||
while (/[>&\d]/.test(resource[index + 1] ?? "")) index++
|
||||
continue
|
||||
}
|
||||
if ((char === "&" || char === ".") && !raw && tokens.length === 0) continue
|
||||
raw += char
|
||||
value += char
|
||||
}
|
||||
finish()
|
||||
return tokens
|
||||
}
|
||||
|
||||
function powerShellBracedEnd(resource: string, start: number) {
|
||||
let level = 1
|
||||
let quote: "single" | "double" | undefined
|
||||
for (let index = start + 1; index < resource.length; index++) {
|
||||
const char = resource[index]
|
||||
if (char === "`" && quote !== "single") {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if (quote === "single") {
|
||||
if (char === "'" && resource[index + 1] === "'") index++
|
||||
else if (char === "'") quote = undefined
|
||||
continue
|
||||
}
|
||||
if (quote === "double") {
|
||||
if (char === '"') quote = undefined
|
||||
continue
|
||||
}
|
||||
if (char === "'") {
|
||||
quote = "single"
|
||||
continue
|
||||
}
|
||||
if (char === '"') {
|
||||
quote = "double"
|
||||
continue
|
||||
}
|
||||
if (char === "{") level++
|
||||
if (char === "}" && --level === 0) return index
|
||||
}
|
||||
return resource.length - 1
|
||||
}
|
||||
|
||||
function parts(node: Node) {
|
||||
return Array.from({ length: node.childCount }).flatMap((_, index): Part[] => {
|
||||
|
||||
+371
-1167
File diff suppressed because it is too large
Load Diff
+139
-132
@@ -4,8 +4,7 @@ export type { Context, Metadata, Options, Result } from "@opencode-ai/schema/too
|
||||
|
||||
import { ToolDefinition, type ToolCall } from "@opencode-ai/ai"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import type { ToolDraft } from "@opencode-ai/plugin/effect/tool"
|
||||
import { Context, Effect, Layer, Result, Schema, SchemaIssue, Types } from "effect"
|
||||
import { Context, Effect, Layer, Schema, SchemaIssue, Scope, Semaphore } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import type { Agent } from "./agent.js"
|
||||
import { CodeModeCatalog } from "./codemode/catalog.js"
|
||||
@@ -15,7 +14,6 @@ import { Permission } from "./permission.js"
|
||||
import { PluginHooks } from "./plugin/hooks.js"
|
||||
import { SessionMessage } from "./session/message.js"
|
||||
import { SessionSchema } from "./session/schema.js"
|
||||
import { State } from "./state.js"
|
||||
import { definition, execute, normalizeContent } from "./tool/runtime.js"
|
||||
import { Wildcard } from "./util/wildcard.js"
|
||||
|
||||
@@ -24,7 +22,10 @@ export class RegistrationError extends Schema.TaggedError<RegistrationError>()("
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export interface Interface extends State.Transformable<ToolDraft> {
|
||||
export interface Interface {
|
||||
readonly transform: (
|
||||
callback: (draft: { readonly add: (tool: Tool.Info) => void }) => void,
|
||||
) => Effect.Effect<void, RegistrationError, Scope.Scope>
|
||||
readonly snapshot: (permissions?: Permission.Ruleset) => Effect.Effect<Snapshot>
|
||||
}
|
||||
|
||||
@@ -78,6 +79,9 @@ const layer = Layer.effect(
|
||||
]
|
||||
})
|
||||
|
||||
const local = new Map<string, Array<{ readonly token: object; readonly tool: Tool.Info }>>()
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
|
||||
const executeTool = Effect.fn("Tool.execute")(function* (
|
||||
tool: Tool.Info,
|
||||
name: string,
|
||||
@@ -133,123 +137,122 @@ const layer = Layer.effect(
|
||||
}
|
||||
})
|
||||
|
||||
const state = State.create({
|
||||
name: "tool",
|
||||
initial: () => ({
|
||||
tools: new Map<string, Types.Mutable<Tool.Info>>(),
|
||||
errors: new Array<{ tool: Tool.Info; error: RegistrationError }>(),
|
||||
}),
|
||||
draft: (data) => data,
|
||||
finalize: (draft) =>
|
||||
Effect.forEach(
|
||||
draft.errors,
|
||||
(entry) =>
|
||||
Effect.logError("Skipping invalid tool registration", {
|
||||
name: entry.tool.name,
|
||||
namespace: entry.tool.options?.namespace,
|
||||
error: entry.error.message,
|
||||
}),
|
||||
{ discard: true },
|
||||
const transform: Interface["transform"] = Effect.fn("Tool.transform")(function* (callback) {
|
||||
const tools: Array<Tool.Info> = []
|
||||
yield* Effect.sync(() => callback({ add: (tool) => tools.push(tool) }))
|
||||
yield* Effect.forEach(
|
||||
tools.flatMap((tool) => (tool.options?.namespace === undefined ? [] : [tool.options.namespace])),
|
||||
validateNamespace,
|
||||
{ discard: true },
|
||||
)
|
||||
const entries = normalizedEntries(tools)
|
||||
yield* Effect.forEach(entries, (entry) => validateName(normalizedName(entry.tool)), { discard: true })
|
||||
const collision = entries.find(
|
||||
(entry, index) => entries.findIndex((candidate) => candidate.key === entry.key) !== index,
|
||||
)
|
||||
if (collision)
|
||||
return yield* Effect.fail(
|
||||
new RegistrationError({
|
||||
name: collision.key,
|
||||
message: `Duplicate normalized tool name: ${collision.key}`,
|
||||
}),
|
||||
)
|
||||
const reserved = entries.find((entry) => entry.tool.options?.codemode === false && entry.key === "execute")
|
||||
if (reserved)
|
||||
return yield* Effect.fail(
|
||||
new RegistrationError({
|
||||
name: reserved.key,
|
||||
message: 'Tool name "execute" is reserved for CodeMode',
|
||||
}),
|
||||
)
|
||||
if (entries.length === 0) return
|
||||
yield* Effect.forEach(
|
||||
entries,
|
||||
(entry) =>
|
||||
Effect.try({
|
||||
try: () => ToolDefinition.make(definition(entry.tool)),
|
||||
catch: (error) =>
|
||||
new RegistrationError({
|
||||
name: entry.key,
|
||||
message: `Invalid tool definition ${entry.key}: ${schemaMakeError(error)}`,
|
||||
}),
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
yield* Effect.uninterruptible(
|
||||
lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const token = {}
|
||||
for (const entry of entries)
|
||||
local.set(entry.key, [...(local.get(entry.key) ?? []), { token, tool: entry.tool }])
|
||||
yield* Effect.addFinalizer(() =>
|
||||
lock.withPermit(
|
||||
Effect.sync(() => {
|
||||
for (const entry of entries) {
|
||||
const remaining = local.get(entry.key)?.filter((item) => item.token !== token) ?? []
|
||||
if (remaining.length > 0) local.set(entry.key, remaining)
|
||||
else local.delete(entry.key)
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
transform: (callback) =>
|
||||
state.transform((draft) => {
|
||||
// Preserve rejection of ambiguous adds within one transform, without rejecting later overrides.
|
||||
const added = new Map<string, Tool.Info | undefined>()
|
||||
callback({
|
||||
add: (tool) => {
|
||||
const error = registrationError(tool)
|
||||
if (error) {
|
||||
draft.errors.push({ tool, error })
|
||||
return
|
||||
}
|
||||
const id = effectiveName(tool)
|
||||
if (added.has(id)) {
|
||||
draft.errors.push({
|
||||
tool,
|
||||
error: new RegistrationError({ name: id, message: `Duplicate normalized tool name: ${id}` }),
|
||||
})
|
||||
const previous = added.get(id)
|
||||
if (previous) {
|
||||
draft.tools.set(id, previous)
|
||||
return
|
||||
}
|
||||
draft.tools.delete(id)
|
||||
return
|
||||
}
|
||||
added.set(id, draft.tools.get(id))
|
||||
draft.tools.set(id, { ...tool })
|
||||
},
|
||||
update: (id, update) => {
|
||||
const current = draft.tools.get(id)
|
||||
if (!current) return
|
||||
const tool = { ...current }
|
||||
update(tool)
|
||||
tool.name = current.name
|
||||
if (tool.options?.namespace !== current.options?.namespace)
|
||||
tool.options = { ...tool.options, namespace: current.options?.namespace }
|
||||
const error = registrationError(tool)
|
||||
if (error) {
|
||||
draft.errors.push({ tool, error })
|
||||
return
|
||||
}
|
||||
draft.tools.set(id, tool)
|
||||
},
|
||||
remove: (id) => {
|
||||
draft.tools.delete(id)
|
||||
added.delete(id)
|
||||
},
|
||||
})
|
||||
}),
|
||||
reload: state.reload,
|
||||
transform,
|
||||
snapshot: Effect.fn("Tool.snapshot")((permissions) =>
|
||||
Effect.sync(() => {
|
||||
const active = new Map<string, Tool.Info>()
|
||||
const rules = permissions ?? []
|
||||
for (const [name, tool] of state.get().tools) {
|
||||
if (whollyDisabled(tool.options?.permission ?? name, rules)) continue
|
||||
active.set(name, tool)
|
||||
}
|
||||
const direct = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode === false))
|
||||
const codemode = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode !== false))
|
||||
const executeRule = rules.findLast((rule) => Wildcard.match("execute", rule.action))
|
||||
const codemodeEnabled = executeRule?.resource !== "*" || executeRule.effect !== "deny"
|
||||
const codemodeTool = codemodeEnabled
|
||||
? CodeModeTool.create(codemode, (name, tool, input, context) => executeTool(tool, name, input, context))
|
||||
: undefined
|
||||
const codeModeCatalog = codemodeEnabled ? CodeModeTool.catalog(codemode) : undefined
|
||||
return {
|
||||
...(codeModeCatalog === undefined ? {} : { codeModeCatalog }),
|
||||
definitions: [
|
||||
...Array.from(direct)
|
||||
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
||||
.map(([, tool]) => definition(tool)),
|
||||
...(codemodeTool ? [definition(codemodeTool)] : []),
|
||||
],
|
||||
execute: (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly messageID: SessionMessage.ID
|
||||
readonly call: ToolCall
|
||||
readonly progress?: (update: Tool.Metadata) => Effect.Effect<void>
|
||||
}) => {
|
||||
const context: Tool.Context = {
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
messageID: input.messageID,
|
||||
id: Tool.CallID.make(input.call.id),
|
||||
progress: input.progress ?? (() => Effect.void),
|
||||
}
|
||||
if (input.call.name === "execute" && codemodeTool)
|
||||
return executeTool(codemodeTool, input.call.name, input.call.input, context)
|
||||
const tool = direct.get(input.call.name)
|
||||
if (tool) return executeTool(tool, input.call.name, input.call.input, context)
|
||||
return new Tool.Error({ message: `Unknown tool: ${input.call.name}` })
|
||||
},
|
||||
}
|
||||
}),
|
||||
lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const active = new Map<string, Tool.Info>()
|
||||
const rules = permissions ?? []
|
||||
for (const [name, entries] of local) {
|
||||
const tool = entries.at(-1)?.tool
|
||||
if (!tool) continue
|
||||
if (whollyDisabled(tool.options?.permission ?? name, rules)) continue
|
||||
active.set(name, tool)
|
||||
}
|
||||
const direct = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode === false))
|
||||
const codemode = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode !== false))
|
||||
const executeRule = rules.findLast((rule) => Wildcard.match("execute", rule.action))
|
||||
const codemodeEnabled = executeRule?.resource !== "*" || executeRule.effect !== "deny"
|
||||
const codemodeTool = codemodeEnabled
|
||||
? CodeModeTool.create(codemode, (name, tool, input, context) => executeTool(tool, name, input, context))
|
||||
: undefined
|
||||
const codeModeCatalog = codemodeEnabled ? CodeModeTool.catalog(codemode) : undefined
|
||||
return {
|
||||
...(codeModeCatalog === undefined ? {} : { codeModeCatalog }),
|
||||
definitions: [
|
||||
...Array.from(direct)
|
||||
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
||||
.map(([, tool]) => definition(tool)),
|
||||
...(codemodeTool ? [definition(codemodeTool)] : []),
|
||||
],
|
||||
execute: (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly messageID: SessionMessage.ID
|
||||
readonly call: ToolCall
|
||||
readonly progress?: (update: Tool.Metadata) => Effect.Effect<void>
|
||||
}) => {
|
||||
const context: Tool.Context = {
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
messageID: input.messageID,
|
||||
id: Tool.CallID.make(input.call.id),
|
||||
progress: input.progress ?? (() => Effect.void),
|
||||
}
|
||||
if (input.call.name === "execute" && codemodeTool)
|
||||
return executeTool(codemodeTool, input.call.name, input.call.input, context)
|
||||
const tool = direct.get(input.call.name)
|
||||
if (tool) return executeTool(tool, input.call.name, input.call.input, context)
|
||||
return new Tool.Error({ message: `Unknown tool: ${input.call.name}` })
|
||||
},
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
})
|
||||
}),
|
||||
@@ -267,22 +270,20 @@ function schemaMakeError(error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
function registrationError(tool: Tool.Info) {
|
||||
const namespace = tool.options?.namespace
|
||||
if (namespace !== undefined && !namespace.split(".").every((segment) => /^[A-Za-z0-9_-]{1,64}$/.test(segment)))
|
||||
return new RegistrationError({ name: namespace, message: `Invalid tool namespace: ${JSON.stringify(namespace)}` })
|
||||
const name = normalizedName(tool)
|
||||
if (!/^[A-Za-z0-9_-]{1,64}$/.test(name)) return new RegistrationError({ name, message: `Invalid tool name: ${name}` })
|
||||
const id = effectiveName(tool)
|
||||
if (tool.options?.codemode === false && id === "execute")
|
||||
return new RegistrationError({ name: id, message: 'Tool name "execute" is reserved for CodeMode' })
|
||||
const result = Result.try({
|
||||
try: () => ToolDefinition.make(definition(tool)),
|
||||
catch: (error) =>
|
||||
new RegistrationError({ name: id, message: `Invalid tool definition ${id}: ${schemaMakeError(error)}` }),
|
||||
})
|
||||
return Result.isFailure(result) ? result.failure : undefined
|
||||
}
|
||||
const validateName = (name: string) =>
|
||||
/^[A-Za-z0-9_-]{1,64}$/.test(name)
|
||||
? Effect.void
|
||||
: Effect.fail(new RegistrationError({ name, message: `Invalid tool name: ${name}` }))
|
||||
|
||||
const validateNamespace = (namespace: string) =>
|
||||
namespace.split(".").every((segment) => /^[A-Za-z0-9_-]{1,64}$/.test(segment))
|
||||
? Effect.void
|
||||
: Effect.fail(
|
||||
new RegistrationError({
|
||||
name: namespace,
|
||||
message: `Invalid tool namespace: ${JSON.stringify(namespace)}`,
|
||||
}),
|
||||
)
|
||||
|
||||
const normalizedName = (tool: Tool.Info) => tool.name.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
|
||||
@@ -291,6 +292,12 @@ const effectiveName = (tool: Tool.Info) =>
|
||||
? normalizedName(tool)
|
||||
: `${tool.options.namespace.replaceAll(".", "_")}_${normalizedName(tool)}`
|
||||
|
||||
const normalizedEntries = (tools: ReadonlyArray<Tool.Info>) =>
|
||||
tools.map((tool) => ({
|
||||
key: effectiveName(tool),
|
||||
tool,
|
||||
}))
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
|
||||
@@ -30,18 +30,17 @@ Leaves own resolution, permission, and side-effect ordering. Translate only expe
|
||||
|
||||
## Registration
|
||||
|
||||
Built-ins, plugins, and MCP install tools through `Tool.Service.transform`, adding complete tool objects to the draft. A tool may provide a namespace, which flattens direct model names to `<namespace>_<tool>`, and defaults into CodeMode (`codemode` defaults true; `codemode: false` keeps the tool on the provider's native tool list).
|
||||
Built-ins, plugins, and MCP install tools through `ToolRegistry.Service.transform`, adding complete tool objects to the draft. A tool may provide a namespace, which flattens direct model names to `<namespace>_<tool>`, and defaults into CodeMode (`codemode` defaults true; `codemode: false` keeps the tool on the provider's native tool list).
|
||||
|
||||
Registrations are scoped:
|
||||
|
||||
- Tool transforms use the shared `State.create` lifecycle, like agents and skills: `add`, `update`, and `remove` replay in registration order when state is rebuilt.
|
||||
- `update` and `remove` do nothing for missing tools. `add` requires a complete tool definition.
|
||||
- Disposing a registration or closing its scope removes its transform and rebuilds the remaining state. `reload` replays transforms after their external inputs change.
|
||||
- The latest active same-placement registration wins.
|
||||
- Closing any registration removes only that registration and reveals the next active one.
|
||||
- Each model request captures the effective tools it advertises; later registration changes affect later requests.
|
||||
|
||||
Type safety ends at registration. The registry validates model input and declared output at runtime and should not carry producer schema generics through storage or execution.
|
||||
|
||||
`Tool.Service` is Location-scoped. Do not make the registry process-global or construct a separate application-tool service for each Location.
|
||||
`ToolRegistry.Service` is Location-scoped. Do not make the registry process-global or construct a separate application-tool service for each Location.
|
||||
|
||||
## Permissions
|
||||
|
||||
@@ -57,4 +56,4 @@ Producer capture limits remain local to producers. For example, Bash keeps `AppP
|
||||
|
||||
## Current Gaps
|
||||
|
||||
- Future Session-scoped registrations still need an explicit canonical registration design.
|
||||
- MCP and future Session-scoped registrations still need an explicit canonical registration design.
|
||||
|
||||
@@ -2,7 +2,7 @@ export * as McpTool from "./mcp.js"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { McpEvent } from "@opencode-ai/schema/mcp-event"
|
||||
import { Context, Effect, Fiber, type JsonSchema, Layer, Semaphore, Stream } from "effect"
|
||||
import { Context, Effect, Exit, Fiber, type JsonSchema, Layer, Scope, Semaphore, Stream } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Bus } from "../bus.js"
|
||||
|
||||
@@ -30,88 +30,94 @@ export const layer = Layer.effect(
|
||||
const tools = yield* Tool.Service
|
||||
const bus = yield* Bus.Service
|
||||
const permission = yield* Permission.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
let discovered: MCP.Tool[] = []
|
||||
let current: Scope.Closeable | undefined
|
||||
|
||||
// Keep the source's position so later plugin transforms also apply after MCP refreshes.
|
||||
yield* tools.transform((draft) => {
|
||||
for (const tool of discovered) {
|
||||
const schema = (tool.inputSchema ?? {}) as JsonSchema.JsonSchema
|
||||
draft.add({
|
||||
name: tool.name,
|
||||
options: { namespace: namespace(tool.server), codemode: tool.codemode !== false },
|
||||
description: tool.description ?? "",
|
||||
input: {
|
||||
...schema,
|
||||
type: "object",
|
||||
properties: schema.properties ?? {},
|
||||
additionalProperties: false,
|
||||
},
|
||||
output: (tool.outputSchema ?? {}) as JsonSchema.JsonSchema,
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* permission.assert({
|
||||
action: name(tool.server, tool.name),
|
||||
resources: ["*"],
|
||||
save: ["*"],
|
||||
metadata: {},
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: {
|
||||
type: "tool",
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
},
|
||||
})
|
||||
const result = yield* mcp
|
||||
.callTool({
|
||||
server: tool.server,
|
||||
name: tool.name,
|
||||
args: (input ?? {}) as Record<string, unknown>,
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchTags({
|
||||
"MCP.NotFoundError": (error) =>
|
||||
new ToolFailure({ message: `MCP server "${error.server}" is not available` }),
|
||||
"MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }),
|
||||
}),
|
||||
)
|
||||
if (result.isError)
|
||||
return yield* new ToolFailure({
|
||||
message:
|
||||
result.content
|
||||
.flatMap((part) => (part.type === "text" ? [part.text] : []))
|
||||
.join("\n")
|
||||
.trim() || "MCP tool returned an error",
|
||||
})
|
||||
const content = result.content.map((part) =>
|
||||
part.type === "text"
|
||||
? { type: "text" as const, text: part.text }
|
||||
: {
|
||||
type: "file" as const,
|
||||
uri: `data:${part.mimeType};base64,${part.data}`,
|
||||
mime: part.mimeType,
|
||||
},
|
||||
)
|
||||
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
|
||||
return {
|
||||
output: result.structured ?? (text === "" ? null : text),
|
||||
...(content.length === 0 ? {} : { content }),
|
||||
}
|
||||
}).pipe(
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure
|
||||
? error
|
||||
: new ToolFailure({ message: `Unable to execute ${name(tool.server, tool.name)}` }),
|
||||
),
|
||||
),
|
||||
})
|
||||
}
|
||||
})
|
||||
// Register the current tool set under a fresh child scope, then close the previous one so the
|
||||
// registry never has a gap where MCP tools disappear mid-swap.
|
||||
const reconcile = lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
discovered = yield* mcp.tools()
|
||||
yield* tools.reload()
|
||||
const discovered = yield* mcp.tools()
|
||||
const next = yield* Scope.fork(scope)
|
||||
yield* tools
|
||||
.transform((draft) => {
|
||||
for (const tool of discovered) {
|
||||
const schema = (tool.inputSchema ?? {}) as JsonSchema.JsonSchema
|
||||
draft.add({
|
||||
name: tool.name,
|
||||
options: { namespace: namespace(tool.server), codemode: tool.codemode !== false },
|
||||
description: tool.description ?? "",
|
||||
input: {
|
||||
...schema,
|
||||
type: "object",
|
||||
properties: schema.properties ?? {},
|
||||
additionalProperties: false,
|
||||
},
|
||||
output: (tool.outputSchema ?? {}) as JsonSchema.JsonSchema,
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* permission.assert({
|
||||
action: name(tool.server, tool.name),
|
||||
resources: ["*"],
|
||||
save: ["*"],
|
||||
metadata: {},
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: {
|
||||
type: "tool",
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
},
|
||||
})
|
||||
const result = yield* mcp
|
||||
.callTool({
|
||||
server: tool.server,
|
||||
name: tool.name,
|
||||
args: (input ?? {}) as Record<string, unknown>,
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchTags({
|
||||
"MCP.NotFoundError": (error) =>
|
||||
new ToolFailure({ message: `MCP server "${error.server}" is not available` }),
|
||||
"MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }),
|
||||
}),
|
||||
)
|
||||
if (result.isError)
|
||||
return yield* new ToolFailure({
|
||||
message:
|
||||
result.content
|
||||
.flatMap((part) => (part.type === "text" ? [part.text] : []))
|
||||
.join("\n")
|
||||
.trim() || "MCP tool returned an error",
|
||||
})
|
||||
const content = result.content.map((part) =>
|
||||
part.type === "text"
|
||||
? { type: "text" as const, text: part.text }
|
||||
: {
|
||||
type: "file" as const,
|
||||
uri: `data:${part.mimeType};base64,${part.data}`,
|
||||
mime: part.mimeType,
|
||||
},
|
||||
)
|
||||
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
|
||||
return {
|
||||
output: result.structured ?? (text === "" ? null : text),
|
||||
...(content.length === 0 ? {} : { content }),
|
||||
}
|
||||
}).pipe(
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure
|
||||
? error
|
||||
: new ToolFailure({ message: `Unable to execute ${name(tool.server, tool.name)}` }),
|
||||
),
|
||||
),
|
||||
})
|
||||
}
|
||||
})
|
||||
.pipe(Scope.provide(next), Effect.orDie)
|
||||
if (current) yield* Scope.close(current, Exit.void)
|
||||
current = next
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -115,45 +115,56 @@ export const Plugin = {
|
||||
const permission = yield* Permission.Service
|
||||
const config = yield* Config.Service
|
||||
|
||||
const notifyWhenDone = Effect.fn("ShellTool.notifyWhenDone")(
|
||||
function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
id: string,
|
||||
shellID: string,
|
||||
command: string,
|
||||
settled: Deferred.Deferred<Output>,
|
||||
) {
|
||||
const info = (yield* runtime.job.wait({ id })).info
|
||||
if (!info || info.status === "running") return
|
||||
const output = info.status === "completed" ? yield* Deferred.await(settled) : undefined
|
||||
const text = output
|
||||
? resultMessages(output).join("\n\n")
|
||||
: info.status === "error"
|
||||
? (info.error ?? "Command failed")
|
||||
: "Command cancelled"
|
||||
yield* runtime.session.synthetic({
|
||||
...(info.notificationID ? { id: info.notificationID } : {}),
|
||||
sessionID,
|
||||
text: `<shell id="${id}" state="${info.status}" command="${command}">\n${text}\n</shell>`,
|
||||
description: command,
|
||||
metadata: {
|
||||
source: "shell",
|
||||
jobID: id,
|
||||
shellID,
|
||||
state: info.status,
|
||||
...(output
|
||||
? {
|
||||
truncated: output.truncated,
|
||||
...(output.exit !== undefined ? { exit: output.exit } : {}),
|
||||
...(output.timeout !== undefined ? { timeout: output.timeout } : {}),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
})
|
||||
if (info.notificationID) yield* runtime.job.completeBackground(info.notificationID)
|
||||
},
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
)
|
||||
const notifyWhenDone = Effect.fn("ShellTool.notifyWhenDone")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
id: string,
|
||||
shellID: string,
|
||||
command: string,
|
||||
settled: Deferred.Deferred<Output>,
|
||||
) {
|
||||
yield* runtime.job.wait({ id: id }).pipe(
|
||||
Effect.flatMap((result) =>
|
||||
Effect.gen(function* () {
|
||||
const info = result.info
|
||||
if (!info) return
|
||||
const state =
|
||||
info.status === "completed"
|
||||
? "completed"
|
||||
: info.status === "error"
|
||||
? "error"
|
||||
: info.status === "cancelled"
|
||||
? "cancelled"
|
||||
: undefined
|
||||
if (state === undefined) return
|
||||
const output = state === "completed" ? yield* Deferred.await(settled) : undefined
|
||||
const text = output
|
||||
? resultMessages(output).join("\n\n")
|
||||
: state === "error"
|
||||
? (info.error ?? "Command failed")
|
||||
: "Command cancelled"
|
||||
yield* runtime.session.synthetic({
|
||||
sessionID,
|
||||
text: `<shell id="${id}" state="${state}" command="${command}">\n${text}\n</shell>`,
|
||||
description: command,
|
||||
metadata: {
|
||||
source: "shell",
|
||||
jobID: id,
|
||||
shellID,
|
||||
state,
|
||||
...(output
|
||||
? {
|
||||
truncated: output.truncated,
|
||||
...(output.exit !== undefined ? { exit: output.exit } : {}),
|
||||
...(output.timeout !== undefined ? { timeout: output.timeout } : {}),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
})
|
||||
}),
|
||||
),
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
)
|
||||
})
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((draft) =>
|
||||
@@ -275,7 +286,7 @@ export const Plugin = {
|
||||
const settled = yield* Deferred.make<Output>()
|
||||
const run = settleShell().pipe(
|
||||
Effect.tap((output) => Deferred.succeed(settled, output)),
|
||||
Effect.map((output) => resultMessages(output).join("\n\n")),
|
||||
Effect.map((output) => output.output),
|
||||
Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)),
|
||||
)
|
||||
const job = yield* runtime.job.start({
|
||||
@@ -283,12 +294,6 @@ export const Plugin = {
|
||||
type: name,
|
||||
title: info.command,
|
||||
metadata: { sessionID: context.sessionID, shellID: info.id },
|
||||
recovery: {
|
||||
kind: "shell",
|
||||
sessionID: context.sessionID,
|
||||
shellID: info.id,
|
||||
command: info.command,
|
||||
},
|
||||
run,
|
||||
})
|
||||
|
||||
|
||||
@@ -78,6 +78,22 @@ export const Plugin = {
|
||||
return text.length > 0 ? text : NO_TEXT
|
||||
})
|
||||
|
||||
const injectCompletion = Effect.fn("SubagentTool.injectCompletion")(function* (
|
||||
parentID: SessionSchema.ID,
|
||||
childID: SessionSchema.ID,
|
||||
agent: string,
|
||||
description: string,
|
||||
state: "completed" | "error" | "cancelled",
|
||||
text: string,
|
||||
) {
|
||||
yield* runtime.session.synthetic({
|
||||
sessionID: parentID,
|
||||
text: `<subagent sessionID="${childID}" state="${state}" description="${description}">\n${text}\n</subagent>`,
|
||||
description,
|
||||
metadata: { source: "subagent", childID, agent, state },
|
||||
})
|
||||
})
|
||||
|
||||
const notifyWhenDone = Effect.fn("SubagentTool.notifyWhenDone")(function* (
|
||||
parentID: SessionSchema.ID,
|
||||
childID: SessionSchema.ID,
|
||||
@@ -88,24 +104,23 @@ export const Plugin = {
|
||||
const key = `${childID}:${startedAt}`
|
||||
if (notifications.has(key)) return
|
||||
notifications.add(key)
|
||||
yield* Effect.gen(function* () {
|
||||
const info = (yield* runtime.job.wait({ id: childID })).info
|
||||
if (!info || info.status === "running") return
|
||||
const text =
|
||||
info.status === "completed"
|
||||
? (info.output ?? NO_TEXT)
|
||||
: info.status === "error"
|
||||
? (info.error ?? "Subagent failed")
|
||||
: "Subagent cancelled"
|
||||
yield* runtime.session.synthetic({
|
||||
...(info.notificationID ? { id: info.notificationID } : {}),
|
||||
sessionID: parentID,
|
||||
text: `<subagent sessionID="${childID}" state="${info.status}" description="${description}">\n${text}\n</subagent>`,
|
||||
description,
|
||||
metadata: { source: "subagent", childID, agent, state: info.status },
|
||||
})
|
||||
if (info.notificationID) yield* runtime.job.completeBackground(info.notificationID)
|
||||
}).pipe(
|
||||
yield* runtime.job.wait({ id: childID }).pipe(
|
||||
Effect.flatMap((result) => {
|
||||
if (result.info?.status === "completed")
|
||||
return injectCompletion(parentID, childID, agent, description, "completed", result.info.output ?? NO_TEXT)
|
||||
if (result.info?.status === "error")
|
||||
return injectCompletion(
|
||||
parentID,
|
||||
childID,
|
||||
agent,
|
||||
description,
|
||||
"error",
|
||||
result.info.error ?? "Subagent failed",
|
||||
)
|
||||
if (result.info?.status === "cancelled")
|
||||
return injectCompletion(parentID, childID, agent, description, "cancelled", "Subagent cancelled")
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.ensuring(Effect.sync(() => notifications.delete(key))),
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
)
|
||||
@@ -224,7 +239,6 @@ export const Plugin = {
|
||||
existing === undefined
|
||||
? ["You are a subagent spawned by another session.", input.prompt].join("\n")
|
||||
: input.prompt,
|
||||
...(background && existing === undefined ? { resume: false } : {}),
|
||||
})
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
@@ -232,19 +246,17 @@ export const Plugin = {
|
||||
),
|
||||
)
|
||||
|
||||
const run = Effect.gen(function* () {
|
||||
yield* runtime.session.resume(child.id)
|
||||
return yield* latestAssistantText(child.id)
|
||||
}).pipe(Effect.onInterrupt(() => runtime.session.interrupt(child.id)))
|
||||
|
||||
const info = yield* runtime.job.start({
|
||||
id: child.id,
|
||||
type: name,
|
||||
title: input.description,
|
||||
metadata: {},
|
||||
recovery: {
|
||||
kind: "subagent",
|
||||
parentSessionID: context.sessionID,
|
||||
childSessionID: child.id,
|
||||
agent: agent.name,
|
||||
description: input.description,
|
||||
},
|
||||
run: runtime.session.resume(child.id).pipe(Effect.andThen(latestAssistantText(child.id))),
|
||||
run,
|
||||
})
|
||||
|
||||
if (background) {
|
||||
|
||||
@@ -222,11 +222,9 @@ describe("AISDKNative", () => {
|
||||
},
|
||||
headers: { "x-test": "value" },
|
||||
})
|
||||
for (const modelID of ["openai.gpt-oss-safeguard-20b", "openai.gpt-oss-safeguard-120b"]) {
|
||||
expect(map("@ai-sdk/amazon-bedrock/mantle", settings, modelID)?.package).toBe(
|
||||
"@opencode-ai/ai/providers/amazon-bedrock/mantle/chat",
|
||||
)
|
||||
}
|
||||
expect(map("@ai-sdk/amazon-bedrock/mantle", settings, "openai.gpt-oss-safeguard-20b")?.package).toBe(
|
||||
"@opencode-ai/ai/providers/amazon-bedrock/mantle/chat",
|
||||
)
|
||||
expect(
|
||||
map(
|
||||
"@ai-sdk/amazon-bedrock/mantle",
|
||||
|
||||
@@ -385,9 +385,6 @@ describe("DatabaseMigration", () => {
|
||||
const content = JSON.stringify({
|
||||
openai: { type: "oauth", refresh: "refresh", access: "access", expires: 123, accountId: "account" },
|
||||
anthropic: { type: "api", key: "legacy-key", metadata: { region: "us" } },
|
||||
google: { type: "api", key: "google-key", metadata: { region: "us" } },
|
||||
"github-copilot": { type: "oauth", refresh: "refresh", access: "access", expires: 123 },
|
||||
"custom-provider": { type: "api", key: "custom-key" },
|
||||
"https://example.com/": { type: "wellknown", key: "TOKEN", token: "wellknown-key" },
|
||||
invalid: { type: "unknown" },
|
||||
})
|
||||
@@ -405,7 +402,6 @@ describe("DatabaseMigration", () => {
|
||||
|
||||
yield* db.run(sql`DELETE FROM migration WHERE id = ${legacyCredentialsMigration.id}`)
|
||||
yield* DatabaseMigration.applyOnly(db, [legacyCredentialsMigration])
|
||||
yield* DatabaseMigration.applyOnly(db, [legacyCredentialsMigration])
|
||||
|
||||
expect(yield* db.all(sql`SELECT integration_id, label, value FROM credential ORDER BY integration_id`)).toEqual(
|
||||
[
|
||||
@@ -414,35 +410,14 @@ describe("DatabaseMigration", () => {
|
||||
label: "Existing",
|
||||
value: JSON.stringify({ type: "key", key: "current-key" }),
|
||||
},
|
||||
{
|
||||
integration_id: "custom-provider",
|
||||
label: "API key",
|
||||
value: JSON.stringify({ type: "key", key: "custom-key" }),
|
||||
},
|
||||
{
|
||||
integration_id: "github-copilot",
|
||||
label: "OAuth",
|
||||
value: JSON.stringify({
|
||||
type: "oauth",
|
||||
methodID: "device",
|
||||
refresh: "refresh",
|
||||
access: "access",
|
||||
expires: 123,
|
||||
}),
|
||||
},
|
||||
{
|
||||
integration_id: "google",
|
||||
label: "API key",
|
||||
value: JSON.stringify({ type: "key", key: "google-key", metadata: { region: "us" } }),
|
||||
},
|
||||
{
|
||||
integration_id: "https://example.com",
|
||||
label: "API key",
|
||||
label: "default",
|
||||
value: JSON.stringify({ type: "key", key: "wellknown-key" }),
|
||||
},
|
||||
{
|
||||
integration_id: "openai",
|
||||
label: "OAuth",
|
||||
label: "default",
|
||||
value: JSON.stringify({
|
||||
type: "oauth",
|
||||
methodID: "chatgpt-browser",
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Job } from "@opencode-ai/core/job"
|
||||
import { KV } from "@opencode-ai/core/kv"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Deferred, Effect, Exit, Fiber, Scope } from "effect"
|
||||
import { SessionSchema } from "@opencode-ai/core/session/schema"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Job.node, KV.node])))
|
||||
const it = testEffect(AppNodeBuilder.build(Job.node))
|
||||
|
||||
describe("Job", () => {
|
||||
it.live("tracks process-local work through explicit observation", () =>
|
||||
@@ -147,177 +145,6 @@ describe("Job", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("retains background ownership and terminal output until notification acknowledgment", () =>
|
||||
Effect.gen(function* () {
|
||||
const jobs = yield* Job.Service
|
||||
const latch = yield* Deferred.make<void>()
|
||||
const recovery = {
|
||||
kind: "shell" as const,
|
||||
sessionID: SessionSchema.ID.make("ses_background_shell"),
|
||||
shellID: "shell_background",
|
||||
command: "echo done",
|
||||
}
|
||||
const job = yield* jobs.start({ type: "shell", recovery, run: Deferred.await(latch).pipe(Effect.as("done")) })
|
||||
|
||||
expect((yield* jobs.pendingBackground).find((item) => item.id === job.id)).toBeUndefined()
|
||||
const background = yield* jobs.background(job.id)
|
||||
|
||||
const running = (yield* jobs.pendingBackground).find((item) => item.id === job.id)
|
||||
expect(running).toMatchObject({ id: job.id, recovery, status: "running" })
|
||||
expect(running?.notificationID).toStartWith("msg_")
|
||||
expect(background?.notificationID).toBe(running?.notificationID)
|
||||
|
||||
yield* Deferred.succeed(latch, undefined)
|
||||
yield* jobs.wait({ id: job.id })
|
||||
|
||||
const completed = (yield* jobs.pendingBackground).find((item) => item.id === job.id)
|
||||
expect(completed).toMatchObject({
|
||||
id: job.id,
|
||||
notificationID: running?.notificationID,
|
||||
recovery,
|
||||
status: "completed",
|
||||
output: "done",
|
||||
})
|
||||
if (!completed) return yield* Effect.die("background marker missing")
|
||||
|
||||
yield* jobs.completeBackground(completed.notificationID)
|
||||
expect((yield* jobs.pendingBackground).find((item) => item.id === job.id)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("persists backgroundAll ownership before releasing a blocked subagent", () =>
|
||||
Effect.gen(function* () {
|
||||
const jobs = yield* Job.Service
|
||||
const parentSessionID = SessionSchema.ID.make("ses_background_parent")
|
||||
const latch = yield* Deferred.make<void>()
|
||||
const recovery = {
|
||||
kind: "subagent" as const,
|
||||
parentSessionID,
|
||||
childSessionID: SessionSchema.ID.make("ses_background_child"),
|
||||
agent: "explore",
|
||||
description: "Explore background recovery",
|
||||
}
|
||||
const job = yield* jobs.start({ type: "subagent", recovery, run: Deferred.await(latch).pipe(Effect.as("done")) })
|
||||
const waiting = yield* jobs
|
||||
.block({ id: job.id, sessionID: parentSessionID })
|
||||
.pipe(Effect.forkIn(yield* Scope.Scope, { startImmediately: true }))
|
||||
|
||||
yield* jobs.backgroundAll({ sessionID: parentSessionID })
|
||||
expect(yield* Fiber.join(waiting)).toMatchObject({ type: "backgrounded", info: { id: job.id } })
|
||||
|
||||
const marker = (yield* jobs.pendingBackground).find((item) => item.id === job.id)
|
||||
expect(marker).toMatchObject({ id: job.id, recovery, status: "running" })
|
||||
if (!marker) return yield* Effect.die("background marker missing")
|
||||
|
||||
yield* jobs.cancel(job.id)
|
||||
expect((yield* jobs.pendingBackground).find((item) => item.id === job.id)).toMatchObject({
|
||||
notificationID: marker.notificationID,
|
||||
status: "cancelled",
|
||||
})
|
||||
yield* jobs.completeBackground(marker.notificationID)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("retains terminal errors for recovery until notification acknowledgment", () =>
|
||||
Effect.gen(function* () {
|
||||
const jobs = yield* Job.Service
|
||||
const latch = yield* Deferred.make<void>()
|
||||
const job = yield* jobs.start({
|
||||
type: "shell",
|
||||
recovery: {
|
||||
kind: "shell",
|
||||
sessionID: SessionSchema.ID.make("ses_background_error"),
|
||||
shellID: "shell_error",
|
||||
command: "exit 1",
|
||||
},
|
||||
run: Deferred.await(latch).pipe(Effect.andThen(Effect.fail(new Error("shell failed")))),
|
||||
})
|
||||
|
||||
yield* jobs.background(job.id)
|
||||
yield* Deferred.succeed(latch, undefined)
|
||||
yield* jobs.wait({ id: job.id })
|
||||
|
||||
const marker = (yield* jobs.pendingBackground).find((item) => item.id === job.id)
|
||||
expect(marker).toMatchObject({ id: job.id, status: "error", error: "shell failed" })
|
||||
if (!marker) return yield* Effect.die("background marker missing")
|
||||
yield* jobs.completeBackground(marker.notificationID)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("durably backgrounds recoverable work that has already failed", () =>
|
||||
Effect.gen(function* () {
|
||||
const jobs = yield* Job.Service
|
||||
const job = yield* jobs.start({
|
||||
type: "shell",
|
||||
recovery: {
|
||||
kind: "shell",
|
||||
sessionID: SessionSchema.ID.make("ses_immediate_error"),
|
||||
shellID: "shell_immediate_error",
|
||||
command: "exit 1",
|
||||
},
|
||||
run: Effect.fail(new Error("shell failed")),
|
||||
})
|
||||
expect((yield* jobs.wait({ id: job.id })).info?.status).toBe("error")
|
||||
|
||||
const background = yield* jobs.background(job.id)
|
||||
expect(background?.notificationID).toStartWith("msg_")
|
||||
expect(yield* jobs.pendingBackground).toMatchObject([
|
||||
{ id: job.id, notificationID: background?.notificationID, status: "error", error: "shell failed" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("recovers a background marker after its process-local registry closes", () =>
|
||||
Effect.gen(function* () {
|
||||
const scope = yield* Scope.make()
|
||||
const previous = yield* Job.make.pipe(Scope.provide(scope))
|
||||
const job = yield* previous.start({
|
||||
type: "shell",
|
||||
recovery: {
|
||||
kind: "shell",
|
||||
sessionID: SessionSchema.ID.make("ses_background_restart"),
|
||||
shellID: "shell_restart",
|
||||
command: "sleep 60",
|
||||
},
|
||||
run: Effect.never,
|
||||
})
|
||||
yield* previous.background(job.id)
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
|
||||
const current = yield* Job.make
|
||||
const marker = (yield* current.pendingBackground).find((item) => item.id === job.id)
|
||||
expect(marker).toMatchObject({ id: job.id, status: "running" })
|
||||
if (!marker) return yield* Effect.die("background marker missing")
|
||||
yield* current.completeBackground(marker.notificationID)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("preserves running background ownership when its work is interrupted", () =>
|
||||
Effect.gen(function* () {
|
||||
const jobs = yield* Job.Service
|
||||
const interrupted = yield* Deferred.make<void>()
|
||||
const job = yield* jobs.start({
|
||||
type: "subagent",
|
||||
recovery: {
|
||||
kind: "subagent",
|
||||
parentSessionID: SessionSchema.ID.make("ses_interrupted_parent"),
|
||||
childSessionID: SessionSchema.ID.make("ses_interrupted_child"),
|
||||
agent: "explore",
|
||||
description: "Continue after shutdown",
|
||||
},
|
||||
run: Deferred.await(interrupted).pipe(Effect.andThen(Effect.interrupt)),
|
||||
})
|
||||
yield* jobs.background(job.id)
|
||||
yield* Deferred.succeed(interrupted, undefined)
|
||||
yield* jobs.wait({ id: job.id })
|
||||
|
||||
const marker = (yield* jobs.pendingBackground).find((item) => item.id === job.id)
|
||||
expect(marker).toMatchObject({ id: job.id, status: "running" })
|
||||
if (!marker) return yield* Effect.die("background marker missing")
|
||||
yield* jobs.completeBackground(marker.notificationID)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("interrupts live work without promising settlement after the owning process-local scope closes", () =>
|
||||
Effect.gen(function* () {
|
||||
const scope = yield* Scope.make()
|
||||
|
||||
@@ -64,8 +64,10 @@ export const registerToolPlugin = <R>(
|
||||
hook: () => Effect.succeed({ dispose: Effect.void }),
|
||||
},
|
||||
tool: {
|
||||
transform: tools.transform,
|
||||
reload: tools.reload,
|
||||
transform: (callback) =>
|
||||
tools
|
||||
.transform((draft) => callback({ add: (tool) => draft.add(tool) }))
|
||||
.pipe(Effect.orDie, Effect.as({ dispose: Effect.void })),
|
||||
hook: () => Effect.die("registerToolPlugin does not support tool hooks"),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -35,7 +35,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { McpTool } from "@opencode-ai/core/tool/mcp"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { DateTime, Deferred, Effect, Exit, Fiber, Layer, PubSub, Ref, Schedule, Schema, Sink, Stream } from "effect"
|
||||
import { DateTime, Deferred, Effect, Exit, Fiber, Layer, PubSub, Schedule, Schema, Sink, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { ExitCode, makeHandle, ProcessId } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import { Image } from "@opencode-ai/core/image"
|
||||
@@ -77,7 +77,6 @@ function resourceServer(
|
||||
templateLists: 0,
|
||||
toolLists: 0,
|
||||
initializations: 0,
|
||||
urls: [] as string[],
|
||||
}
|
||||
const protocol = new Server(
|
||||
{ name: "mcp-resources", version: "1.0.0" },
|
||||
@@ -146,7 +145,6 @@ function resourceServer(
|
||||
const http = Bun.serve({
|
||||
port: 0,
|
||||
fetch: async (request) => {
|
||||
state.urls.push(request.url)
|
||||
const body: unknown = request.method === "POST" ? await request.clone().json() : undefined
|
||||
if (typeof body === "object" && body !== null && "method" in body && body.method === "initialize") {
|
||||
state.initializations += 1
|
||||
@@ -720,40 +718,6 @@ test("applies configured MCP timeouts to resource operations", async () => {
|
||||
await expect(read).rejects.toThrow("Request timed out")
|
||||
})
|
||||
|
||||
for (const entry of [
|
||||
{ name: "default", query: "", codemode: undefined, expected: "?codemode=false" },
|
||||
{ name: "explicit local code mode", query: "", codemode: true, expected: "?codemode=false" },
|
||||
{ name: "direct tools", query: "", codemode: false, expected: "" },
|
||||
{
|
||||
name: "existing query",
|
||||
query: "?source=opencode",
|
||||
codemode: undefined,
|
||||
expected: "?source=opencode&codemode=false",
|
||||
},
|
||||
{ name: "explicit remote code mode", query: "?codemode=true", codemode: undefined, expected: "?codemode=true" },
|
||||
{ name: "explicit remote opt-out", query: "?codemode=false", codemode: undefined, expected: "?codemode=false" },
|
||||
{ name: "portal opt-out", query: "?codemode=off", codemode: undefined, expected: "?codemode=off" },
|
||||
]) {
|
||||
testEffect(Layer.empty).live(`remote MCP code mode preference: ${entry.name}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* resourceServer()
|
||||
const config = new ConfigMCP.Remote({
|
||||
type: "remote",
|
||||
url: server.url + entry.query,
|
||||
oauth: false,
|
||||
codemode: entry.codemode,
|
||||
})
|
||||
const connection = yield* connect("resources", config, import.meta.dir)
|
||||
yield* connection.tools()
|
||||
expect(server.state.initializations).toBe(1)
|
||||
expect(server.state.toolLists).toBe(1)
|
||||
expect(server.state.urls.length).toBeGreaterThanOrEqual(3)
|
||||
expect(new Set(server.state.urls)).toEqual(new Set([server.url + entry.expected]))
|
||||
expect(config.url).toBe(server.url + entry.query)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
test("lists, reads, and reports MCP resource changes", async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
@@ -1229,102 +1193,7 @@ test("serializes concurrent MCP lifecycle operations", async () => {
|
||||
)
|
||||
})
|
||||
|
||||
testEffect(Layer.empty).live("isolates invalid MCP tools and reapplies plugin mutations on catalog updates", () =>
|
||||
Effect.gen(function* () {
|
||||
const tool = (server: string, name: string) =>
|
||||
new MCP.Tool({
|
||||
server: MCP.ServerName.make(server),
|
||||
name,
|
||||
codemode: false,
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
})
|
||||
const healthy = [tool("demo", "search"), tool("other", "lookup")]
|
||||
const namespace = tool("x".repeat(65), "lookup")
|
||||
const catalog = yield* Ref.make([tool("demo", "x".repeat(65)), ...healthy, namespace])
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const registry = yield* Tool.Service
|
||||
const registration = yield* McpTool.Service
|
||||
const bus = yield* Bus.Service
|
||||
const policy = yield* registry.transform((draft) => {
|
||||
draft.update("demo_search", (tool) => {
|
||||
tool.description = "Updated search"
|
||||
})
|
||||
draft.remove("other_lookup")
|
||||
})
|
||||
yield* registration.flush
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["demo_search", "execute"])
|
||||
|
||||
yield* Ref.set(catalog, [tool("demo", "y".repeat(65)), ...healthy, tool("demo", "added"), namespace])
|
||||
yield* bus.publish(McpEvent.ToolsChanged, { server: "demo" })
|
||||
yield* waitForTool(registry, "demo_added")
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([
|
||||
"demo_added",
|
||||
"demo_search",
|
||||
"execute",
|
||||
])
|
||||
expect((yield* toolDefinitions(registry)).find((tool) => tool.name === "demo_search")?.description).toBe(
|
||||
"Updated search",
|
||||
)
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
sessionID: Session.ID.make("ses_mcp_invalid_catalog"),
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call_demo_search", name: "demo_search", input: {} },
|
||||
}),
|
||||
).toMatchObject({ status: "completed" })
|
||||
|
||||
yield* Ref.set(catalog, [
|
||||
tool("demo", "status"),
|
||||
tool("other", "lookup"),
|
||||
tool("demo", "added"),
|
||||
tool("repaired", "lookup"),
|
||||
])
|
||||
yield* bus.publish(McpEvent.ToolsChanged, { server: "demo" })
|
||||
yield* waitForTool(registry, "demo_status")
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([
|
||||
"demo_added",
|
||||
"demo_status",
|
||||
"repaired_lookup",
|
||||
"execute",
|
||||
])
|
||||
yield* policy.dispose
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([
|
||||
"demo_added",
|
||||
"demo_status",
|
||||
"other_lookup",
|
||||
"repaired_lookup",
|
||||
"execute",
|
||||
])
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
Layer.fresh(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node, Bus.node]), [
|
||||
[
|
||||
MCP.node,
|
||||
Layer.mock(MCP.Service, {
|
||||
tools: () => Ref.get(catalog),
|
||||
callTool: (input) =>
|
||||
Effect.succeed(
|
||||
new MCP.ToolResult({
|
||||
server: MCP.ServerName.make(input.server),
|
||||
tool: input.name,
|
||||
isError: false,
|
||||
content: [{ type: "text", text: "healthy" }],
|
||||
}),
|
||||
),
|
||||
}),
|
||||
],
|
||||
[Permission.node, Layer.mock(Permission.Service, { assert: () => Effect.void })],
|
||||
[Image.node, imagePassthrough],
|
||||
]),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("advertises MCP output schemas to Code Mode", () =>
|
||||
it.effect("advertises MCP output schemas to Code Mode", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* Tool.Service
|
||||
const toolSet = yield* waitForCodeModeTool(registry, "demo.search")
|
||||
@@ -1341,7 +1210,7 @@ it.live("advertises MCP output schemas to Code Mode", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("returns content-only MCP results through Code Mode", () =>
|
||||
it.effect("returns content-only MCP results through Code Mode", () =>
|
||||
Effect.gen(function* () {
|
||||
assertion = yield* Deferred.make<Permission.AssertInput>()
|
||||
decision = Effect.void
|
||||
@@ -1366,7 +1235,7 @@ it.live("returns content-only MCP results through Code Mode", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("advertises MCP tools directly when Code Mode is disabled for the server", () =>
|
||||
it.effect("advertises MCP tools directly when Code Mode is disabled for the server", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* Tool.Service
|
||||
yield* waitForTool(registry, "direct_lookup")
|
||||
@@ -1380,7 +1249,7 @@ it.live("advertises MCP tools directly when Code Mode is disabled for the server
|
||||
|
||||
// Baseline (PLAN.md step 1): MCP isError must become one failed tool call, not a
|
||||
// success whose text happens to describe an error.
|
||||
it.live("fails the call when MCP reports isError", () =>
|
||||
it.effect("fails the call when MCP reports isError", () =>
|
||||
Effect.gen(function* () {
|
||||
assertion = yield* Deferred.make<Permission.AssertInput>()
|
||||
decision = Effect.void
|
||||
@@ -1398,7 +1267,7 @@ it.live("fails the call when MCP reports isError", () =>
|
||||
)
|
||||
|
||||
// Baseline (PLAN.md step 1): mixed MCP text and media content must reach the model intact.
|
||||
it.live("preserves MCP text and media content for the model", () =>
|
||||
it.effect("preserves MCP text and media content for the model", () =>
|
||||
Effect.gen(function* () {
|
||||
assertion = yield* Deferred.make<Permission.AssertInput>()
|
||||
decision = Effect.void
|
||||
@@ -1419,7 +1288,7 @@ it.live("preserves MCP text and media content for the model", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("waits for permission before calling an MCP tool", () =>
|
||||
it.effect("waits for permission before calling an MCP tool", () =>
|
||||
Effect.gen(function* () {
|
||||
calls = 0
|
||||
assertion = yield* Deferred.make<Permission.AssertInput>()
|
||||
@@ -1461,7 +1330,7 @@ it.live("waits for permission before calling an MCP tool", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("does not call MCP when permission is blocked", () =>
|
||||
it.effect("does not call MCP when permission is blocked", () =>
|
||||
Effect.gen(function* () {
|
||||
calls = 0
|
||||
assertion = yield* Deferred.make<Permission.AssertInput>()
|
||||
|
||||
@@ -1126,7 +1126,7 @@ describe("ModelResolver", () => {
|
||||
const mantle = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"), {
|
||||
modelID: "openai.gpt-oss-120b",
|
||||
settings: { region: "us-east-1", topP: 0.6 },
|
||||
settings: { region: "us-east-1" },
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1158,7 +1158,6 @@ describe("ModelResolver", () => {
|
||||
expect(bedrock.route.defaults.generation).toEqual({ topP: 0.8 })
|
||||
expect(bedrock.route.defaults.http?.body).toEqual({ serviceTier: { type: "priority" } })
|
||||
expect(mantle.route.id).toBe("bedrock-mantle-responses")
|
||||
expect(mantle.route.defaults.generation).toEqual({ topP: 0.6 })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import path from "path"
|
||||
import { expect } from "bun:test"
|
||||
import { Deferred, Effect, Fiber, FileSystem, Layer } from "effect"
|
||||
import { ModelsDevCache } from "@opencode-ai/core/models-dev/cache"
|
||||
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const source = "https://models.opencode.ai"
|
||||
const it = testEffect(
|
||||
LayerNode.compile(LayerNode.group([ModelsDevCache.node, LayerNodePlatform.filesystem, Global.node]), [
|
||||
[Global.node, tempGlobalLayer],
|
||||
]),
|
||||
)
|
||||
|
||||
it.live("returns undefined for a missing catalog", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = yield* ModelsDevCache.Service
|
||||
expect(yield* cache.read(source)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("persists raw catalog bodies larger than 2 MB with the file mtime", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = yield* ModelsDevCache.Service
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const global = yield* Global.Service
|
||||
const body = ` {\n "payload": "${"x".repeat(2 * 1024 * 1024)}"\n}\n`
|
||||
const file = path.join(global.cache, "models-dev", `${Hash.fast(source)}.json`)
|
||||
const modified = new Date("2026-01-01T00:00:00Z")
|
||||
|
||||
yield* cache.write(source, body)
|
||||
expect(yield* fs.readFileString(file)).toBe(body)
|
||||
yield* fs.utimes(file, modified, modified)
|
||||
expect(yield* cache.read(source)).toEqual({ body, updatedAt: modified.getTime() })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("isolates catalogs by source including the default source", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = yield* ModelsDevCache.Service
|
||||
const custom = "https://models.example.com"
|
||||
|
||||
yield* cache.write(source, "default catalog")
|
||||
expect(yield* cache.read(custom)).toBeUndefined()
|
||||
yield* cache.write(custom, "custom catalog")
|
||||
expect((yield* cache.read(source))?.body).toBe("default catalog")
|
||||
expect((yield* cache.read(custom))?.body).toBe("custom catalog")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("replaces an existing catalog without leaving temporary files", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = yield* ModelsDevCache.Service
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const global = yield* Global.Service
|
||||
|
||||
yield* cache.write(source, "old catalog")
|
||||
yield* cache.write(source, "new catalog")
|
||||
expect((yield* cache.read(source))?.body).toBe("new catalog")
|
||||
expect(yield* fs.readDirectory(path.join(global.cache, "models-dev"))).toEqual([`${Hash.fast(source)}.json`])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("cleans up temporary files and preserves platform errors when replacement fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = yield* ModelsDevCache.Service
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const global = yield* Global.Service
|
||||
const directory = path.join(global.cache, "models-dev")
|
||||
const file = path.join(directory, `${Hash.fast(source)}.json`)
|
||||
yield* fs.makeDirectory(file, { recursive: true })
|
||||
|
||||
const error = yield* cache.write(source, "new catalog").pipe(Effect.flip)
|
||||
expect(error._tag).toBe("PlatformError")
|
||||
expect(yield* fs.readDirectory(directory)).toEqual([`${Hash.fast(source)}.json`])
|
||||
expect((yield* fs.stat(file)).type).toBe("Directory")
|
||||
expect((yield* cache.read(source).pipe(Effect.flip))._tag).toBe("PlatformError")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("keeps the old catalog readable and cleans up an interrupted replacement", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = yield* ModelsDevCache.Service
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const global = yield* Global.Service
|
||||
const staged = yield* Deferred.make<string>()
|
||||
yield* cache.write(source, "old catalog")
|
||||
|
||||
// Pause only the commit; staging and cleanup still use the real filesystem.
|
||||
const writer = yield* ModelsDevCache.Service.pipe(
|
||||
Effect.flatMap((service) => service.write(source, "new catalog")),
|
||||
Effect.provide(Layer.fresh(ModelsDevCache.layer)),
|
||||
Effect.provideService(FileSystem.FileSystem, {
|
||||
...fs,
|
||||
rename: (file) => Deferred.succeed(staged, file).pipe(Effect.andThen(Effect.never)),
|
||||
}),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
const temporary = yield* Deferred.await(staged)
|
||||
expect(yield* fs.readFileString(temporary)).toBe("new catalog")
|
||||
expect((yield* cache.read(source))?.body).toBe("old catalog")
|
||||
|
||||
yield* Fiber.interrupt(writer)
|
||||
expect((yield* cache.read(source))?.body).toBe("old catalog")
|
||||
expect(yield* fs.readDirectory(path.join(global.cache, "models-dev"))).toEqual([`${Hash.fast(source)}.json`])
|
||||
}),
|
||||
)
|
||||
@@ -1,18 +1,20 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Effect, Fiber, Layer, Ref, Scope, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { Deferred, Effect, Fiber, Layer, Ref, Scope, Stream } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { KV } from "@opencode-ai/core/kv"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { bodyDigest, ModelsDev } from "@opencode-ai/core/models-dev"
|
||||
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||
import { ModelsDevCache } from "@opencode-ai/core/models-dev/cache"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
const cacheKey = "models-dev:catalog"
|
||||
const source = "https://models.opencode.ai"
|
||||
|
||||
test("normalizes permissive interleaved values to compatibility", () => {
|
||||
expect(Model.compatibility("reasoning_text")).toEqual({ reasoningField: "reasoning_text" })
|
||||
@@ -166,41 +168,40 @@ const makeMockClient = (state: Ref.Ref<MockState>) =>
|
||||
)
|
||||
|
||||
interface MockCache {
|
||||
readonly values: Map<string, KV.Value>
|
||||
readonly values: Map<string, ModelsDevCache.Entry>
|
||||
}
|
||||
|
||||
const makeMockKV = (cache: MockCache) =>
|
||||
Layer.mock(KV.Service, {
|
||||
get: (key) => Effect.sync(() => cache.values.get(key)),
|
||||
set: (key, value) => Effect.sync(() => cache.values.set(key, value)).pipe(Effect.asVoid),
|
||||
remove: (key) => Effect.sync(() => cache.values.delete(key)).pipe(Effect.asVoid),
|
||||
const makeMockCache = (cache: MockCache) =>
|
||||
Layer.succeed(ModelsDevCache.Service, {
|
||||
read: (source) => Effect.sync(() => cache.values.get(source)),
|
||||
write: (source, body) =>
|
||||
Effect.sync(() => cache.values.set(source, { updatedAt: Date.now(), body })).pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
const buildLayer = (state: Ref.Ref<MockState>, cache: MockCache, options: ModelsDev.Options = { fetch: false }) =>
|
||||
// Layer.fresh is required because the ModelsDev implementation is a module-level Layer constant,
|
||||
// and Effect.provide uses a process-global MemoMap by default — without fresh,
|
||||
// every test would reuse the cachedInvalidateWithTTL state from the first run.
|
||||
const buildLayer = (
|
||||
state: Ref.Ref<MockState>,
|
||||
cache: MockCache,
|
||||
options: ModelsDev.Options = { fetch: false },
|
||||
persistence = makeMockCache(cache),
|
||||
) =>
|
||||
Layer.fresh(
|
||||
AppNodeBuilder.build(LayerNode.group([ModelsDev.node, Bus.node]), [
|
||||
[ModelsDev.node, ModelsDev.configured(options)],
|
||||
[LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
|
||||
[KV.node, makeMockKV(cache)],
|
||||
[ModelsDevCache.node, persistence],
|
||||
]),
|
||||
)
|
||||
|
||||
// Mirrors production KV backends whose writes die as defects (e.g. Durable
|
||||
// Object SQLite rejecting values over its 2 MB cap with EffectDrizzleQueryError).
|
||||
const makeFailingWriteKV = (cache: MockCache) =>
|
||||
Layer.mock(KV.Service, {
|
||||
get: (key) => Effect.sync(() => cache.values.get(key)),
|
||||
set: () => Effect.die(new Error('Failed query: insert into "kv"')),
|
||||
remove: (key) => Effect.sync(() => cache.values.delete(key)).pipe(Effect.asVoid),
|
||||
const makeFailingWriteCache = (cache: MockCache) =>
|
||||
Layer.succeed(ModelsDevCache.Service, {
|
||||
read: (source) => Effect.sync(() => cache.values.get(source)),
|
||||
write: () => Effect.die(new Error("Cache write failed")),
|
||||
})
|
||||
|
||||
const makeCache = (): MockCache => ({ values: new Map() })
|
||||
|
||||
const writeCacheText = (cache: MockCache, text: string, updatedAt = Date.now()) =>
|
||||
cache.values.set(cacheKey, { updatedAt, digest: bodyDigest(text), body: text })
|
||||
cache.values.set(source, { updatedAt, body: text })
|
||||
|
||||
const writeCache = (cache: MockCache, data: object, updatedAt?: number) =>
|
||||
writeCacheText(cache, JSON.stringify(data), updatedAt)
|
||||
@@ -218,7 +219,7 @@ const initialState: MockState = {
|
||||
}
|
||||
|
||||
describe("ModelsDev Service", () => {
|
||||
it.live("get() returns normalized snapshots from KV when a cache entry exists", () =>
|
||||
it.live("get() returns normalized snapshots from the persisted cache", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
writeCache(cache, fixture)
|
||||
@@ -259,7 +260,7 @@ describe("ModelsDev Service", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("get() returns empty catalog when KV is empty, fetch disabled, and the bundled snapshot is disabled", () =>
|
||||
it.live("get() returns empty catalog when the cache, fetch, and bundled snapshot are unavailable", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
const state = yield* Ref.make(initialState)
|
||||
@@ -272,7 +273,7 @@ describe("ModelsDev Service", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("get() falls back to the bundled snapshot when KV is empty and fetch is disabled", () =>
|
||||
it.live("get() falls back to the bundled snapshot when the cache is empty and fetch is disabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
const state = yield* Ref.make(initialState)
|
||||
@@ -289,7 +290,7 @@ describe("ModelsDev Service", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("get() recovers from a corrupted KV entry by fetching a fresh catalog", () =>
|
||||
it.live("get() recovers from a corrupted cache by fetching a fresh catalog", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
writeCacheText(cache, "{")
|
||||
@@ -297,31 +298,247 @@ describe("ModelsDev Service", () => {
|
||||
const context = yield* Layer.build(buildLayer(state, cache, { fetch: true, snapshot: false }))
|
||||
const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context))
|
||||
expect(result).toEqual(fixture2Snapshot)
|
||||
expect(cache.values.get(cacheKey)).toMatchObject({ body: JSON.stringify(fixture2) })
|
||||
expect(cache.values.get(source)).toMatchObject({ body: JSON.stringify(fixture2) })
|
||||
const final = yield* Ref.get(state)
|
||||
expect(final.calls.length).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("get() still populates the catalog when the KV cache write fails", () =>
|
||||
it.live("get() still populates the catalog when persistence fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
|
||||
const layer = Layer.fresh(
|
||||
AppNodeBuilder.build(ModelsDev.node, [
|
||||
[ModelsDev.node, ModelsDev.configured({ fetch: true, snapshot: false })],
|
||||
[LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
|
||||
[KV.node, makeFailingWriteKV(cache)],
|
||||
]),
|
||||
)
|
||||
const layer = buildLayer(state, cache, { fetch: true, snapshot: false }, makeFailingWriteCache(cache))
|
||||
const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(layer))
|
||||
expect(result).toEqual(fixture2Snapshot)
|
||||
expect(cache.values.has(cacheKey)).toBe(false)
|
||||
expect(cache.values.has(source)).toBe(false)
|
||||
const final = yield* Ref.get(state)
|
||||
expect(final.calls.length).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
for (const seeded of [false, true]) {
|
||||
it.live(`refresh adopts and publishes the fetched catalog when persistence fails (seeded=${seeded})`, () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
if (seeded) writeCache(cache, fixture)
|
||||
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
|
||||
yield* Effect.gen(function* () {
|
||||
const models = yield* ModelsDev.Service
|
||||
const bus = yield* Bus.Service
|
||||
expect(yield* models.get()).not.toEqual(fixture2Snapshot)
|
||||
const event = yield* bus.subscribe(ModelsDev.Event.Refreshed).pipe(
|
||||
Stream.take(1),
|
||||
Stream.runDrain,
|
||||
Effect.andThen(() => models.get()),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* models.refresh(true)
|
||||
expect(yield* Fiber.join(event)).toEqual(fixture2Snapshot)
|
||||
expect(yield* models.get()).toEqual(fixture2Snapshot)
|
||||
yield* models.refresh()
|
||||
expect((yield* Ref.get(state)).calls).toHaveLength(1)
|
||||
}).pipe(Effect.provide(buildLayer(state, cache, { fetch: false }, makeFailingWriteCache(cache))))
|
||||
expect(cache.values.get(source)?.body).toBe(seeded ? JSON.stringify(fixture) : undefined)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.live("a failed cache read falls back to the bundled snapshot without blocking refresh", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
const state = yield* Ref.make(initialState)
|
||||
yield* Effect.gen(function* () {
|
||||
const models = yield* ModelsDev.Service
|
||||
expect((yield* models.get()).length).toBeGreaterThan(0)
|
||||
yield* models.refresh(true)
|
||||
expect(yield* models.get()).toEqual(fixtureSnapshot)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
buildLayer(
|
||||
state,
|
||||
cache,
|
||||
{ fetch: false },
|
||||
Layer.succeed(ModelsDevCache.Service, {
|
||||
read: () => Effect.die(new Error("Cache read failed")),
|
||||
write: () => Effect.void,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect((yield* Ref.get(state)).calls).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("refresh publishes the live catalog while its cache write is still pending", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
writeCache(cache, fixture)
|
||||
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
|
||||
const writing = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
yield* Effect.gen(function* () {
|
||||
const models = yield* ModelsDev.Service
|
||||
const bus = yield* Bus.Service
|
||||
expect(yield* models.get()).toEqual(fixtureSnapshot)
|
||||
const event = yield* bus
|
||||
.subscribe(ModelsDev.Event.Refreshed)
|
||||
.pipe(Stream.take(1), Stream.runDrain, Effect.forkScoped({ startImmediately: true }))
|
||||
const refresh = yield* models.refresh(true).pipe(Effect.forkScoped)
|
||||
yield* Deferred.await(writing)
|
||||
yield* Fiber.join(event).pipe(Effect.timeout("1 second"))
|
||||
expect(yield* models.get()).toEqual(fixture2Snapshot)
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(refresh)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
buildLayer(
|
||||
state,
|
||||
cache,
|
||||
{ fetch: false },
|
||||
Layer.succeed(ModelsDevCache.Service, {
|
||||
read: () => Effect.succeed(cache.values.get(source)),
|
||||
write: () => Deferred.succeed(writing, undefined).pipe(Effect.andThen(Deferred.await(release))),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("get() can use the bundled snapshot while the initial background fetch is pending", () =>
|
||||
Effect.gen(function* () {
|
||||
const reading = yield* Deferred.make<void>()
|
||||
const releaseRead = yield* Deferred.make<void>()
|
||||
const fetching = yield* Deferred.make<void>()
|
||||
const releaseFetch = yield* Deferred.make<void>()
|
||||
const layer = Layer.fresh(
|
||||
AppNodeBuilder.build(ModelsDev.node, [
|
||||
[ModelsDev.node, ModelsDev.configured({ fetch: true })],
|
||||
[
|
||||
ModelsDevCache.node,
|
||||
Layer.succeed(ModelsDevCache.Service, {
|
||||
read: () =>
|
||||
Deferred.succeed(reading, undefined).pipe(
|
||||
Effect.andThen(Deferred.await(releaseRead)),
|
||||
Effect.as(undefined),
|
||||
),
|
||||
write: () => Effect.void,
|
||||
}),
|
||||
],
|
||||
[
|
||||
LayerNodePlatform.httpClient,
|
||||
Layer.succeed(
|
||||
HttpClient.HttpClient,
|
||||
HttpClient.make((request) =>
|
||||
Deferred.succeed(fetching, undefined).pipe(
|
||||
Effect.andThen(Deferred.await(releaseFetch)),
|
||||
Effect.as(HttpClientResponse.fromWeb(request, new Response(JSON.stringify(fixture)))),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
]),
|
||||
)
|
||||
yield* Effect.gen(function* () {
|
||||
const models = yield* ModelsDev.Service
|
||||
yield* Deferred.await(reading)
|
||||
const get = yield* models.get().pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Deferred.succeed(releaseRead, undefined)
|
||||
yield* Deferred.await(fetching)
|
||||
expect((yield* Fiber.join(get).pipe(Effect.timeout("1 second"))).length).toBeGreaterThan(0)
|
||||
yield* Deferred.succeed(releaseFetch, undefined)
|
||||
}).pipe(Effect.provide(layer))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("cancelling a reader during initialization does not poison later reads or refreshes", () =>
|
||||
Effect.gen(function* () {
|
||||
const reading = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
|
||||
yield* Effect.gen(function* () {
|
||||
const models = yield* ModelsDev.Service
|
||||
const first = yield* models.get().pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Deferred.await(reading)
|
||||
yield* Fiber.interrupt(first)
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
expect(yield* models.get()).toEqual(fixtureSnapshot)
|
||||
yield* models.refresh(true)
|
||||
expect(yield* models.get()).toEqual(fixture2Snapshot)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
buildLayer(
|
||||
state,
|
||||
makeCache(),
|
||||
{ fetch: false },
|
||||
Layer.succeed(ModelsDevCache.Service, {
|
||||
read: () =>
|
||||
Deferred.succeed(reading, undefined).pipe(
|
||||
Effect.andThen(Deferred.await(release)),
|
||||
Effect.as({ body: JSON.stringify(fixture), updatedAt: Date.now() }),
|
||||
),
|
||||
write: () => Effect.void,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("custom source URLs do not read or overwrite the default source cache", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
writeCache(cache, fixture)
|
||||
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
|
||||
const result = yield* ModelsDev.Service.use((models) => models.get()).pipe(
|
||||
Effect.provide(buildLayer(state, cache, { url: "https://catalog.example", fetch: true, snapshot: false })),
|
||||
)
|
||||
expect(result).toEqual(fixture2Snapshot)
|
||||
expect(cache.values.get(source)?.body).toBe(JSON.stringify(fixture))
|
||||
expect(cache.values.get("https://catalog.example")?.body).toBe(JSON.stringify(fixture2))
|
||||
expect((yield* Ref.get(state)).calls[0]?.url).toBe("https://catalog.example/api.json")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("an explicit file remains authoritative and refresh rereads it without HTTP or cache access", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
)
|
||||
const file = path.join(dir.path, "catalog.json")
|
||||
yield* Effect.promise(() => Bun.write(file, JSON.stringify(fixture)))
|
||||
const state = yield* Ref.make(initialState)
|
||||
const cacheCalls: string[] = []
|
||||
yield* Effect.gen(function* () {
|
||||
const models = yield* ModelsDev.Service
|
||||
expect(yield* models.get()).toEqual(fixtureSnapshot)
|
||||
yield* Effect.promise(() => Bun.write(file, JSON.stringify(fixture2)))
|
||||
yield* models.refresh(true)
|
||||
expect(yield* models.get()).toEqual(fixture2Snapshot)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
buildLayer(
|
||||
state,
|
||||
makeCache(),
|
||||
{ file, fetch: false },
|
||||
Layer.succeed(ModelsDevCache.Service, {
|
||||
read: () =>
|
||||
Effect.sync(() => {
|
||||
cacheCalls.push("read")
|
||||
return undefined
|
||||
}),
|
||||
write: () => Effect.sync(() => void cacheCalls.push("write")),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect((yield* Ref.get(state)).calls).toEqual([])
|
||||
expect(cacheCalls).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("uses the default models URL when the configured URL is empty", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
@@ -348,7 +565,7 @@ describe("ModelsDev Service", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("get() caches across calls (later KV writes are ignored until invalidate)", () =>
|
||||
it.live("get() retains the live catalog instead of rereading persistence", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
writeCache(cache, fixture)
|
||||
@@ -387,7 +604,7 @@ describe("ModelsDev Service", () => {
|
||||
)
|
||||
expect(result.before).toEqual(fixtureSnapshot)
|
||||
expect(result.after).toEqual(fixture2Snapshot)
|
||||
expect(cache.values.get(cacheKey)).toMatchObject({ body: JSON.stringify(fixture2) })
|
||||
expect(cache.values.get(source)).toMatchObject({ body: JSON.stringify(fixture2) })
|
||||
const final = yield* Ref.get(state)
|
||||
expect(final.calls.length).toBe(1)
|
||||
expect(final.calls[0].url).toContain("/api.json")
|
||||
@@ -395,7 +612,7 @@ describe("ModelsDev Service", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("refresh(false) skips fetch when the KV entry is fresh", () =>
|
||||
it.live("refresh(false) skips fetch when the persisted catalog is fresh", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
writeCache(cache, fixture, Date.now() - 1000)
|
||||
@@ -410,7 +627,7 @@ describe("ModelsDev Service", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("refresh(false) fetches when the KV entry is stale", () =>
|
||||
it.live("refresh(false) fetches when the persisted catalog is stale", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
writeCache(cache, fixture, Date.now() - 10 * 60 * 1000)
|
||||
@@ -447,7 +664,7 @@ describe("ModelsDev Service", () => {
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
writeCache(cache, fixture, Date.now() - 10 * 60 * 1000)
|
||||
const seeded = structuredClone(cache.values.get(cacheKey))
|
||||
const seeded = structuredClone(cache.values.get(source))
|
||||
// The server serves a byte-identical body, so the refresh still hits
|
||||
// the network but must not rewrite the cache or publish Refreshed.
|
||||
const state = yield* Ref.make(initialState)
|
||||
@@ -474,38 +691,24 @@ describe("ModelsDev Service", () => {
|
||||
)
|
||||
const final = yield* Ref.get(state)
|
||||
expect(final.calls.length).toBe(1)
|
||||
expect(cache.values.get(cacheKey)).toEqual(seeded)
|
||||
expect(cache.values.get(source)).toEqual(seeded)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("refresh(false) republishes once for legacy cache entries without a digest", () =>
|
||||
it.live("concurrent refreshes share the freshness check even when the body is unchanged", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
cache.values.set(cacheKey, { updatedAt: Date.now() - 10 * 60 * 1000, body: JSON.stringify(fixture) })
|
||||
writeCache(cache, fixture, Date.now() - 10 * 60 * 1000)
|
||||
const state = yield* Ref.make(initialState)
|
||||
yield* provided(
|
||||
state,
|
||||
cache,
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* ModelsDev.Service
|
||||
const bus = yield* Bus.Service
|
||||
const refreshed = yield* bus.subscribe(ModelsDev.Event.Refreshed).pipe(
|
||||
Stream.take(1),
|
||||
Stream.runCollect,
|
||||
Effect.forkScoped,
|
||||
Effect.flatMap((fiber) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.yieldNow
|
||||
yield* svc.refresh(false)
|
||||
return yield* Fiber.join(fiber)
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(refreshed.length).toBe(1)
|
||||
yield* Effect.all([svc.refresh(), svc.refresh(), svc.refresh()], { concurrency: "unbounded" })
|
||||
}),
|
||||
)
|
||||
// The rewritten entry now carries a digest, so later identical bodies stay quiet.
|
||||
expect(cache.values.get(cacheKey)).toMatchObject({ digest: bodyDigest(JSON.stringify(fixture)) })
|
||||
expect((yield* Ref.get(state)).calls).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -529,4 +732,25 @@ describe("ModelsDev Service", () => {
|
||||
expect(final.calls.length).toBeGreaterThanOrEqual(1)
|
||||
}),
|
||||
)
|
||||
|
||||
for (const body of ["{", JSON.stringify({ broken: {} })]) {
|
||||
it.live(`refresh preserves the live and persisted catalog when the response is invalid: ${body}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = makeCache()
|
||||
writeCache(cache, fixture)
|
||||
const state = yield* Ref.make({ ...initialState, body })
|
||||
yield* provided(
|
||||
state,
|
||||
cache,
|
||||
Effect.gen(function* () {
|
||||
const models = yield* ModelsDev.Service
|
||||
const before = yield* models.get()
|
||||
yield* models.refresh(true)
|
||||
expect(yield* models.get()).toBe(before)
|
||||
}),
|
||||
)
|
||||
expect(cache.values.get(source)?.body).toBe(JSON.stringify(fixture))
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -223,7 +223,6 @@ describe("Npm.add", () => {
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
// Several real Git installs and refreshes exceed Bun's default timeout on Windows.
|
||||
test("refreshes mutable Git packages once per service lifetime and preserves pinned or cached installs", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const fixture = await createGitFixture(tmp.path)
|
||||
@@ -263,7 +262,7 @@ describe("Npm.add", () => {
|
||||
return yield* npm.add(mutable, { refresh: true })
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
|
||||
expect(await Bun.file(path.join(offline.directory, "index.js")).text()).toContain('root: "second"')
|
||||
}, 30_000)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Npm.resolve", () => {
|
||||
|
||||
@@ -17,7 +17,6 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { ShellParse } from "@opencode-ai/core/shell/parse"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { location } from "./fixture/location"
|
||||
import { testEffect } from "./lib/effect"
|
||||
@@ -385,334 +384,3 @@ describe("Permission", () => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("shell scanner permission impact", () => {
|
||||
// Fixed cases require matching outcomes; remaining differences are investigation snapshots, not contracts.
|
||||
// These service-level cases all have command resources; tool tests cover skipped checks and directories.
|
||||
// Outcome pairs are [legacy, native].
|
||||
for (const fixture of [
|
||||
{
|
||||
name: "timed command preserves wrapper approvals",
|
||||
shell: "bash",
|
||||
command: "time -p git status",
|
||||
approved: ["time *"],
|
||||
exact: ["time -p git status"],
|
||||
denied: "time -p git status",
|
||||
savedEffect: ["allow", "allow"],
|
||||
exactEffect: ["allow", "allow"],
|
||||
deniedEffect: ["deny", "deny"],
|
||||
},
|
||||
{
|
||||
name: "coprocess command preserves wrapper approvals",
|
||||
shell: "bash",
|
||||
command: "coproc git status",
|
||||
approved: ["coproc *"],
|
||||
exact: ["coproc git status"],
|
||||
denied: "coproc git status",
|
||||
savedEffect: ["allow", "allow"],
|
||||
exactEffect: ["allow", "allow"],
|
||||
deniedEffect: ["deny", "deny"],
|
||||
},
|
||||
{
|
||||
name: "declarations and unset",
|
||||
shell: "bash",
|
||||
command: "export X=value; unset X; git status",
|
||||
approved: ["git status *"],
|
||||
exact: ["git status"],
|
||||
denied: "export *",
|
||||
savedEffect: ["allow", "allow"],
|
||||
exactEffect: ["allow", "allow"],
|
||||
deniedEffect: ["allow", "allow"],
|
||||
},
|
||||
{
|
||||
name: "export with an approved command substitution",
|
||||
shell: "bash",
|
||||
command: "export VERSION=$(git describe --tags); npm run build",
|
||||
approved: ["git describe *", "npm run build *"],
|
||||
exact: ["git describe --tags", "npm run build"],
|
||||
denied: "export *",
|
||||
savedEffect: ["allow", "allow"],
|
||||
exactEffect: ["allow", "allow"],
|
||||
deniedEffect: ["allow", "allow"],
|
||||
},
|
||||
{
|
||||
name: "export retains checks on the command substitution",
|
||||
shell: "bash",
|
||||
command: "export VERSION=$(git describe --tags); npm run build",
|
||||
approved: ["git describe *", "npm run build *"],
|
||||
exact: ["git describe --tags", "npm run build"],
|
||||
denied: "git describe *",
|
||||
savedEffect: ["allow", "allow"],
|
||||
exactEffect: ["allow", "allow"],
|
||||
deniedEffect: ["deny", "deny"],
|
||||
},
|
||||
{
|
||||
name: "redirect after a conditional list",
|
||||
shell: "bash",
|
||||
command: "printf ok && git status > output",
|
||||
approved: ["printf *", "git status *"],
|
||||
exact: ["printf ok", "git status"],
|
||||
denied: "git status",
|
||||
savedEffect: ["allow", "allow"],
|
||||
exactEffect: ["allow", "allow"],
|
||||
deniedEffect: ["deny", "deny"],
|
||||
},
|
||||
{
|
||||
name: "redirect after a pipeline",
|
||||
shell: "bash",
|
||||
command: "printf ok | cat < input > output",
|
||||
approved: ["printf *", "cat *"],
|
||||
exact: ["printf ok", "cat"],
|
||||
denied: "cat",
|
||||
savedEffect: ["allow", "allow"],
|
||||
exactEffect: ["allow", "allow"],
|
||||
deniedEffect: ["deny", "deny"],
|
||||
},
|
||||
{
|
||||
name: "assignment redirect followed by a command",
|
||||
shell: "bash",
|
||||
command: "FOO=bar > output; printf done",
|
||||
approved: ["printf *"],
|
||||
exact: ["printf done"],
|
||||
denied: "FOO=bar > output; printf done",
|
||||
savedEffect: ["ask", "allow"],
|
||||
exactEffect: ["ask", "allow"],
|
||||
deniedEffect: ["deny", "allow"],
|
||||
},
|
||||
{
|
||||
name: "assignment redirect with an approved command substitution",
|
||||
shell: "bash",
|
||||
command: "VERSION=$(git describe --tags) > build/version.txt",
|
||||
approved: ["git describe *"],
|
||||
exact: ["git describe --tags"],
|
||||
denied: "VERSION=$(git describe --tags) > build/version.txt",
|
||||
savedEffect: ["ask", "allow"],
|
||||
exactEffect: ["ask", "allow"],
|
||||
deniedEffect: ["deny", "allow"],
|
||||
},
|
||||
{
|
||||
name: "substitution in a saved prefix",
|
||||
shell: "bash",
|
||||
command: "git $(printf diff) --stat",
|
||||
approved: ["git *", "printf *"],
|
||||
exact: ["git $(printf diff) --stat", "printf diff"],
|
||||
denied: "git $(printf diff) --stat",
|
||||
savedEffect: ["allow", "allow"],
|
||||
exactEffect: ["allow", "allow"],
|
||||
deniedEffect: ["deny", "deny"],
|
||||
},
|
||||
{
|
||||
name: "standalone PowerShell scriptblock caller",
|
||||
shell: "pwsh",
|
||||
command: "ForEach-Object { Write-Output value }",
|
||||
approved: ["Write-Output *"],
|
||||
exact: ["Write-Output value"],
|
||||
denied: "ForEach-Object *",
|
||||
savedEffect: ["allow", "allow"],
|
||||
exactEffect: ["allow", "allow"],
|
||||
deniedEffect: ["allow", "allow"],
|
||||
},
|
||||
{
|
||||
name: "tab-separated PowerShell command",
|
||||
shell: "pwsh",
|
||||
command: "git\tstatus; Write-Output done",
|
||||
approved: ["git status *", "Write-Output *"],
|
||||
exact: ["git status", "Write-Output done"],
|
||||
denied: "git\tstatus",
|
||||
savedEffect: ["allow", "ask"],
|
||||
exactEffect: ["allow", "ask"],
|
||||
deniedEffect: ["allow", "deny"],
|
||||
},
|
||||
{
|
||||
name: "PowerShell equals-joined argument",
|
||||
shell: "pwsh",
|
||||
command: "git --work-tree=src status",
|
||||
approved: ["git --work-tree *"],
|
||||
exact: ["git --work-tree"],
|
||||
denied: "git --work-tree",
|
||||
savedEffect: ["allow", "ask"],
|
||||
exactEffect: ["allow", "ask"],
|
||||
deniedEffect: ["deny", "allow"],
|
||||
},
|
||||
] as const) {
|
||||
for (const scenario of [
|
||||
{ name: "no approval", saved: [], rules: [], expected: ["ask", "ask"] },
|
||||
{ name: "saved wildcard", saved: ["*"], rules: [], expected: ["allow", "allow"] },
|
||||
{ name: "saved command approvals", saved: fixture.approved, rules: [], expected: fixture.savedEffect },
|
||||
{
|
||||
name: "exact configured approvals",
|
||||
saved: [],
|
||||
rules: fixture.exact.map((resource): Permission.Rule => ({ action: "shell", resource, effect: "allow" })),
|
||||
expected: fixture.exactEffect,
|
||||
},
|
||||
{
|
||||
name: "exact saved approvals",
|
||||
saved: fixture.exact,
|
||||
rules: [],
|
||||
expected: fixture.exactEffect,
|
||||
},
|
||||
{
|
||||
name: "configured deny despite saved wildcard",
|
||||
saved: ["*"],
|
||||
rules: [{ action: "shell", resource: fixture.denied, effect: "deny" }] satisfies Permission.Ruleset,
|
||||
expected: fixture.deniedEffect,
|
||||
},
|
||||
] as const) {
|
||||
it.live(`${fixture.name}: ${scenario.name}`, () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup(scenario.rules)
|
||||
const saved = yield* PermissionSaved.Service
|
||||
yield* saved.add({ projectID: Project.ID.global, action: "shell", resources: scenario.saved })
|
||||
const service = yield* Permission.Service
|
||||
|
||||
for (const [index, portable] of [false, true].entries()) {
|
||||
const parsed = yield* ShellParse.scan(fixture.command, fixture.shell, "/project", { portable })
|
||||
expect(parsed.commands.length).toBeGreaterThan(0)
|
||||
expect(parsed.directories).toEqual([])
|
||||
const result = yield* service.ask(
|
||||
assertion({
|
||||
action: "shell",
|
||||
resources: parsed.commands.map((command) => command.resource),
|
||||
save: parsed.commands.map((command) => command.save),
|
||||
}),
|
||||
)
|
||||
expect(result.effect, portable ? "native" : "legacy").toBe(scenario.expected[index])
|
||||
const pending = yield* service.list()
|
||||
expect(pending).toHaveLength(result.effect === "ask" ? 1 : 0)
|
||||
if (result.effect !== "ask") continue
|
||||
expect(pending[0]?.resources).toEqual(parsed.commands.map((command) => command.resource))
|
||||
expect(pending[0]?.save).toEqual(parsed.commands.map((command) => command.save))
|
||||
yield* service.reply({ requestID: result.id, reply: "once" })
|
||||
expect(yield* service.list()).toEqual([])
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Grant/repeat rows select the granting parser; repeat columns select the parser used afterwards.
|
||||
for (const fixture of [
|
||||
{
|
||||
name: "numeric npm script prefix",
|
||||
shell: "bash",
|
||||
command: "npm run 123",
|
||||
grants: [["npm run *"], ["npm run 123 *"]],
|
||||
repeat: [
|
||||
["allow", "allow"],
|
||||
["allow", "allow"],
|
||||
],
|
||||
next: "npm run build",
|
||||
nextEffect: ["allow", "ask"],
|
||||
},
|
||||
{
|
||||
name: "numeric AWS option prefix",
|
||||
shell: "bash",
|
||||
command: "aws --cli-read-timeout 60 s3 ls",
|
||||
grants: [["aws --cli-read-timeout s3 *"], ["aws --cli-read-timeout 60 *"]],
|
||||
repeat: [
|
||||
["ask", "ask"],
|
||||
["allow", "allow"],
|
||||
],
|
||||
next: "aws --cli-read-timeout 60 ec2 describe-instances",
|
||||
nextEffect: ["ask", "allow"],
|
||||
},
|
||||
{
|
||||
name: "substitution prefix",
|
||||
shell: "bash",
|
||||
command: "git $(printf diff) --stat",
|
||||
grants: [
|
||||
["git --stat *", "printf *"],
|
||||
["git $(printf diff) *", "printf *"],
|
||||
],
|
||||
repeat: [
|
||||
["ask", "ask"],
|
||||
["allow", "allow"],
|
||||
],
|
||||
next: "git --stat",
|
||||
nextEffect: ["allow", "ask"],
|
||||
},
|
||||
{
|
||||
name: "redirect prefix",
|
||||
shell: "bash",
|
||||
command: "printf ok && git status > output",
|
||||
grants: [
|
||||
["printf *", "git status *"],
|
||||
["printf *", "git status *"],
|
||||
],
|
||||
repeat: [
|
||||
["allow", "allow"],
|
||||
["allow", "allow"],
|
||||
],
|
||||
next: "git status --short",
|
||||
nextEffect: ["allow", "allow"],
|
||||
},
|
||||
{
|
||||
name: "assignment redirect prefix",
|
||||
shell: "bash",
|
||||
command: "FOO=bar > output; printf done",
|
||||
grants: [["printf *"], ["printf *"]],
|
||||
// Identical saved rules cover only the native resource, regardless of which parser saved them.
|
||||
repeat: [
|
||||
["ask", "allow"],
|
||||
["ask", "allow"],
|
||||
],
|
||||
next: "printf next",
|
||||
nextEffect: ["allow", "allow"],
|
||||
},
|
||||
{
|
||||
name: "PowerShell tab prefix",
|
||||
shell: "pwsh",
|
||||
command: "git\tstatus; Write-Output done",
|
||||
grants: [["Write-Output *"], ["git\tstatus *", "Write-Output *"]],
|
||||
repeat: [
|
||||
["allow", "ask"],
|
||||
["allow", "allow"],
|
||||
],
|
||||
next: "git status",
|
||||
nextEffect: ["ask", "ask"],
|
||||
},
|
||||
] as const) {
|
||||
for (const [origin, portable] of [false, true].entries()) {
|
||||
it.live(`${fixture.name}: always allow from ${portable ? "native" : "legacy"}, then use either parser`, () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup()
|
||||
const service = yield* Permission.Service
|
||||
const saved = yield* PermissionSaved.Service
|
||||
const parsed = yield* ShellParse.scan(fixture.command, fixture.shell, "/project", { portable })
|
||||
const first = yield* service.ask(
|
||||
assertion({
|
||||
action: "shell",
|
||||
resources: parsed.commands.map((command) => command.resource),
|
||||
save: parsed.commands.map((command) => command.save),
|
||||
}),
|
||||
)
|
||||
expect(first.effect).toBe("ask")
|
||||
expect(yield* service.list()).toHaveLength(1)
|
||||
yield* service.reply({ requestID: first.id, reply: "always" })
|
||||
expect(yield* service.list()).toEqual([])
|
||||
expect((yield* saved.list({ projectID: Project.ID.global })).map((rule) => rule.resource).sort()).toEqual(
|
||||
[...fixture.grants[portable ? 1 : 0]].sort(),
|
||||
)
|
||||
|
||||
for (const [index, target] of [false, true].entries()) {
|
||||
for (const command of [fixture.command, fixture.next]) {
|
||||
const parsed = yield* ShellParse.scan(command, fixture.shell, "/project", { portable: target })
|
||||
const result = yield* service.ask(
|
||||
assertion({
|
||||
action: "shell",
|
||||
resources: parsed.commands.map((command) => command.resource),
|
||||
save: parsed.commands.map((command) => command.save),
|
||||
}),
|
||||
)
|
||||
expect(result.effect, `${target ? "native" : "legacy"}: ${command}`).toBe(
|
||||
command === fixture.next ? fixture.nextEffect[origin] : fixture.repeat[origin]?.[index],
|
||||
)
|
||||
if (result.effect === "ask") yield* service.reply({ requestID: result.id, reply: "once" })
|
||||
expect(yield* service.list()).toEqual([])
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -282,47 +282,6 @@ describe("Plugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps plugins active when a tool registration is invalid", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const tools = yield* Tool.Service
|
||||
const agents = yield* Agent.Service
|
||||
yield* plugins.activate([
|
||||
{
|
||||
id: "partial-tools",
|
||||
version: "1",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.tool.transform((draft) => {
|
||||
const tool = {
|
||||
name: "healthy",
|
||||
description: "Healthy tool",
|
||||
input: Schema.Struct({}),
|
||||
execute: () => Effect.succeed({ content: "ok" }),
|
||||
options: { codemode: false },
|
||||
}
|
||||
draft.add({ ...tool, name: "invalid", options: { namespace: "invalid..namespace" } })
|
||||
draft.add(tool)
|
||||
})
|
||||
yield* ctx.agent.transform((draft) =>
|
||||
draft.update("configured", (agent) => {
|
||||
agent.description = "setup continued"
|
||||
}),
|
||||
)
|
||||
}),
|
||||
},
|
||||
])
|
||||
|
||||
expect(yield* plugins.list()).toEqual([
|
||||
{ id: Plugin.ID.make("partial-tools"), source: { type: "builtin" }, status: "active", tui: false },
|
||||
])
|
||||
expect((yield* agents.get(Agent.ID.make("configured")))?.description).toBe("setup continued")
|
||||
expect((yield* tools.snapshot()).definitions.map((tool) => tool.name)).toEqual(["healthy", "execute"])
|
||||
yield* plugins.activate([])
|
||||
expect((yield* tools.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("restores the previous plugin when its replacement fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
|
||||
@@ -114,7 +114,6 @@ export function host(overrides: Overrides = {}): Plugin.Context {
|
||||
},
|
||||
tool: overrides.tool ?? {
|
||||
transform: () => Effect.die("unused tool.transform"),
|
||||
reload: () => Effect.die("unused tool.reload"),
|
||||
hook: () => Effect.die("unused tool.hook"),
|
||||
},
|
||||
vcs: overrides.vcs ?? {
|
||||
|
||||
@@ -21,12 +21,7 @@ it.effect("defaults only known Code Mode MCP servers to direct tools", () =>
|
||||
{
|
||||
name: "cloudflare code mode",
|
||||
server: { type: "remote", url: "https://mcp.cloudflare.com/mcp/" },
|
||||
codemode: undefined,
|
||||
},
|
||||
{
|
||||
name: "cloudflare raw tools",
|
||||
server: { type: "remote", url: "https://mcp.cloudflare.com/mcp?codemode=false" },
|
||||
codemode: undefined,
|
||||
codemode: false,
|
||||
},
|
||||
{
|
||||
name: "cloudflare docs",
|
||||
|
||||
@@ -72,7 +72,6 @@ const run = Effect.fnUntraced(function* (events: ReadonlyArray<SessionEvent.Agen
|
||||
},
|
||||
tool: {
|
||||
transform: () => Effect.die("unused tool.transform"),
|
||||
reload: () => Effect.die("unused tool.reload"),
|
||||
hook: (name, callback) => {
|
||||
if (name === "execute.after") {
|
||||
// Hook names and callbacks are correlated, but TypeScript does not narrow this generic registration API.
|
||||
|
||||
@@ -636,77 +636,4 @@ describe("fromPromise", () => {
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("adapts tool mutation, replay, and disposal through the Promise API", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const registry = yield* Tool.Service
|
||||
const host = yield* PluginHost.make(plugins)
|
||||
const progress: Tool.Metadata[] = []
|
||||
let greeting = "Hello"
|
||||
let registration: { dispose(): Promise<void> } | undefined
|
||||
yield* host.tool.transform((draft) => {
|
||||
const text = greeting
|
||||
draft.add({
|
||||
name: "hello",
|
||||
description: "Hello",
|
||||
options: { namespace: "acme", codemode: false },
|
||||
input: Schema.Struct({ name: Schema.String }),
|
||||
output: Schema.String,
|
||||
execute: ({ name }, context) =>
|
||||
context.progress({ phase: "original" }).pipe(Effect.as({ output: `${text}, ${name}!` })),
|
||||
})
|
||||
draft.add({
|
||||
name: "temporary",
|
||||
description: "Temporary",
|
||||
input: Schema.Struct({}),
|
||||
options: { codemode: false },
|
||||
execute: () => Effect.succeed({ content: "temporary" }),
|
||||
})
|
||||
})
|
||||
yield* PluginPromise.fromPromise(
|
||||
define({
|
||||
id: "promise-update",
|
||||
setup: async (ctx) => {
|
||||
registration = await ctx.tool.transform((draft) => {
|
||||
draft.update("missing", () => {
|
||||
throw new Error("must not create a tool")
|
||||
})
|
||||
draft.update("acme_hello", (tool) => {
|
||||
const execute = tool.execute
|
||||
tool.description = "Wrapped"
|
||||
tool.execute = async (input, context) => {
|
||||
const result = await execute(input, context)
|
||||
return { ...result, output: `${result.output} Wrapped.` }
|
||||
}
|
||||
})
|
||||
draft.remove("temporary")
|
||||
})
|
||||
greeting = "Hi"
|
||||
await ctx.tool.reload()
|
||||
},
|
||||
}),
|
||||
).effect(host)
|
||||
const snapshot = yield* registry.snapshot()
|
||||
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["acme_hello", "execute"])
|
||||
expect(snapshot.definitions[0]?.description).toBe("Wrapped")
|
||||
expect(
|
||||
yield* snapshot.execute({
|
||||
sessionID: Session.ID.make("ses_promise_update"),
|
||||
agent: Agent.ID.make("build"),
|
||||
messageID: SessionMessage.ID.make("msg_promise_update"),
|
||||
progress: (value) => Effect.sync(() => progress.push(value)),
|
||||
call: { type: "tool-call", id: "call_promise_update", name: "acme_hello", input: { name: "world" } },
|
||||
}),
|
||||
).toMatchObject({ output: "Hi, world! Wrapped." })
|
||||
expect(progress).toEqual([{ phase: "original" }])
|
||||
const registered = registration
|
||||
if (!registered) throw new Error("Expected registration")
|
||||
yield* Effect.promise(() => registered.dispose())
|
||||
yield* Effect.promise(() => registered.dispose())
|
||||
const restored = yield* registry.snapshot()
|
||||
expect(restored.definitions.map((tool) => tool.name)).toEqual(["acme_hello", "temporary", "execute"])
|
||||
expect(restored.definitions[0]?.description).toBe("Hello")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -4,8 +4,6 @@ import { Database } from "@opencode-ai/core/database/database"
|
||||
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 { Job } from "@opencode-ai/core/job"
|
||||
import { KV } from "@opencode-ai/core/kv"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import type { LocationServices } from "@opencode-ai/core/location-services"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
@@ -25,9 +23,7 @@ import { Context, Deferred, Effect, Exit, Fiber, Layer, LayerMap, Scope } from "
|
||||
import { eq } from "drizzle-orm"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionStore.node, Job.node, KV.node, Session.node])),
|
||||
)
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionStore.node])))
|
||||
|
||||
describe("SessionExecution lifecycle", () => {
|
||||
test("classifies success and typed failure terminals", () => {
|
||||
@@ -64,13 +60,14 @@ describe("SessionExecution lifecycle", () => {
|
||||
const idle = Session.ID.make("ses_recover_idle")
|
||||
yield* seedSessions(database, [parent], { time_suspended: Date.now() })
|
||||
yield* seedSessions(database, [idle])
|
||||
// Children recover through background Job records, never through the root claim sweep.
|
||||
// An orphaned child is never resumed: the resumed parent re-runs its
|
||||
// tool call and spawns a fresh child instead.
|
||||
yield* seedSessions(database, [child], { time_suspended: Date.now(), parent_id: parent })
|
||||
|
||||
expect(yield* store.listSuspended()).toEqual([parent])
|
||||
|
||||
// The sweep clears orphaned child claims outright; parents keep theirs.
|
||||
yield* store.releaseChildClaims([])
|
||||
yield* store.releaseChildClaims
|
||||
expect(yield* claims(database)).toEqual({ [parent]: true, [child]: false, [idle]: false })
|
||||
}),
|
||||
)
|
||||
@@ -150,66 +147,6 @@ describe("SessionExecution lifecycle", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not resume a user-cancelled background child whose notification was not admitted", () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const parent = Session.ID.make("ses_cancelled_background_parent")
|
||||
const child = Session.ID.make("ses_cancelled_background_child")
|
||||
yield* seedSessions(database, [parent])
|
||||
yield* seedSessions(database, [child], { parent_id: parent })
|
||||
|
||||
const running = yield* Deferred.make<void>()
|
||||
const scope = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
|
||||
const jobs = yield* Job.make.pipe(Scope.provide(scope))
|
||||
const context = yield* buildExecution(
|
||||
scope,
|
||||
() => Deferred.succeed(running, undefined).pipe(Effect.andThen(Effect.never)),
|
||||
undefined,
|
||||
jobs,
|
||||
)
|
||||
const execution = Context.get(context, SessionExecution.Service)
|
||||
yield* jobs.start({
|
||||
id: child,
|
||||
type: "subagent",
|
||||
recovery: {
|
||||
kind: "subagent",
|
||||
parentSessionID: parent,
|
||||
childSessionID: child,
|
||||
agent: "general",
|
||||
description: "Cancelled inspection",
|
||||
},
|
||||
run: execution.resume(child).pipe(Effect.as("unused")),
|
||||
})
|
||||
yield* jobs.background(child)
|
||||
yield* Deferred.await(running)
|
||||
expect(yield* execution.interrupt(child)).toBeTrue()
|
||||
yield* execution.awaitIdle(child)
|
||||
expect((yield* jobs.wait({ id: child })).info?.status).toBe("cancelled")
|
||||
expect(yield* jobs.pendingBackground).toMatchObject([{ id: child, status: "cancelled" }])
|
||||
expect((yield* claims(database))[child]).toBe(false)
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
|
||||
const restartedScope = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() => Scope.close(restartedScope, Exit.void))
|
||||
const restartedJobs = yield* Job.make.pipe(Scope.provide(restartedScope))
|
||||
const drained: Session.ID[] = []
|
||||
const restarted = yield* buildExecution(
|
||||
restartedScope,
|
||||
({ sessionID }) => Effect.sync(() => void drained.push(sessionID)),
|
||||
undefined,
|
||||
restartedJobs,
|
||||
)
|
||||
yield* Context.get(restarted, SessionRestart.Service).resumeSuspendedSessions
|
||||
yield* Context.get(restarted, SessionExecution.Service).awaitIdle(parent)
|
||||
expect(drained).toEqual([parent])
|
||||
expect(yield* SessionInbox.list(database.db, parent)).toMatchObject([
|
||||
{ payload: { text: expect.stringContaining("Subagent cancelled"), metadata: { state: "cancelled" } } },
|
||||
])
|
||||
expect(yield* restartedJobs.pendingBackground).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("starts every claimed execution without waiting for earlier drains to finish", () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
@@ -367,518 +304,6 @@ describe("SessionExecution lifecycle", () => {
|
||||
)
|
||||
})
|
||||
|
||||
describe("SessionRestart background recovery", () => {
|
||||
it.effect("admits orphaned shell notices without waking and delivers them once on the next run", () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const jobs = yield* Job.Service
|
||||
const bus = yield* Bus.Service
|
||||
const parent = Session.ID.make("ses_background_recovery_parent")
|
||||
const child = Session.ID.make("ses_background_recovery_child")
|
||||
yield* seedSessions(database, [parent])
|
||||
yield* seedSessions(database, [child], { parent_id: parent, time_suspended: Date.now() })
|
||||
yield* seedBackground(jobs, parent, [
|
||||
{ id: "call-background-shell", shellID: "sh_background_orphan", command: "sleep 60" },
|
||||
])
|
||||
yield* seedBackground(jobs, child, [{ id: "call-child-shell", shellID: "sh_child_orphan", command: "sleep 30" }])
|
||||
|
||||
expect(yield* store.listSuspended()).toEqual([])
|
||||
expect(yield* jobs.pendingBackground).toHaveLength(2)
|
||||
|
||||
const drained: Session.ID[] = []
|
||||
const scope = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
|
||||
const restarted = yield* Job.make.pipe(Effect.provideService(Scope.Scope, scope))
|
||||
const context = yield* buildExecution(
|
||||
scope,
|
||||
({ sessionID }) =>
|
||||
Effect.sync(() => void drained.push(sessionID)).pipe(
|
||||
Effect.andThen(SessionInbox.promote(database.db, bus, sessionID, "steer")),
|
||||
Effect.asVoid,
|
||||
),
|
||||
undefined,
|
||||
restarted,
|
||||
)
|
||||
const restart = Context.get(context, SessionRestart.Service)
|
||||
yield* restart.resumeSuspendedSessions
|
||||
|
||||
expect((yield* store.context(parent)).filter((message) => message.type === "synthetic")).toEqual([])
|
||||
expect(yield* SessionInbox.list(database.db, parent)).toMatchObject([
|
||||
{
|
||||
type: "synthetic",
|
||||
payload: {
|
||||
description: "sleep 60",
|
||||
text: expect.stringContaining("server restarted"),
|
||||
metadata: {
|
||||
source: "shell",
|
||||
jobID: "call-background-shell",
|
||||
shellID: "sh_background_orphan",
|
||||
state: "cancelled",
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(yield* SessionInbox.list(database.db, child)).toMatchObject([
|
||||
{
|
||||
type: "synthetic",
|
||||
payload: {
|
||||
metadata: {
|
||||
source: "shell",
|
||||
jobID: "call-child-shell",
|
||||
shellID: "sh_child_orphan",
|
||||
state: "cancelled",
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(drained).toEqual([])
|
||||
expect(yield* claims(database)).toEqual({ [parent]: false, [child]: false })
|
||||
expect(yield* restarted.pendingBackground).toEqual([])
|
||||
|
||||
yield* restart.resumeSuspendedSessions
|
||||
expect(yield* SessionInbox.list(database.db, parent)).toHaveLength(1)
|
||||
expect(drained).toEqual([])
|
||||
const execution = Context.get(context, SessionExecution.Service)
|
||||
yield* execution.resume(parent)
|
||||
expect(yield* SessionInbox.list(database.db, parent)).toEqual([])
|
||||
expect((yield* store.context(parent)).filter((message) => message.type === "synthetic")).toHaveLength(1)
|
||||
yield* execution.resume(parent)
|
||||
expect((yield* store.context(parent)).filter((message) => message.type === "synthetic")).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves locally running background work", () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const jobs = yield* Job.Service
|
||||
const parent = Session.ID.make("ses_background_existing_parent")
|
||||
yield* seedSessions(database, [parent])
|
||||
yield* seedBackground(jobs, parent, [{ id: "call-running-shell", shellID: "sh_running", command: "sleep 60" }])
|
||||
|
||||
const scope = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
|
||||
const context = yield* buildExecution(scope, () => Effect.void)
|
||||
const restart = Context.get(context, SessionRestart.Service)
|
||||
yield* restart.resumeSuspendedSessions
|
||||
|
||||
expect((yield* store.context(parent)).filter((message) => message.type === "synthetic")).toEqual([])
|
||||
expect(yield* jobs.get("call-running-shell")).toMatchObject({ status: "running" })
|
||||
expect(yield* jobs.pendingBackground).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves a silent shell failure persisted before its completion notification", () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const jobs = yield* Job.Service
|
||||
const sessionID = Session.ID.make("ses_background_completed_shell")
|
||||
yield* seedSessions(database, [sessionID])
|
||||
const complete = yield* Deferred.make<string>()
|
||||
yield* jobs.start({
|
||||
id: "call-completed-shell",
|
||||
type: "shell",
|
||||
recovery: {
|
||||
kind: "shell",
|
||||
sessionID,
|
||||
shellID: "sh_completed",
|
||||
command: "exit 7",
|
||||
},
|
||||
run: Deferred.await(complete),
|
||||
})
|
||||
yield* jobs.background("call-completed-shell")
|
||||
yield* Deferred.succeed(complete, "(no output)\n\nCommand exited with code 7.")
|
||||
yield* jobs.wait({ id: "call-completed-shell" })
|
||||
|
||||
const scope = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
|
||||
const restarted = yield* Job.make.pipe(Effect.provideService(Scope.Scope, scope))
|
||||
const context = yield* buildExecution(scope, () => Effect.void, undefined, restarted)
|
||||
yield* Context.get(context, SessionRestart.Service).resumeSuspendedSessions
|
||||
|
||||
expect(yield* SessionInbox.list(database.db, sessionID)).toMatchObject([
|
||||
{
|
||||
type: "synthetic",
|
||||
payload: {
|
||||
text: expect.stringContaining("(no output)\n\nCommand exited with code 7."),
|
||||
metadata: { source: "shell", shellID: "sh_completed", state: "completed" },
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(yield* restarted.pendingBackground).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
for (const delivered of [false, true]) {
|
||||
it.effect(`does not duplicate a shell notification already ${delivered ? "delivered" : "admitted"}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
const jobs = yield* Job.Service
|
||||
const sessions = yield* Session.Service
|
||||
const sessionID = Session.ID.make("ses_shell_notification_retry")
|
||||
yield* seedSessions(database, [sessionID])
|
||||
yield* seedBackground(jobs, sessionID, [
|
||||
{ id: "call-shell-notified", shellID: "sh_notified", command: "echo done" },
|
||||
])
|
||||
const background = (yield* jobs.pendingBackground)[0]
|
||||
if (!background) return yield* Effect.die("background record missing")
|
||||
yield* sessions.synthetic({
|
||||
id: background.notificationID,
|
||||
sessionID,
|
||||
text: "Command already completed",
|
||||
metadata: { source: "shell", shellID: "sh_notified", state: "completed" },
|
||||
resume: false,
|
||||
})
|
||||
if (delivered) yield* SessionInbox.promote(database.db, bus, sessionID, "steer")
|
||||
|
||||
const scope = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
|
||||
const restarted = yield* Job.make.pipe(Effect.provideService(Scope.Scope, scope))
|
||||
const context = yield* buildExecution(scope, () => Effect.void, undefined, restarted)
|
||||
yield* Context.get(context, SessionRestart.Service).resumeSuspendedSessions
|
||||
|
||||
expect(yield* restarted.pendingBackground).toEqual([])
|
||||
expect(yield* SessionInbox.list(database.db, sessionID)).toHaveLength(delivered ? 0 : 1)
|
||||
yield* SessionInbox.promote(database.db, bus, sessionID, "steer")
|
||||
expect(yield* sessions.messages({ sessionID })).toMatchObject([
|
||||
{
|
||||
id: background.notificationID,
|
||||
type: "synthetic",
|
||||
text: "Command already completed",
|
||||
metadata: { state: "completed" },
|
||||
},
|
||||
])
|
||||
expect(yield* sessions.messages({ sessionID })).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.effect("acknowledges recovery markers when their owning session is deleted", () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const jobs = yield* Job.Service
|
||||
const sessionID = Session.ID.make("ses_background_deleted")
|
||||
yield* seedSessions(database, [sessionID])
|
||||
yield* seedBackground(jobs, sessionID, [{ id: "call-deleted-shell", shellID: "sh_deleted", command: "sleep 60" }])
|
||||
yield* database.db.delete(SessionTable).where(eq(SessionTable.id, sessionID)).run().pipe(Effect.orDie)
|
||||
|
||||
const scope = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
|
||||
const restarted = yield* Job.make.pipe(Effect.provideService(Scope.Scope, scope))
|
||||
const context = yield* buildExecution(scope, () => Effect.void, undefined, restarted)
|
||||
yield* Context.get(context, SessionRestart.Service).resumeSuspendedSessions
|
||||
|
||||
expect(yield* restarted.pendingBackground).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("delivers cancellation at the resumed parent's next step", () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const jobs = yield* Job.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const bus = yield* Bus.Service
|
||||
const parent = Session.ID.make("ses_background_claimed_parent")
|
||||
yield* seedSessions(database, [parent], { time_suspended: Date.now() })
|
||||
yield* seedBackground(jobs, parent, [{ id: "call-claimed-shell", shellID: "sh_claimed", command: "sleep 60" }])
|
||||
|
||||
const observed = yield* Deferred.make<string[]>()
|
||||
const scope = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
|
||||
const restarted = yield* Job.make.pipe(Effect.provideService(Scope.Scope, scope))
|
||||
const context = yield* buildExecution(
|
||||
scope,
|
||||
({ sessionID }) =>
|
||||
SessionInbox.promote(database.db, bus, sessionID, "steer").pipe(
|
||||
Effect.andThen(store.context(sessionID)),
|
||||
Effect.orDie,
|
||||
Effect.flatMap((messages) =>
|
||||
Deferred.succeed(
|
||||
observed,
|
||||
messages.filter((message) => message.type === "synthetic").map((message) => message.text),
|
||||
),
|
||||
),
|
||||
Effect.asVoid,
|
||||
),
|
||||
undefined,
|
||||
restarted,
|
||||
)
|
||||
const execution = Context.get(context, SessionExecution.Service)
|
||||
yield* Context.get(context, SessionRestart.Service).resumeSuspendedSessions
|
||||
expect(yield* Deferred.await(observed)).toEqual([
|
||||
"The server restarted while you were working. Continue from where you left off without repeating completed work.",
|
||||
expect.stringContaining("Command cancelled because the server restarted"),
|
||||
])
|
||||
yield* execution.awaitIdle(parent)
|
||||
expect(yield* SessionInbox.list(database.db, parent)).toEqual([])
|
||||
expect((yield* claims(database))[parent]).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("resumes a background subagent and notifies its parent exactly once", () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const jobs = yield* Job.Service
|
||||
const parent = Session.ID.make("ses_subagent_recovery_parent")
|
||||
const child = Session.ID.make("ses_subagent_recovery_child")
|
||||
const unrelated = Session.ID.make("ses_subagent_unrelated_child")
|
||||
yield* seedSessions(database, [parent], { time_suspended: Date.now(), resume_attempts: 1 })
|
||||
yield* seedSessions(database, [child, unrelated], { parent_id: parent, time_suspended: Date.now() })
|
||||
yield* jobs.start({
|
||||
id: child,
|
||||
type: "subagent",
|
||||
recovery: {
|
||||
kind: "subagent",
|
||||
parentSessionID: parent,
|
||||
childSessionID: child,
|
||||
agent: "explore",
|
||||
description: "Inspect recovery",
|
||||
},
|
||||
run: Effect.never,
|
||||
})
|
||||
yield* jobs.background(child)
|
||||
|
||||
const resumed = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const parentResumed = yield* Deferred.make<void>()
|
||||
const parentWoken = yield* Deferred.make<void>()
|
||||
const drained: Session.ID[] = []
|
||||
const scope = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
|
||||
const restarted = yield* Job.make.pipe(Effect.provideService(Scope.Scope, scope))
|
||||
const context = yield* buildExecution(
|
||||
scope,
|
||||
({ sessionID }) =>
|
||||
Effect.gen(function* () {
|
||||
drained.push(sessionID)
|
||||
if (sessionID === child) {
|
||||
yield* Deferred.succeed(resumed, undefined)
|
||||
yield* Deferred.await(release)
|
||||
return
|
||||
}
|
||||
yield* Deferred.succeed(
|
||||
drained.filter((id) => id === parent).length === 1 ? parentResumed : parentWoken,
|
||||
undefined,
|
||||
)
|
||||
}),
|
||||
undefined,
|
||||
restarted,
|
||||
)
|
||||
const restart = Context.get(context, SessionRestart.Service)
|
||||
const execution = Context.get(context, SessionExecution.Service)
|
||||
yield* restart.resumeSuspendedSessions
|
||||
yield* Deferred.await(resumed)
|
||||
yield* Deferred.await(parentResumed)
|
||||
yield* execution.awaitIdle(parent)
|
||||
|
||||
yield* restart.resumeSuspendedSessions
|
||||
expect(drained.toSorted()).toEqual([child, parent].toSorted())
|
||||
expect(yield* claims(database)).toEqual({ [parent]: false, [child]: true, [unrelated]: false })
|
||||
expect(yield* attempts(database, child)).toBe(1)
|
||||
expect(yield* restarted.get(child)).toMatchObject({ status: "running" })
|
||||
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Deferred.await(parentWoken)
|
||||
expect(drained.filter((id) => id === child)).toHaveLength(1)
|
||||
expect(drained.filter((id) => id === parent)).toHaveLength(2)
|
||||
expect(yield* SessionInbox.list(database.db, parent)).toMatchObject([
|
||||
{
|
||||
payload: {
|
||||
description: "Inspect recovery",
|
||||
metadata: { source: "subagent", childID: child, agent: "explore", state: "completed" },
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(yield* restarted.pendingBackground).toEqual([])
|
||||
yield* restart.resumeSuspendedSessions
|
||||
expect(yield* SessionInbox.list(database.db, parent)).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("delivers a subagent result persisted before restart without rerunning the child", () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const jobs = yield* Job.Service
|
||||
const parent = Session.ID.make("ses_subagent_completed_parent")
|
||||
const child = Session.ID.make("ses_subagent_completed_child")
|
||||
yield* seedSessions(database, [parent])
|
||||
yield* seedSessions(database, [child], { parent_id: parent })
|
||||
const complete = yield* Deferred.make<string>()
|
||||
yield* jobs.start({
|
||||
id: child,
|
||||
type: "subagent",
|
||||
recovery: {
|
||||
kind: "subagent",
|
||||
parentSessionID: parent,
|
||||
childSessionID: child,
|
||||
agent: "explore",
|
||||
description: "Completed inspection",
|
||||
},
|
||||
run: Deferred.await(complete),
|
||||
})
|
||||
yield* jobs.background(child)
|
||||
yield* Deferred.succeed(complete, "Recovered result")
|
||||
yield* jobs.wait({ id: child })
|
||||
|
||||
const parentWoken = yield* Deferred.make<void>()
|
||||
const drained: Session.ID[] = []
|
||||
const scope = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
|
||||
const restarted = yield* Job.make.pipe(Effect.provideService(Scope.Scope, scope))
|
||||
const context = yield* buildExecution(
|
||||
scope,
|
||||
({ sessionID }) =>
|
||||
Effect.sync(() => void drained.push(sessionID)).pipe(
|
||||
Effect.andThen(Deferred.succeed(parentWoken, undefined)),
|
||||
),
|
||||
undefined,
|
||||
restarted,
|
||||
)
|
||||
yield* Context.get(context, SessionRestart.Service).resumeSuspendedSessions
|
||||
yield* Deferred.await(parentWoken)
|
||||
|
||||
expect(drained).toEqual([parent])
|
||||
expect(yield* SessionInbox.list(database.db, parent)).toMatchObject([
|
||||
{ payload: { text: expect.stringContaining("Recovered result"), metadata: { state: "completed" } } },
|
||||
])
|
||||
expect(yield* restarted.pendingBackground).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
for (const resumeAttempts of [1, 2]) {
|
||||
it.effect(`honors a suspended parent's restart budget after ${resumeAttempts} attempts before notifying it`, () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
const jobs = yield* Job.Service
|
||||
const parent = Session.ID.make("ses_subagent_budget_parent")
|
||||
const children = [
|
||||
Session.ID.make("ses_subagent_budget_child_1"),
|
||||
Session.ID.make("ses_subagent_budget_child_2"),
|
||||
]
|
||||
yield* seedSessions(database, [parent], { time_suspended: Date.now(), resume_attempts: resumeAttempts })
|
||||
yield* seedSessions(database, children, { parent_id: parent })
|
||||
const complete = yield* Deferred.make<string>()
|
||||
for (const child of children) {
|
||||
yield* jobs.start({
|
||||
id: child,
|
||||
type: "subagent",
|
||||
recovery: {
|
||||
kind: "subagent",
|
||||
parentSessionID: parent,
|
||||
childSessionID: child,
|
||||
agent: "explore",
|
||||
description: "Completed inspection",
|
||||
},
|
||||
run: Deferred.await(complete),
|
||||
})
|
||||
yield* jobs.background(child)
|
||||
}
|
||||
yield* Deferred.succeed(complete, "Recovered result")
|
||||
yield* Effect.forEach(children, (id) => jobs.wait({ id }), { discard: true })
|
||||
|
||||
const draining = yield* Deferred.make<number | undefined>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const drained: Session.ID[] = []
|
||||
const continued: Session.ID[] = []
|
||||
yield* bus.project(SessionEvent.Synthetic, (event) =>
|
||||
Effect.sync(() => void continued.push(event.data.sessionID)),
|
||||
)
|
||||
const scope = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
|
||||
const restarted = yield* Job.make.pipe(Scope.provide(scope))
|
||||
const context = yield* buildExecution(
|
||||
scope,
|
||||
({ sessionID }) =>
|
||||
Effect.gen(function* () {
|
||||
drained.push(sessionID)
|
||||
yield* Deferred.succeed(draining, yield* attempts(database, sessionID))
|
||||
yield* Deferred.await(release)
|
||||
}),
|
||||
{ maxAttempts: 2 },
|
||||
restarted,
|
||||
)
|
||||
const restart = Context.get(context, SessionRestart.Service)
|
||||
const execution = Context.get(context, SessionExecution.Service)
|
||||
yield* restart.resumeSuspendedSessions
|
||||
|
||||
if (resumeAttempts < 2) {
|
||||
expect(yield* Deferred.await(draining)).toBe(2)
|
||||
expect(drained).toEqual([parent])
|
||||
expect(continued).toEqual([parent])
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* execution.awaitIdle(parent)
|
||||
}
|
||||
if (resumeAttempts === 2) {
|
||||
expect(drained).toEqual([])
|
||||
expect(continued).toEqual([])
|
||||
}
|
||||
expect((yield* claims(database))[parent]).toBe(false)
|
||||
expect(yield* attempts(database, parent)).toBe(0)
|
||||
expect(yield* SessionInbox.list(database.db, parent)).toHaveLength(2)
|
||||
expect(yield* restarted.pendingBackground).toEqual([])
|
||||
yield* restart.resumeSuspendedSessions
|
||||
expect(drained).toHaveLength(resumeAttempts < 2 ? 1 : 0)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.effect("terminalizes a recovered subagent that exhausts its resume budget", () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const jobs = yield* Job.Service
|
||||
const parent = Session.ID.make("ses_subagent_exhausted_parent")
|
||||
const child = Session.ID.make("ses_subagent_exhausted_child")
|
||||
yield* seedSessions(database, [parent])
|
||||
yield* seedSessions(database, [child], { parent_id: parent, time_suspended: Date.now(), resume_attempts: 2 })
|
||||
yield* jobs.start({
|
||||
id: child,
|
||||
type: "subagent",
|
||||
recovery: {
|
||||
kind: "subagent",
|
||||
parentSessionID: parent,
|
||||
childSessionID: child,
|
||||
agent: "explore",
|
||||
description: "Exhausted inspection",
|
||||
},
|
||||
run: Effect.never,
|
||||
})
|
||||
yield* jobs.background(child)
|
||||
|
||||
const parentWoken = yield* Deferred.make<void>()
|
||||
const drained: Session.ID[] = []
|
||||
const scope = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
|
||||
const restarted = yield* Job.make.pipe(Effect.provideService(Scope.Scope, scope))
|
||||
const context = yield* buildExecution(
|
||||
scope,
|
||||
({ sessionID }) =>
|
||||
Effect.sync(() => void drained.push(sessionID)).pipe(
|
||||
Effect.andThen(Deferred.succeed(parentWoken, undefined)),
|
||||
),
|
||||
{ maxAttempts: 2 },
|
||||
restarted,
|
||||
)
|
||||
yield* Context.get(context, SessionRestart.Service).resumeSuspendedSessions
|
||||
yield* Deferred.await(parentWoken)
|
||||
|
||||
expect(drained).toEqual([parent])
|
||||
expect((yield* claims(database))[child]).toBe(false)
|
||||
expect(yield* SessionInbox.list(database.db, parent)).toMatchObject([
|
||||
{
|
||||
payload: {
|
||||
text: expect.stringContaining("will not be resumed automatically"),
|
||||
metadata: { source: "subagent", childID: child, state: "error" },
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(yield* restarted.pendingBackground).toEqual([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("SessionExecution interrupt continuation", () => {
|
||||
it.effect("resumes only steering input after an interrupt with continue", () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -1021,27 +446,6 @@ describe("SessionExecution interrupt continuation", () => {
|
||||
)
|
||||
})
|
||||
|
||||
function seedBackground(
|
||||
jobs: Job.Interface,
|
||||
sessionID: Session.ID,
|
||||
background: ReadonlyArray<{ readonly id: string; readonly shellID: string; readonly command: string }>,
|
||||
) {
|
||||
return Effect.forEach(
|
||||
background,
|
||||
(job) =>
|
||||
Effect.gen(function* () {
|
||||
yield* jobs.start({
|
||||
id: job.id,
|
||||
type: "shell",
|
||||
recovery: { kind: "shell", sessionID, shellID: job.shellID, command: job.command },
|
||||
run: Effect.never,
|
||||
})
|
||||
yield* jobs.background(job.id)
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
}
|
||||
|
||||
/** Plain deliveries seed user prompts; objects seed control items. */
|
||||
function seedInbox(
|
||||
database: Database.Service["Service"],
|
||||
@@ -1127,27 +531,11 @@ function buildExecution(
|
||||
scope: Scope.Closeable,
|
||||
drain: (input: Parameters<SessionRunner.Interface["drain"]>[0]) => Effect.Effect<void, SessionRunner.RunError>,
|
||||
options?: SessionRestart.Options,
|
||||
overrideJobs?: Job.Interface,
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const jobs = overrideJobs ?? (yield* Job.Service)
|
||||
const sessions = yield* Session.Service
|
||||
const sessionLayer = Layer.effect(
|
||||
Session.Service,
|
||||
Effect.gen(function* () {
|
||||
const execution = yield* SessionExecution.Service
|
||||
return Session.Service.of({
|
||||
...sessions,
|
||||
synthetic: (input) =>
|
||||
sessions
|
||||
.synthetic({ ...input, resume: false })
|
||||
.pipe(Effect.tap(() => (input.resume === false ? Effect.void : execution.wake(input.sessionID)))),
|
||||
})
|
||||
}),
|
||||
)
|
||||
const runner = Layer.succeed(
|
||||
SessionRunner.Service,
|
||||
SessionRunner.Service.of({
|
||||
@@ -1165,12 +553,10 @@ function buildExecution(
|
||||
)
|
||||
return yield* Layer.buildWithScope(
|
||||
SessionRestart.layer(options).pipe(
|
||||
Layer.provideMerge(sessionLayer),
|
||||
Layer.provideMerge(Layer.fresh(SessionExecution.layer)),
|
||||
Layer.provideMerge(SessionExecution.layer),
|
||||
Layer.provide(Layer.succeed(Database.Service, database)),
|
||||
Layer.provide(Layer.succeed(Bus.Service, bus)),
|
||||
Layer.provide(Layer.succeed(SessionStore.Service, store)),
|
||||
Layer.provide(Layer.succeed(Job.Service, jobs)),
|
||||
Layer.provide(locations),
|
||||
),
|
||||
scope,
|
||||
|
||||
@@ -128,35 +128,6 @@ test("interrupted progress metadata remains in the terminal failure snapshot", a
|
||||
})
|
||||
})
|
||||
|
||||
test("interrupted subagent failures expose their existing child session to the model", async () => {
|
||||
const { published, publisher } = capture("anthropic", { interruptProgress: true })
|
||||
const subagent = LLMEvent.toolCall({
|
||||
id: "call-subagent",
|
||||
name: "subagent",
|
||||
input: { agent: "general", description: "Recover child", prompt: "Continue working" },
|
||||
})
|
||||
await Effect.runPromise(publisher.publish(subagent))
|
||||
await Effect.runPromiseExit(publisher.progress(subagent.id, { sessionID: "ses_existing_child", status: "running" }))
|
||||
await Effect.runPromise(publisher.failUnsettledTools({ type: "aborted", message: "Tool execution interrupted" }))
|
||||
|
||||
expect(published.find((event) => event.type === "session.tool.failed.2")?.data).toMatchObject({
|
||||
error: { type: "aborted", message: "Tool execution interrupted (sessionID: ses_existing_child)" },
|
||||
metadata: { sessionID: "ses_existing_child", status: "running" },
|
||||
})
|
||||
})
|
||||
|
||||
test("interrupted non-subagent failures do not expose their progress session IDs", async () => {
|
||||
const { published, publisher } = capture()
|
||||
await Effect.runPromise(publisher.publish(call))
|
||||
await Effect.runPromise(publisher.progress(call.id, { sessionID: "ses_private", status: "running" }))
|
||||
await Effect.runPromise(publisher.failUnsettledTools({ type: "aborted", message: "Tool execution interrupted" }))
|
||||
|
||||
expect(published.find((event) => event.type === "session.tool.failed.2")?.data).toMatchObject({
|
||||
error: { type: "aborted", message: "Tool execution interrupted" },
|
||||
metadata: { sessionID: "ses_private", status: "running" },
|
||||
})
|
||||
})
|
||||
|
||||
test("local failure metadata completes the progress snapshot", async () => {
|
||||
const { published, publisher } = capture()
|
||||
await Effect.runPromise(publisher.publish(call))
|
||||
|
||||
@@ -7,11 +7,10 @@ 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 { State } from "@opencode-ai/core/state"
|
||||
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, Logger, Schema, SchemaGetter, SchemaIssue, Scope } from "effect"
|
||||
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Schema, SchemaGetter, SchemaIssue, Scope } from "effect"
|
||||
import { z } from "zod"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
@@ -72,181 +71,30 @@ const transform = (service: Tool.Interface, tools: Readonly<Record<string, Info>
|
||||
)
|
||||
|
||||
describe("Tool", () => {
|
||||
it.live("replays updates and removals on reload and restores definitions on disposal", () =>
|
||||
it.effect("rejects invalid dotted namespaces", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
let text = "original"
|
||||
const source = yield* service.transform((draft) =>
|
||||
draft.add({
|
||||
...constant(text),
|
||||
name: "echo",
|
||||
description: text,
|
||||
options: { namespace: "acme", codemode: false },
|
||||
}),
|
||||
)
|
||||
yield* transform(service, { echo: make() }, { namespace: "other", codemode: false })
|
||||
const before = yield* service.snapshot()
|
||||
const update = yield* service.transform((draft) => {
|
||||
draft.update("missing", () => {
|
||||
throw new Error("must not create a tool")
|
||||
})
|
||||
draft.update("acme_echo", (tool) => {
|
||||
tool.description += " updated"
|
||||
const execute = tool.execute
|
||||
tool.execute = (input, context) =>
|
||||
execute(input, context).pipe(
|
||||
Effect.map((result) => ({ ...result, output: { text: `${result.output.text} updated` } })),
|
||||
)
|
||||
})
|
||||
})
|
||||
const removal = yield* service.transform((draft) => {
|
||||
draft.remove("missing")
|
||||
draft.remove("other_echo")
|
||||
})
|
||||
const updated = yield* service.snapshot()
|
||||
expect(updated.definitions.map((tool) => tool.name)).toEqual(["acme_echo", "execute"])
|
||||
expect(updated.definitions[0]?.description).toBe("original updated")
|
||||
expect((yield* updated.execute(call("acme_echo"))).output).toEqual({ text: "original updated" })
|
||||
text = "refreshed"
|
||||
yield* service.reload()
|
||||
const reloaded = yield* service.snapshot()
|
||||
expect(reloaded.definitions.map((tool) => tool.name)).toEqual(["acme_echo", "execute"])
|
||||
expect(reloaded.definitions[0]?.description).toBe("refreshed updated")
|
||||
expect((yield* reloaded.execute(call("acme_echo"))).output).toEqual({ text: "refreshed updated" })
|
||||
expect((yield* before.execute(call("acme_echo"))).output).toEqual({ text: "original" })
|
||||
yield* removal.dispose
|
||||
yield* removal.dispose
|
||||
yield* update.dispose
|
||||
const restored = yield* service.snapshot()
|
||||
expect(restored.definitions.map((tool) => tool.name)).toEqual(["acme_echo", "other_echo", "execute"])
|
||||
expect((yield* restored.execute(call("acme_echo"))).output).toEqual({ text: "refreshed" })
|
||||
yield* source.dispose
|
||||
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["other_echo", "execute"])
|
||||
}),
|
||||
)
|
||||
const error = yield* transform(service, { echo: make() }, { namespace: "slack..admin" }).pipe(Effect.flip)
|
||||
|
||||
it.effect("does not retain an updated tool after its source scope closes", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
const scope = yield* Scope.make()
|
||||
yield* transform(service, { echo: make() }, { codemode: false }).pipe(Scope.provide(scope))
|
||||
yield* service.transform((draft) =>
|
||||
draft.update("echo", (tool) => {
|
||||
tool.description = "Updated"
|
||||
}),
|
||||
)
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(error).toBeInstanceOf(Tool.RegistrationError)
|
||||
expect(error.message).toBe('Invalid tool namespace: "slack..admin"')
|
||||
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("batches tool transforms with the shared state lifecycle", () =>
|
||||
it.effect("rejects invalid and colliding normalized names", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
let runs = 0
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* service.transform((draft) => {
|
||||
runs++
|
||||
draft.add({ ...make(), options: { codemode: false } })
|
||||
})
|
||||
yield* service.transform((draft) =>
|
||||
draft.update("echo", (tool) => {
|
||||
tool.description = "Batched"
|
||||
}),
|
||||
)
|
||||
expect(runs).toBe(0)
|
||||
}),
|
||||
for (const name of ["", "x".repeat(65)]) {
|
||||
const invalid = yield* transform(service, { [name]: make() }, { codemode: false }).pipe(Effect.flip)
|
||||
expect(invalid.message).toBe(`Invalid tool name: ${name}`)
|
||||
}
|
||||
|
||||
const collision = yield* transform(service, { "echo.tool": make(), echo_tool: make() }, { codemode: false }).pipe(
|
||||
Effect.flip,
|
||||
)
|
||||
expect(runs).toBe(1)
|
||||
expect((yield* service.snapshot()).definitions[0]?.description).toBe("Batched")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("skips invalid updates without dropping the existing definition", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
yield* transform(service, { echo: make() }, { codemode: false })
|
||||
yield* service.transform((draft) =>
|
||||
draft.update("echo", (tool) => {
|
||||
Object.assign(tool, { description: undefined })
|
||||
}),
|
||||
)
|
||||
expect((yield* service.snapshot()).definitions[0]?.description).toBe("Echo text")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("updates newly added tools and applies removals in order", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
yield* service.transform((draft) => {
|
||||
draft.add({ ...make(), options: { codemode: false } })
|
||||
draft.update("echo", (tool) => {
|
||||
tool.description = "Updated"
|
||||
tool.input = Schema.Struct({ value: Schema.Number })
|
||||
tool.output = Schema.Number
|
||||
tool.execute = ({ value }) => Effect.succeed({ output: value * 2 })
|
||||
})
|
||||
draft.add({ ...make(), name: "removed" })
|
||||
draft.remove("removed")
|
||||
draft.add({ ...make(), name: "removed" })
|
||||
draft.remove("removed")
|
||||
})
|
||||
const snapshot = yield* service.snapshot()
|
||||
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["echo", "execute"])
|
||||
expect(snapshot.definitions[0]?.description).toBe("Updated")
|
||||
expect(
|
||||
(yield* snapshot.execute({
|
||||
...call("echo"),
|
||||
call: { type: "tool-call", id: "updated", name: "echo", input: { value: 3 } },
|
||||
})).output,
|
||||
).toBe(6)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("logs and skips invalid dotted namespaces", () => {
|
||||
const output: unknown[] = []
|
||||
const logger = Logger.map(Logger.formatStructured, (entry) => {
|
||||
output.push(entry.message)
|
||||
})
|
||||
return Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
yield* transform(service, { echo: make() }, { namespace: "slack..admin" })
|
||||
|
||||
expect(output).toEqual([
|
||||
[
|
||||
"Skipping invalid tool registration",
|
||||
{ name: "echo", namespace: "slack..admin", error: 'Invalid tool namespace: "slack..admin"' },
|
||||
],
|
||||
])
|
||||
const snapshot = yield* service.snapshot()
|
||||
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["execute"])
|
||||
expect(snapshot.codeModeCatalog).toEqual([])
|
||||
}).pipe(Effect.provide(Logger.layer([logger])))
|
||||
})
|
||||
|
||||
it.effect("skips invalid, reserved, and colliding names without dropping healthy tools", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
yield* transform(
|
||||
service,
|
||||
{
|
||||
before: make(),
|
||||
"": make(),
|
||||
["x".repeat(65)]: make(),
|
||||
"echo.tool": make(),
|
||||
echo_tool: make(),
|
||||
execute: make(),
|
||||
after: make(),
|
||||
},
|
||||
{ codemode: false },
|
||||
)
|
||||
const snapshot = yield* service.snapshot()
|
||||
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["after", "before", "execute"])
|
||||
expect((yield* snapshot.execute(call("before"))).output).toEqual({ text: "before" })
|
||||
expect((yield* snapshot.execute(call("after"))).output).toEqual({ text: "after" })
|
||||
expect((yield* snapshot.execute(call("echo_tool")).pipe(Effect.flip)).message).toBe("Unknown tool: echo_tool")
|
||||
expect(snapshot.codeModeCatalog).toEqual([])
|
||||
expect(collision.message).toBe("Duplicate normalized tool name: echo_tool")
|
||||
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -311,72 +159,40 @@ describe("Tool", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps healthy tools when another namespace is invalid", () =>
|
||||
it.effect("validates a registration batch before installing any tools", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
yield* service.transform((draft) => {
|
||||
draft.add({ ...make(), name: "first", options: { codemode: false } })
|
||||
draft.add({ ...make(), name: "second", options: { namespace: "invalid..namespace", codemode: false } })
|
||||
draft.add({ ...make(), name: "second", options: { namespace: "invalid__namespace" } })
|
||||
})
|
||||
const error = yield* service
|
||||
.transform((draft) => {
|
||||
draft.add({ ...make(), name: "first", options: { codemode: false } })
|
||||
draft.add({ ...make(), name: "second", options: { namespace: "invalid..namespace", codemode: false } })
|
||||
})
|
||||
.pipe(Effect.flip)
|
||||
|
||||
const snapshot = yield* service.snapshot()
|
||||
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["first", "execute"])
|
||||
expect(snapshot.codeModeCatalog?.map((tool) => tool.path)).toEqual(["invalid__namespace.second"])
|
||||
expect(error).toBeInstanceOf(Tool.RegistrationError)
|
||||
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("logs invalid tool definitions without dropping healthy tools", () => {
|
||||
const output: unknown[] = []
|
||||
const logger = Logger.map(Logger.formatStructured, (entry) => {
|
||||
output.push(entry.message)
|
||||
})
|
||||
return Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
yield* service.transform((draft) => {
|
||||
draft.add({ ...make(), name: "healthy", options: { codemode: false } })
|
||||
draft.add({
|
||||
name: "phone_type",
|
||||
input: Schema.Struct({}),
|
||||
execute: () => Effect.succeed({ content: "ok" }),
|
||||
options: { codemode: false },
|
||||
} as unknown as Info)
|
||||
draft.add({ ...make(), name: "codemode" })
|
||||
})
|
||||
|
||||
expect(output).toEqual([
|
||||
[
|
||||
"Skipping invalid tool registration",
|
||||
{
|
||||
name: "phone_type",
|
||||
namespace: undefined,
|
||||
error: expect.stringContaining('Expected string\n at ["description"]'),
|
||||
},
|
||||
],
|
||||
])
|
||||
const snapshot = yield* service.snapshot()
|
||||
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["healthy", "execute"])
|
||||
expect(snapshot.codeModeCatalog?.map((tool) => tool.path)).toEqual(["codemode"])
|
||||
expect((yield* snapshot.execute(call("phone_type")).pipe(Effect.flip)).message).toBe("Unknown tool: phone_type")
|
||||
}).pipe(Effect.provide(Logger.layer([logger])))
|
||||
})
|
||||
|
||||
it.effect("skipped registrations leave existing tools and scoped cleanup intact", () =>
|
||||
it.effect("rejects invalid tool definitions before installing any tools", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
yield* transform(service, { echo: constant("original") }, { codemode: false })
|
||||
yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* service.transform((draft) => {
|
||||
draft.add({ ...constant("invalid"), name: "echo", description: undefined } as unknown as Info)
|
||||
draft.add({ ...make(), name: "temporary", options: { codemode: false } })
|
||||
})
|
||||
const snapshot = yield* service.snapshot()
|
||||
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["echo", "temporary", "execute"])
|
||||
expect((yield* snapshot.execute(call("echo"))).output).toEqual({ text: "original" })
|
||||
}),
|
||||
)
|
||||
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["echo", "execute"])
|
||||
const error = yield* service
|
||||
.transform((draft) => {
|
||||
draft.add({ ...make(), name: "healthy", options: { codemode: false } })
|
||||
draft.add({
|
||||
name: "phone_type",
|
||||
input: Schema.Struct({}),
|
||||
execute: () => Effect.succeed({ content: "ok" }),
|
||||
options: { codemode: false },
|
||||
} as unknown as Info)
|
||||
})
|
||||
.pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(Tool.RegistrationError)
|
||||
expect(error.name).toBe("phone_type")
|
||||
expect(error.message).toContain('Expected string\n at ["description"]')
|
||||
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ import { Provider } from "@opencode-ai/core/provider"
|
||||
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Queue, Schema, Scope, Stream } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { asc, desc, eq, sql } from "drizzle-orm"
|
||||
import { asc, desc, eq } from "drizzle-orm"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
import { agentHost, catalogHost, host } from "./plugin/host"
|
||||
@@ -1444,49 +1444,6 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("delivers controls without preflighting unavailable initial instructions", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
const runner = yield* SessionRunner.Service
|
||||
systemUnavailable = true
|
||||
let reads = 0
|
||||
systemLoadHook = Effect.sync(() => {
|
||||
reads++
|
||||
})
|
||||
const compaction = yield* SessionInbox.admitCompaction(database.db, bus, {
|
||||
id: SessionMessage.ID.create(),
|
||||
sessionID,
|
||||
delivery: "queue",
|
||||
})
|
||||
yield* SessionInbox.admit(database.db, bus, {
|
||||
id: SessionMessage.ID.create(),
|
||||
sessionID,
|
||||
item: {
|
||||
type: "move",
|
||||
payload: {
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }),
|
||||
projectID: Project.ID.global,
|
||||
},
|
||||
delivery: "queue",
|
||||
},
|
||||
})
|
||||
|
||||
expect(yield* runner.drain({ sessionID, force: false })).toEqual(SessionRunner.DrainResult.Moved({}))
|
||||
|
||||
expect(reads).toBe(0)
|
||||
expect(requests).toHaveLength(0)
|
||||
expect(yield* session.inbox(sessionID)).toEqual([])
|
||||
expect((yield* session.get(sessionID)).location.directory).toBe(AbsolutePath.make("/moved"))
|
||||
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
|
||||
type: "compaction",
|
||||
status: "failed",
|
||||
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("delivers a queued move atomically at the idle boundary", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
@@ -1597,56 +1554,6 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("runs a queued control on Location entry before a carried continuation", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
const runner = yield* SessionRunner.Service
|
||||
yield* admit(session, "Echo before moving")
|
||||
yield* TestLLM.push(
|
||||
TestLLM.tool("call-entry", "echo", { text: "moving" }),
|
||||
TestLLM.text("Entry summary", "entry-summary"),
|
||||
TestLLM.text("Continued", "entry-continuation"),
|
||||
)
|
||||
const stream = yield* TestLLM.gate
|
||||
const run = yield* runner.drain({ sessionID, force: false }).pipe(Effect.forkChild)
|
||||
yield* stream.started
|
||||
const compaction = yield* SessionInbox.admitCompaction(database.db, bus, {
|
||||
id: SessionMessage.ID.create(),
|
||||
sessionID,
|
||||
delivery: "queue",
|
||||
})
|
||||
yield* SessionInbox.admit(database.db, bus, {
|
||||
id: SessionMessage.ID.create(),
|
||||
sessionID,
|
||||
item: {
|
||||
type: "move",
|
||||
payload: {
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/project") }),
|
||||
projectID: Project.ID.global,
|
||||
},
|
||||
delivery: "steer",
|
||||
},
|
||||
})
|
||||
yield* stream.release
|
||||
const moved = yield* Fiber.join(run)
|
||||
|
||||
expect(moved).toEqual(SessionRunner.DrainResult.Moved({ continuation: { step: 2 } }))
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(yield* SessionInbox.find(database.db, compaction.id)).toMatchObject({ id: compaction.id })
|
||||
if (moved._tag !== "Moved") throw new Error("Expected a Location handoff")
|
||||
|
||||
// Location entry considers queued controls even when model work carries across the move.
|
||||
yield* runner.drain({ sessionID, force: false, continuation: moved.continuation })
|
||||
|
||||
expect(requests).toHaveLength(3)
|
||||
expect(userTexts(requests[1])[0]).toContain("Create a new anchored summary")
|
||||
expect(userTexts(requests[2])[0]).toContain("<summary>\nEntry summary\n</summary>")
|
||||
expect(yield* session.inbox(sessionID)).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("seeds a fork with the parent's newest instruction values", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
@@ -2655,74 +2562,6 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("refreshes preparation after overflow compaction without promoting new input", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setupOverflowRecovery
|
||||
const bus = yield* Bus.Service
|
||||
let reads = 0
|
||||
let resolutions = 0
|
||||
systemLoadHook = Effect.sync(() => {
|
||||
reads++
|
||||
})
|
||||
modelResolveHook = Effect.sync(() => {
|
||||
resolutions++
|
||||
})
|
||||
yield* admit(session, "Continue")
|
||||
yield* TestLLM.push(
|
||||
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
|
||||
TestLLM.text("Overflow summary", "overflow-summary"),
|
||||
TestLLM.text("Recovered", "overflow-recovered"),
|
||||
TestLLM.stop(),
|
||||
TestLLM.stop(),
|
||||
)
|
||||
const first = yield* TestLLM.gate
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* first.started
|
||||
expect(reads).toBe(1)
|
||||
expect(resolutions).toBe(1)
|
||||
expect(requests[0]?.model).toBe(recoveryModel)
|
||||
|
||||
const summary = yield* TestLLM.gate
|
||||
yield* first.release
|
||||
yield* summary.started
|
||||
systemBaseline = "Changed during compaction"
|
||||
yield* bus.publish(SessionEvent.ModelSelected, {
|
||||
sessionID,
|
||||
model: { id: ID.make("replacement"), providerID: Provider.ID.make("fake") },
|
||||
})
|
||||
const queued = yield* session.prompt({
|
||||
sessionID,
|
||||
text: "Queued during compaction",
|
||||
delivery: "queue",
|
||||
resume: false,
|
||||
})
|
||||
const steered = yield* admit(session, "Steered during compaction")
|
||||
const retry = yield* TestLLM.gate
|
||||
yield* summary.release
|
||||
yield* retry.started
|
||||
|
||||
expect(reads).toBe(2)
|
||||
expect(resolutions).toBe(2)
|
||||
expect(requests).toHaveLength(3)
|
||||
expect(requests[2]?.model).toBe(replacementModel)
|
||||
expect(requests[2]?.system.map((part) => part.text)).toEqual([defaultSystem, "Initial context"])
|
||||
expect(systemTexts(requests[2])).toContain("Changed during compaction")
|
||||
expect(userTexts(requests[2])[0]).toContain("<summary>\nOverflow summary\n</summary>")
|
||||
expect(userTexts(requests[2]).join("\n")).not.toContain("Queued during compaction")
|
||||
expect(userTexts(requests[2]).join("\n")).not.toContain("Steered during compaction")
|
||||
expect((yield* session.inbox(sessionID)).map((item) => item.id)).toEqual([queued.id, steered.id])
|
||||
|
||||
yield* retry.release
|
||||
yield* Fiber.join(run)
|
||||
|
||||
expect(requests).toHaveLength(5)
|
||||
expect(userTexts(requests[3])).toContain("Steered during compaction")
|
||||
expect(userTexts(requests[3])).not.toContain("Queued during compaction")
|
||||
expect(userTexts(requests[4])).toContain("Queued during compaction")
|
||||
expect(yield* session.inbox(sessionID)).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not recover provider context overflow when automatic compaction is disabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setupOverflowRecovery
|
||||
@@ -3481,71 +3320,6 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps queued input parked when a steer is cancelled during preparation", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const runner = yield* SessionRunner.Service
|
||||
yield* admit(session, "A")
|
||||
yield* TestLLM.push(TestLLM.stop(), TestLLM.stop(), TestLLM.stop())
|
||||
const stream = yield* TestLLM.gate
|
||||
const run = yield* runner.drain({ sessionID, force: false }).pipe(Effect.forkChild)
|
||||
yield* stream.started
|
||||
|
||||
yield* session.prompt({ sessionID, text: "B", delivery: "queue", resume: false })
|
||||
const steer = yield* admit(session, "S")
|
||||
systemLoadHook = Effect.gen(function* () {
|
||||
systemLoadHook = Effect.void
|
||||
yield* session.cancelInbox({ sessionID, inboxID: steer.id }).pipe(Effect.orDie)
|
||||
})
|
||||
yield* stream.release
|
||||
yield* Fiber.join(run)
|
||||
|
||||
expect(requests.map(userTexts)).toEqual([["A"], ["A"], ["A", "B"]])
|
||||
expect((yield* session.messages({ sessionID })).some((message) => message.id === steer.id)).toBe(false)
|
||||
expect(yield* session.inbox(sessionID)).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("dispatches a queued move when a steer is cancelled during preparation", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const runner = yield* SessionRunner.Service
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make("/moved") })
|
||||
yield* admit(session, "A")
|
||||
yield* TestLLM.push(TestLLM.stop(), TestLLM.stop())
|
||||
const stream = yield* TestLLM.gate
|
||||
const run = yield* runner.drain({ sessionID, force: false }).pipe(Effect.forkChild)
|
||||
yield* stream.started
|
||||
|
||||
yield* SessionInbox.admit(database.db, bus, {
|
||||
id: SessionMessage.ID.create(),
|
||||
sessionID,
|
||||
item: {
|
||||
type: "move",
|
||||
payload: { location, projectID: Project.ID.global },
|
||||
delivery: "queue",
|
||||
},
|
||||
})
|
||||
const steer = yield* admit(session, "S")
|
||||
systemLoadHook = Effect.gen(function* () {
|
||||
systemLoadHook = Effect.void
|
||||
yield* session.cancelInbox({ sessionID, inboxID: steer.id }).pipe(Effect.orDie)
|
||||
})
|
||||
yield* stream.release
|
||||
|
||||
expect({ result: yield* Fiber.join(run), location: (yield* session.get(sessionID)).location }).toEqual({
|
||||
result: SessionRunner.DrainResult.Moved({}),
|
||||
location,
|
||||
})
|
||||
expect(requests.map(userTexts)).toEqual([["A"], ["A"]])
|
||||
expect(closedTransports).toEqual([sessionID])
|
||||
expect(yield* recordedEventTypes(sessionID)).toContain(Bus.versionedType(SessionEvent.Moved.type, 1))
|
||||
expect(yield* session.inbox(sessionID)).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves durable queued input for a later wake after interruption", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
@@ -3866,81 +3640,6 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves a stale subagent child session in its model-visible failure", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const bus = yield* Bus.Service
|
||||
const database = yield* Database.Service
|
||||
yield* admit(session, "Recover interrupted subagent")
|
||||
yield* SessionInbox.promote(database.db, bus, sessionID, "steer")
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
yield* bus.publish(SessionEvent.Step.Started, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
agent: Agent.ID.make("build"),
|
||||
model: { id: ID.make("fake-model"), providerID: Provider.ID.make("fake") },
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Tool.Input.Started, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
id: "call-interrupted-subagent",
|
||||
name: "subagent",
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Tool.Input.Ended, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
id: "call-interrupted-subagent",
|
||||
text: '{"agent":"general"}',
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Tool.Called, {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
id: "call-interrupted-subagent",
|
||||
input: { agent: "general" },
|
||||
executed: false,
|
||||
})
|
||||
yield* database.db
|
||||
.update(SessionMessageTable)
|
||||
.set({
|
||||
data: sql`json_set(
|
||||
${SessionMessageTable.data},
|
||||
'$.content[0].state.metadata',
|
||||
json('{"sessionID":"ses_existing_child","status":"running","internal":"private"}')
|
||||
)`,
|
||||
})
|
||||
.where(eq(SessionMessageTable.id, assistantMessageID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
requests.length = 0
|
||||
yield* TestLLM.push([])
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user", text: "Recover interrupted subagent" },
|
||||
{
|
||||
type: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call-interrupted-subagent",
|
||||
state: {
|
||||
status: "error",
|
||||
error: {
|
||||
type: "aborted",
|
||||
message: "Tool execution interrupted: subagent (sessionID: ses_existing_child)",
|
||||
},
|
||||
metadata: { sessionID: "ses_existing_child", status: "running", internal: "private" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
const modelResult = JSON.stringify(requests[0]?.messages.at(-1))
|
||||
expect(modelResult).toContain("ses_existing_child")
|
||||
expect(modelResult).not.toContain("private")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("durably fails hosted tools left running by a prior process before continuing inline", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
|
||||
@@ -1,234 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { ShellParse } from "../src/shell/parse.js"
|
||||
|
||||
describe("portable shell parser compatibility", () => {
|
||||
test.each([
|
||||
["bash", "echo $((1+1))", {}],
|
||||
["bash", "echo $((1 + $(printf hidden)))", {}],
|
||||
["bash", "cd ~/project", {}],
|
||||
["bash", "cd src&&cd..", {}],
|
||||
["bash", "cd src && cd .. && git status", {}],
|
||||
["zsh", "git status", {}],
|
||||
["fish", "git status", {}],
|
||||
["bash", "git status", { BASH_ENV: "/startup" }],
|
||||
["bash", "git status", { "BASH_FUNC_cd%%": "() { :; }" }],
|
||||
["bash", "cd $HOME; pwd", { HOME: "/session-home" }],
|
||||
["bash", "cd; pwd", { HOME: "/session-home" }],
|
||||
["bash", 'target=/outside; cd "$target"; pwd', {}],
|
||||
["bash", "cd 'src dir' && git status", {}],
|
||||
["bash", 'cd "src dir"; cd escaped\\ space', {}],
|
||||
["bash", "cd '$HOME'; cd '~/outside'", {}],
|
||||
["bash", 'g""it status', {}],
|
||||
["bash", 'npm "run" test', {}],
|
||||
["bash", "git '*'", {}],
|
||||
["bash", "FOO=bar git status", {}],
|
||||
["bash", "HOME=/outside; cd; pwd", {}],
|
||||
["bash", "CDPATH=/outside cd child", {}],
|
||||
["bash", "cd child; pwd", { CDPATH: "/outside" }],
|
||||
["bash", "cd /workspace > output", {}],
|
||||
["bash", "export X=value; unset X; git status", {}],
|
||||
["bash", "printf ok && git status > output", {}],
|
||||
["bash", "printf ok | cat < input > output", {}],
|
||||
["bash", "cd -- -/../../../etc; pwd", {}],
|
||||
["bash", "cd -; pushd; popd; pwd", {}],
|
||||
["bash", "command cd /outside; builtin cd /elsewhere", {}],
|
||||
["pwsh", "Get-ChildItem | ForEach-Object { Write-Output $_ }", {}],
|
||||
["pwsh", "ForEach-Object { Remove-Item victim }", {}],
|
||||
["pwsh", "Set-Location -LiteralPath '../outside'; Get-ChildItem", {}],
|
||||
["pwsh", "Set-Location -LiteralPath:/outside", {}],
|
||||
["pwsh", "Set-Location -LiteralPath:'/outside path'", {}],
|
||||
["pwsh", 'Set-Location -PATH:"../outside path"', {}],
|
||||
["pwsh", "Set-Location $HOME; Set-Location $PWD; Set-Location $target", { HOME: "/session-home" }],
|
||||
["pwsh", "Set-Item Env:T /outside; Set-Location $env:T", { T: "/workspace" }],
|
||||
["pwsh", "sl /outside; Microsoft.PowerShell.Management\\Set-Location /outside", {}],
|
||||
] as const)(
|
||||
"matches supported legacy resources, saved prefixes, and directories natively: %s %s %j",
|
||||
async (shell, command, env) => {
|
||||
if (Object.keys(env).length > 0) {
|
||||
const child = Bun.spawn({
|
||||
cmd: [
|
||||
process.execPath,
|
||||
"--eval",
|
||||
`
|
||||
import { Effect } from "effect"
|
||||
import { ShellParse } from "./src/shell/parse.ts"
|
||||
const command = ${JSON.stringify(command)}
|
||||
const shell = ${JSON.stringify(shell)}
|
||||
const legacy = await Effect.runPromise(ShellParse.scan(command, shell, "/workspace"))
|
||||
const portable = await Effect.runPromise(ShellParse.scan(command, shell, "/workspace", { portable: true }))
|
||||
const native = await Effect.runPromise(ShellParse.scanPortable(command, shell, "/workspace"))
|
||||
console.log(JSON.stringify([legacy, portable, native]))
|
||||
`,
|
||||
],
|
||||
cwd: `${import.meta.dir}/..`,
|
||||
env: { ...process.env, ...env },
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const [output, error, code] = await Promise.all([
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
child.exited,
|
||||
])
|
||||
expect(code, error).toBe(0)
|
||||
const [legacy, portable, native] = JSON.parse(output)
|
||||
expect(portable).toEqual(legacy)
|
||||
expect(native).toEqual(legacy)
|
||||
return
|
||||
}
|
||||
const legacy = await Effect.runPromise(ShellParse.scan(command, shell, "/workspace"))
|
||||
const portable = await Effect.runPromise(ShellParse.scan(command, shell, "/workspace", { portable: true }))
|
||||
expect(portable).toEqual(legacy)
|
||||
expect(await Effect.runPromise(ShellParse.scanPortable(command, shell, "/workspace"))).toEqual(legacy)
|
||||
},
|
||||
)
|
||||
|
||||
test("derives the legacy prefix for long argument lists", async () => {
|
||||
const command = `echo ${"x ".repeat(16_000)}`.trimEnd()
|
||||
const result = await Effect.runPromise(ShellParse.scan(command, "bash", "/workspace", { portable: true }))
|
||||
expect(result).toEqual({ commands: [{ resource: command, save: "echo *" }], directories: [] })
|
||||
})
|
||||
|
||||
test("extracts inline PowerShell directory flags with case-insensitive names and quoted values", async () => {
|
||||
const result = await Effect.runPromise(
|
||||
ShellParse.scanPortable(
|
||||
"Set-Location -LITERALPATH:C:\\outside; Set-Location -pAtH:'../other dir'",
|
||||
"pwsh",
|
||||
"/workspace",
|
||||
),
|
||||
)
|
||||
expect(result).toEqual({ commands: [], directories: ["C:\\outside", "../other dir"] })
|
||||
})
|
||||
})
|
||||
|
||||
describe("current native and legacy parity gaps", () => {
|
||||
// These are observed parser gaps, not permission-policy changes that must be preserved.
|
||||
for (const fixture of [
|
||||
{
|
||||
name: "native omits the legacy empty command-name node for an assignment with redirection",
|
||||
shell: "bash",
|
||||
command: "FOO=bar > output",
|
||||
legacy: { commands: [{ resource: "FOO=bar > output", save: " *" }], directories: [] },
|
||||
native: { commands: [], directories: [] },
|
||||
},
|
||||
{
|
||||
name: "native retains nested executable commands without the legacy empty assignment command-name node",
|
||||
shell: "bash",
|
||||
command: "FOO=$(printf value) > output",
|
||||
legacy: {
|
||||
commands: [
|
||||
{ resource: "FOO=$(printf value) > output", save: " *" },
|
||||
{ resource: "printf value", save: "printf *" },
|
||||
],
|
||||
directories: [],
|
||||
},
|
||||
native: { commands: [{ resource: "printf value", save: "printf *" }], directories: [] },
|
||||
},
|
||||
{
|
||||
name: "native keeps numeric arguments in saved prefixes",
|
||||
shell: "bash",
|
||||
command: "git 2 status",
|
||||
legacy: { commands: [{ resource: "git 2 status", save: "git status *" }], directories: [] },
|
||||
native: { commands: [{ resource: "git 2 status", save: "git 2 *" }], directories: [] },
|
||||
},
|
||||
{
|
||||
name: "native keeps numeric directory names and operator-shaped arguments",
|
||||
shell: "bash",
|
||||
command: "cd 123; git == value",
|
||||
legacy: { commands: [{ resource: "git == value", save: "git value *" }], directories: [] },
|
||||
native: { commands: [{ resource: "git == value", save: "git == *" }], directories: ["123"] },
|
||||
},
|
||||
{
|
||||
name: "native preserves substitution source in saved prefixes instead of skipping the argument",
|
||||
shell: "bash",
|
||||
command: "git $(printf status) diff",
|
||||
legacy: {
|
||||
commands: [
|
||||
{ resource: "git $(printf status) diff", save: "git diff *" },
|
||||
{ resource: "printf status", save: "printf *" },
|
||||
],
|
||||
directories: [],
|
||||
},
|
||||
native: {
|
||||
commands: [
|
||||
{ resource: "git $(printf status) diff", save: "git $(printf status) *" },
|
||||
{ resource: "printf status", save: "printf *" },
|
||||
],
|
||||
directories: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "directory line continuations remain unresolved source rather than legacy split operands",
|
||||
shell: "bash",
|
||||
command: "cd before\\\nafter",
|
||||
legacy: { commands: [], directories: ["before", "after"] },
|
||||
native: { commands: [], directories: ["before\\\nafter"] },
|
||||
},
|
||||
{
|
||||
name: "native recognizes PowerShell carriage-return separators omitted by the legacy AST",
|
||||
shell: "pwsh",
|
||||
command: "Get-ChildItem\rRemove-Item victim",
|
||||
legacy: { commands: [], directories: [] },
|
||||
native: {
|
||||
commands: [
|
||||
{ resource: "Get-ChildItem", save: "Get-ChildItem *" },
|
||||
{ resource: "Remove-Item victim", save: "Remove-Item *" },
|
||||
],
|
||||
directories: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "native recognizes tab-separated PowerShell commands omitted by the legacy AST",
|
||||
shell: "pwsh",
|
||||
command: "git\tstatus",
|
||||
legacy: { commands: [], directories: [] },
|
||||
native: { commands: [{ resource: "git\tstatus", save: "git\tstatus *" }], directories: [] },
|
||||
},
|
||||
{
|
||||
name: "native preserves complete PowerShell flag=value resources",
|
||||
shell: "pwsh",
|
||||
command: "git --flag=value",
|
||||
legacy: { commands: [{ resource: "git --flag", save: "git --flag *" }], directories: [] },
|
||||
native: { commands: [{ resource: "git --flag=value", save: "git --flag=value *" }], directories: [] },
|
||||
},
|
||||
{
|
||||
name: "native does not split comma-separated PowerShell directory operands",
|
||||
shell: "pwsh",
|
||||
command: "Set-Location a,b",
|
||||
legacy: { commands: [], directories: ["a", ",b"] },
|
||||
native: { commands: [], directories: ["a,b"] },
|
||||
},
|
||||
]) {
|
||||
test(fixture.name, async () => {
|
||||
const native = await Effect.runPromise(ShellParse.scanPortable(fixture.command, fixture.shell, "/workspace"))
|
||||
expect(native).toEqual(fixture.native)
|
||||
expect(await Effect.runPromise(ShellParse.scan(fixture.command, fixture.shell, "/workspace"))).toEqual(
|
||||
fixture.legacy,
|
||||
)
|
||||
expect(
|
||||
await Effect.runPromise(ShellParse.scan(fixture.command, fixture.shell, "/workspace", { portable: true })),
|
||||
).toEqual(native)
|
||||
expect(native).not.toEqual(fixture.legacy)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe("legacy directory command behavior", () => {
|
||||
test.each(["bash", "zsh", "pwsh"])("retains the original shared directory command set: %s", async (shell) => {
|
||||
const result = await Effect.runPromise(
|
||||
ShellParse.scan(
|
||||
"chdir /outside; set-location /elsewhere; push-location /stack; sl .; pop-location",
|
||||
shell,
|
||||
"/workspace",
|
||||
),
|
||||
)
|
||||
expect(result).toEqual({
|
||||
commands: [
|
||||
{ resource: "sl .", save: "sl *" },
|
||||
{ resource: "pop-location", save: "pop-location *" },
|
||||
],
|
||||
directories: ["/outside", "/elsewhere", "/stack"],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3,104 +3,30 @@ import { Effect } from "effect"
|
||||
import { ShellParse } from "../src/shell/parse.js"
|
||||
import { ShellScan } from "../src/shell/scan.js"
|
||||
|
||||
describe("ShellParse native parity", () => {
|
||||
test("matches the legacy oracle across generated supported syntax without fallback", async () => {
|
||||
const commands = generated()
|
||||
expect(commands.length).toBeGreaterThan(20_000)
|
||||
for (const [shell, command] of commands) {
|
||||
const context = `${shell}: ${JSON.stringify(command)}`
|
||||
describe("ShellParse portable parity", () => {
|
||||
test("matches tree-sitter for generated supported syntax", async () => {
|
||||
for (const [shell, command] of generated()) {
|
||||
const scanned = shell === "pwsh" ? ShellScan.scanPowerShell(command) : ShellScan.scan(command)
|
||||
expect(scanned.kind, context).toBe("scanned")
|
||||
const native = await Effect.runPromise(ShellParse.scanPortable(command, shell, "/workspace"))
|
||||
const portable = await Effect.runPromise(ShellParse.scan(command, shell, "/workspace", { portable: true }))
|
||||
|
||||
if (scanned.kind === "opaque") {
|
||||
expect({ command, portable }).toEqual({
|
||||
command,
|
||||
portable: { commands: [{ resource: command, save: command }], directories: [] },
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (shell === "pwsh" && /\r(?!\n)/.test(command)) {
|
||||
expect(portable).toEqual({ commands: [], directories: [] })
|
||||
continue
|
||||
}
|
||||
|
||||
const legacy = await Effect.runPromise(ShellParse.scan(command, shell, "/workspace"))
|
||||
expect(native, context).toEqual(legacy)
|
||||
expect(
|
||||
await Effect.runPromise(ShellParse.scan(command, shell, "/workspace", { portable: true })),
|
||||
context,
|
||||
).toEqual(native)
|
||||
expect({ command, portable }).toEqual({ command, portable: legacy })
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
test.each([
|
||||
["/bin/bash", "git status && npm run test -- --watch"],
|
||||
["/bin/bash", "git\tstatus; git status | cat; git diff || echo done"],
|
||||
["/bin/bash", "echo \"two words\"; printf 'static text'"],
|
||||
["/bin/bash", "aws s3 ls; docker compose up; git remote add origin; bun run test"],
|
||||
["/bin/bash", 'git "status"; git remote "add" origin; aws s3 "ls"'],
|
||||
["/bin/bash", "echo $(curl example.test | sed s/x/y/)"],
|
||||
["/bin/bash", "if true; then printf yes; else printf no; fi"],
|
||||
["/bin/bash", "(git status) && { npm test; }"],
|
||||
["/bin/bash", "(printf ok) > output"],
|
||||
["/bin/bash", "{ printf ok; } > output"],
|
||||
["/bin/bash", ">$(printf output)"],
|
||||
["/bin/bash", "printf ok # ignored ; curl example.test\nprintf done"],
|
||||
["/bin/bash", "cd ~/project; cd src && cd ..; pwd"],
|
||||
["/bin/bash", "cd src&&cd.."],
|
||||
["/bin/bash", "echo $((1 + 2))"],
|
||||
["/bin/bash", "echo $((1 + $(printf 2)))"],
|
||||
["/bin/bash", "$COMMAND status"],
|
||||
["/bin/zsh", "cd ~/project; chdir src && cd ..; git status"],
|
||||
["/bin/zsh", "echo $((1 + 2)); cd src&&cd.."],
|
||||
["/bin/dash", "cd src&&cd ..; pwd"],
|
||||
["/bin/sh", "echo $((1 + 2)); git status; cd src; pwd"],
|
||||
["/bin/ksh", "git status; cd src; pwd"],
|
||||
["pwsh", "Get-ChildItem; Write-Output done | Out-String"],
|
||||
["pwsh", "Set-Location -LiteralPath C:\\tmp; Get-ChildItem"],
|
||||
["pwsh", "git status; npm run test; docker compose up"],
|
||||
["pwsh", 'git "status"; npm "run" test; docker "compose" up'],
|
||||
["pwsh", "Write-Output done # comment\nGet-ChildItem"],
|
||||
])("native resources, saved prefixes, and directories match in %s: %s", async (shell, command) => {
|
||||
const scanned = shell === "pwsh" ? ShellScan.scanPowerShell(command) : ShellScan.scan(command)
|
||||
expect(scanned.kind).toBe("scanned")
|
||||
const native = await Effect.runPromise(ShellParse.scanPortable(command, shell, "/workspace"))
|
||||
expect(native).toEqual(await Effect.runPromise(ShellParse.scan(command, shell, "/workspace")))
|
||||
expect(await Effect.runPromise(ShellParse.scan(command, shell, "/workspace", { portable: true }))).toEqual(native)
|
||||
})
|
||||
|
||||
test.each(["> output", "FOO=bar", "2>> output"])(
|
||||
"returns an explicit empty result for statements without executable command nodes: %s",
|
||||
async (command) => {
|
||||
expect(ShellScan.scan(command)).toEqual({ kind: "scanned", commands: [] })
|
||||
const native = await Effect.runPromise(ShellParse.scanPortable(command, "bash", "/workspace"))
|
||||
expect(native).toEqual({ commands: [], directories: [] })
|
||||
expect(await Effect.runPromise(ShellParse.scan(command, "bash", "/workspace"))).toEqual(native)
|
||||
expect(await Effect.runPromise(ShellParse.scan(command, "bash", "/workspace", { portable: true }))).toEqual(
|
||||
native,
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe("ShellParse malformed native syntax", () => {
|
||||
test.each([
|
||||
["bash", 'echo "unterminated', "unterminated-quote"],
|
||||
["bash", "printf done &&", "invalid-structure"],
|
||||
["bash", "cat >", "invalid-redirect"],
|
||||
["bash", ">", "invalid-redirect"],
|
||||
["bash", "FOO=bar >", "invalid-redirect"],
|
||||
["bash", "echo \\", "unterminated-escape"],
|
||||
["pwsh", 'Write-Output "unterminated', "unterminated-quote"],
|
||||
["pwsh", "git 12>bar", "invalid-redirect"],
|
||||
["pwsh", "Write-Output `", "unterminated-escape"],
|
||||
] as const)("fails explicitly for malformed %s syntax: %s", async (shell, command, reason) => {
|
||||
const scanned = shell === "pwsh" ? ShellScan.scanPowerShell(command) : ShellScan.scan(command)
|
||||
expect(scanned).toEqual({ kind: "opaque", reason })
|
||||
expect(await Effect.runPromise(Effect.result(ShellParse.scanPortable(command, shell, "/workspace")))).toMatchObject(
|
||||
{
|
||||
_tag: "Failure",
|
||||
failure: { message: `Portable shell scanner cannot analyze command: ${reason}` },
|
||||
},
|
||||
)
|
||||
expect(
|
||||
await Effect.runPromise(Effect.result(ShellParse.scan(command, shell, "/workspace", { portable: true }))),
|
||||
).toMatchObject({
|
||||
_tag: "Failure",
|
||||
failure: { message: `Portable shell scanner cannot analyze command: ${reason}` },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// This generator describes a supported grammar; opaque results fail the test rather than being filtered out.
|
||||
function generated() {
|
||||
const result: Array<[shell: string, command: string]> = []
|
||||
const bashHeads = ["git", "npm", "echo", "printf", "cat", "cd"]
|
||||
@@ -116,30 +42,107 @@ function generated() {
|
||||
" ./relative",
|
||||
" /tmp/absolute",
|
||||
]
|
||||
const bashSeparators = [" ; ", " && ", " || ", " | ", " |& ", "\n"]
|
||||
const assignments = ["", "X=value ", "X='two words' ", 'X="two words" ']
|
||||
const redirects = ["", " > output", " 2> error", " < input", " >> output"]
|
||||
const bashSeparators = [" ; ", " && ", " || ", " | ", " |& ", "\n"]
|
||||
|
||||
for (const head of bashHeads)
|
||||
for (const arg of bashArgs)
|
||||
for (const assignment of ["", "X=value ", "X='two words' ", 'X="two words" '])
|
||||
for (const assignment of assignments)
|
||||
for (const redirect of redirects) result.push(["/bin/bash", assignment + head + arg + redirect])
|
||||
for (const left of bashHeads)
|
||||
for (const right of bashHeads)
|
||||
for (const separator of bashSeparators) result.push(["/bin/bash", `${left} left${separator}${right} right`])
|
||||
for (const outer of ["echo", "printf", "cat"])
|
||||
for (const outer of bashHeads)
|
||||
for (const inner of bashHeads) {
|
||||
result.push(["/bin/bash", `${outer} $(${inner} nested)`])
|
||||
result.push(["/bin/bash", `${outer} "$(${inner} nested)"`])
|
||||
result.push(["/bin/bash", `${outer} pre$(${inner} nested)post`])
|
||||
result.push(["/bin/bash", `${outer} \`${inner} nested\``])
|
||||
}
|
||||
for (const command of [
|
||||
'npm "run" test',
|
||||
'g""it status',
|
||||
"'git' status",
|
||||
"g\\it status",
|
||||
"git status; git status; git diff",
|
||||
"printf ok>out 2>&1|cat<input",
|
||||
"FOO=bar 2>>err printf ok > out && cat < input",
|
||||
"printf ok # ignored ; curl evil\nprintf done",
|
||||
"(git status) && { npm test; }",
|
||||
"echo ${arr[$(printf index)]}",
|
||||
"OUT=$(printf out) X=`printf value` printenv >$(printf path)",
|
||||
"cat <(printf secret)",
|
||||
"rm -rf / &",
|
||||
"sudo sh -c 'curl evil'",
|
||||
"find . -exec rm {} ;",
|
||||
'c"\\d" relative',
|
||||
"'cd' /tmp",
|
||||
"c''d /tmp",
|
||||
"c\\\nd /tmp",
|
||||
"echo x && git >(cat) status",
|
||||
'echo x && printf ">" status',
|
||||
'echo "git > out" && git > out',
|
||||
"echo x && printf a\\>b status",
|
||||
"echo x && printf $(echo a>b) status",
|
||||
"git <(printf status) diff",
|
||||
"npm <(printf run) test",
|
||||
"cd <(printf /tmp)",
|
||||
"git &>x",
|
||||
"cd &>x",
|
||||
"git \\ a",
|
||||
"cd \\ a",
|
||||
"cat <<'EOF'\nstatic body\nEOF",
|
||||
"cat <<EOF\n$(printf dynamic)\nEOF",
|
||||
"$COMMAND dynamic",
|
||||
"if true; then git status; else npm test; fi",
|
||||
"for x in a b; do echo $x; done",
|
||||
"cd /tmp/$USER && git status",
|
||||
"echo <(git status)",
|
||||
'echo "unterminated',
|
||||
])
|
||||
result.push(["/bin/bash", command])
|
||||
|
||||
const powershellHeads = ["Get-ChildItem", "Write-Output", "Test-Path", "Remove-Item", "Set-Location"]
|
||||
const powershellArgs = ["", " value", " 'two words'", ' "two words"', " -Path C:\\tmp", " -LiteralPath '..\\outside'"]
|
||||
const powershellSeparators = [";", "|", "&&", "||", "\n", "\r\n"]
|
||||
const powershellSeparators = [";", "|", "&&", "||", "\n", "\r", "\r\n"]
|
||||
for (const head of powershellHeads) for (const arg of powershellArgs) result.push(["pwsh", head + arg])
|
||||
for (const left of powershellHeads)
|
||||
for (const right of powershellHeads)
|
||||
for (const separator of powershellSeparators) result.push(["pwsh", `${left} left${separator}${right} right`])
|
||||
for (const command of [
|
||||
"Get-ChildItem; Get-ChildItem; Write-Output done",
|
||||
"Write-Output 'a''b; still string'; Write-Output \"a`\"; still string\"",
|
||||
"Get-Content in.txt > out.txt 2>&1 | Out-File all.log",
|
||||
"Write-Output ok > output.txt # ignored\nGet-ChildItem",
|
||||
"Write-Output ok > output.txt # ignored\rGet-ChildItem",
|
||||
"Write-Output ok > output.txt # ignored\r\nGet-ChildItem",
|
||||
"& git status",
|
||||
". ./deploy.ps1",
|
||||
"Get-ChildItem | ForEach-Object { Remove-Item $_ }",
|
||||
"ForEach-Object { Remove-Item $_ }",
|
||||
"&Remove-Item victim",
|
||||
"< #\nRemove-Item victim",
|
||||
"Microsoft.PowerShell.Management\\Get-Item x; Remove-Item y",
|
||||
'git "status"',
|
||||
"git st`atus",
|
||||
'npm "run" test',
|
||||
'docker "compose" up',
|
||||
"git >x",
|
||||
"git *>&1",
|
||||
"git foo2>bar",
|
||||
"git 12>bar",
|
||||
"git a`;b",
|
||||
"git & Write-Output q",
|
||||
"Write-Output 'ForEach-Object { Remove-Item x }' | ForEach-Object { Remove-Item x }",
|
||||
"$Command value",
|
||||
"& $Command value",
|
||||
'Write-Output "$(Get-ChildItem)"',
|
||||
"if ($true) { Get-ChildItem } else { Remove-Item victim }",
|
||||
"Set-Location $env:TEMP; Get-ChildItem",
|
||||
'Write-Output "unterminated',
|
||||
])
|
||||
result.push(["pwsh", command])
|
||||
|
||||
let state = 0x5eed1234
|
||||
const random = (length: number) => {
|
||||
@@ -153,10 +156,10 @@ function generated() {
|
||||
const separator = bashSeparators[random(bashSeparators.length)]
|
||||
const bashForms = [
|
||||
`${left}${arg}${separator}${right} fuzz${index}`,
|
||||
`echo $(${right} fuzz${index})`,
|
||||
`${left}${arg} $(${right} fuzz${index})`,
|
||||
`${left}${arg} # ignored\n${right} fuzz${index}`,
|
||||
`X=value ${left}${arg}${redirects[random(redirects.length)]}`,
|
||||
`${left} 'two words'${separator}${right} fuzz${index}`,
|
||||
`${left} before\\\nafter${separator}${right} fuzz${index}`,
|
||||
]
|
||||
result.push(["/bin/bash", bashForms[index % bashForms.length]])
|
||||
|
||||
@@ -168,9 +171,10 @@ function generated() {
|
||||
`${powershellLeft}${powershellArg}${powershellSeparator}${powershellRight} fuzz${index}`,
|
||||
`${powershellLeft}${powershellArg} # ignored\n${powershellRight} fuzz${index}`,
|
||||
`${powershellLeft} fuzz${index} > output; ${powershellRight}${powershellArg}`,
|
||||
`${powershellLeft} "fuzz${index}" | ${powershellRight}${powershellArg}`,
|
||||
`${powershellLeft}\`\n fuzz${index}; ${powershellRight}${powershellArg}`,
|
||||
]
|
||||
result.push(["pwsh", powershellForms[index % powershellForms.length]])
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { ShellParse } from "../src/shell/parse.js"
|
||||
import { Wildcard } from "../src/util/wildcard.js"
|
||||
|
||||
describe("native shell syntax compatibility", () => {
|
||||
test("PowerShell invocation approvals include the operator instead of saving an ineffective prefix", async () => {
|
||||
const command = "& $Command value"
|
||||
expect(await Effect.runPromise(ShellParse.scan(command, "pwsh", "/workspace"))).toEqual({
|
||||
commands: [{ resource: command, save: "$Command *" }],
|
||||
directories: [],
|
||||
})
|
||||
expect(await Effect.runPromise(ShellParse.scan(command, "pwsh", "/workspace", { portable: true }))).toEqual({
|
||||
commands: [{ resource: command, save: "& $Command *" }],
|
||||
directories: [],
|
||||
})
|
||||
})
|
||||
|
||||
test.each([
|
||||
"ForEach-Object { Write-Output value }",
|
||||
"Write-Output before; ForEach-Object { Write-Output value }",
|
||||
"ForEach-Object { Write-Output value } | Write-Output done",
|
||||
"Write-Output before | ForEach-Object { Write-Output $_ }",
|
||||
"& ForEach-Object { Write-Output value }",
|
||||
"& 'ForEach-Object' { Write-Output value }",
|
||||
"% { Write-Output value }",
|
||||
"Where-Object { Write-Output value }",
|
||||
])("PowerShell scriptblock callers preserve permission resources and usable approvals: %s", async (command) => {
|
||||
const legacy = await Effect.runPromise(ShellParse.scan(command, "pwsh", "/workspace"))
|
||||
const native = await Effect.runPromise(ShellParse.scan(command, "pwsh", "/workspace", { portable: true }))
|
||||
expect(native.commands.map((item) => item.resource)).toEqual(legacy.commands.map((item) => item.resource))
|
||||
for (const item of native.commands) expect(Wildcard.match(item.resource, item.save), item.resource).toBe(true)
|
||||
})
|
||||
|
||||
for (const shell of ["bash", "zsh"]) {
|
||||
test.each([
|
||||
"cat <<'EOF'\n$(not_a_command)\nEOF",
|
||||
"cat <<EOF\n$(printf hello)\nEOF",
|
||||
"cat <<-EOF\n\thello\n\tEOF",
|
||||
"cat <<EOF\nhello\nEOF\nprintf done",
|
||||
'cat <<< "$(printf hello)"',
|
||||
"for file in a b; do printf '%s' \"$file\"; done",
|
||||
"for file in $(printf file); do printf '%s' \"$file\"; done",
|
||||
'for file in a b; do if test -n "$file"; then printf \'%s\' "$file"; fi; done',
|
||||
"while IFS= read -r file; do printf '%s' \"$file\"; done < input",
|
||||
"until test -f ready; do sleep 1; done",
|
||||
"if true; then if false; then printf no; else printf yes; fi; fi",
|
||||
"if true; then :; X=$(printf value); fi",
|
||||
'case "$target" in *.ts) printf typescript;; *) printf other;; esac',
|
||||
"greet() { printf hello; }; greet",
|
||||
"function greet { printf hello; }; greet",
|
||||
"printf before; { printf grouped; }; (printf subshell)",
|
||||
"time git status",
|
||||
"time -p git status",
|
||||
"coproc git status",
|
||||
"if [[ -f file ]]; then cat file; fi",
|
||||
"[[ $(printf yes) = yes ]]",
|
||||
"echo ${value:-default}",
|
||||
'echo "${value:-$(printf fallback)}"',
|
||||
"echo ${value//before/after}",
|
||||
"echo ${arr[$(printf index)]}",
|
||||
"printf '%s' $'line1\\nline2'",
|
||||
'printf "%s" $"hello"',
|
||||
"echo $((1 + ${value:-2}))",
|
||||
"echo $((array[$(printf 0)]))",
|
||||
"echo $[1 + 2]",
|
||||
"((count++))",
|
||||
"for ((i=0; i<2; i++)); do printf ok; done",
|
||||
"echo `printf \\2`",
|
||||
])(`${shell} extracts commands without rejecting ordinary syntax: %s`, async (command) => {
|
||||
const legacy = await Effect.runPromise(ShellParse.scan(command, shell, "/workspace"))
|
||||
const native = await Effect.runPromise(ShellParse.scan(command, shell, "/workspace", { portable: true }))
|
||||
expect(native).toEqual(legacy)
|
||||
expect(await Effect.runPromise(ShellParse.scanPortable(command, shell, "/workspace"))).toEqual(native)
|
||||
})
|
||||
}
|
||||
|
||||
test("does not invent commands from a quoted second heredoc body", async () => {
|
||||
const command = "cat <<FIRST <<'SECOND'\n$(printf first)\nFIRST\n$(not_a_command)\nSECOND"
|
||||
const expected = {
|
||||
commands: [
|
||||
{ resource: command, save: "cat *" },
|
||||
{ resource: "printf first", save: "printf *" },
|
||||
],
|
||||
directories: [],
|
||||
}
|
||||
expect(await Effect.runPromise(ShellParse.scanPortable(command, "bash", "/workspace"))).toEqual(expected)
|
||||
expect(await Effect.runPromise(ShellParse.scan(command, "bash", "/workspace", { portable: true }))).toEqual(
|
||||
expected,
|
||||
)
|
||||
})
|
||||
|
||||
test.each([
|
||||
'Write-Output "$(Get-Location)"',
|
||||
"$value = Get-Date; Write-Output $value",
|
||||
"if ($true) { Write-Output yes } else { Write-Output no }",
|
||||
"if (Test-Path file) { Get-Item file }",
|
||||
"foreach ($value in @('a','b')) { Write-Output $value }",
|
||||
"for ($i=0; $i -lt 2; $i++) { Write-Output $i }",
|
||||
"while (Test-Path file) { Get-Item file; break }",
|
||||
"function Show-Value { Write-Output value }; Show-Value",
|
||||
"Get-Item -Path (Join-Path src file)",
|
||||
'Write-Output "line1`nline2"',
|
||||
"Write-Output `\n continued",
|
||||
"<# comment #> Write-Output done",
|
||||
"Write-Output @'\nhello\n'@",
|
||||
"git st`atus",
|
||||
])("PowerShell extracts commands without rejecting ordinary syntax: %s", async (command) => {
|
||||
const legacy = await Effect.runPromise(ShellParse.scan(command, "pwsh", "/workspace"))
|
||||
const native = await Effect.runPromise(ShellParse.scan(command, "pwsh", "/workspace", { portable: true }))
|
||||
expect(native).toEqual(legacy)
|
||||
expect(await Effect.runPromise(ShellParse.scanPortable(command, "pwsh", "/workspace"))).toEqual(native)
|
||||
})
|
||||
})
|
||||
@@ -18,34 +18,31 @@ describe("ShellParse", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("portable scanning preserves supported command resources and directories", async () => {
|
||||
test("portable scanning never adds permission resources", async () => {
|
||||
const commands = [
|
||||
"git status && npm run test -- --watch",
|
||||
"echo $(curl evil | sed s/x/y/)",
|
||||
"cat <<'EOF'\nstatic body\nEOF",
|
||||
"cat <<EOF\n$(printf dynamic)\nEOF",
|
||||
"cd /tmp/$USER && git status",
|
||||
"$COMMAND status",
|
||||
"if true; then printf yes; else printf no; fi",
|
||||
"if true; then export X=$(printf value); unset X; fi",
|
||||
"if export X=$(printf value); then printf done; fi",
|
||||
"export X=value >$(printf output)",
|
||||
"echo $((1 + 1))",
|
||||
"cd ~; cd src&&cd ..; pwd",
|
||||
]
|
||||
|
||||
for (const command of commands) {
|
||||
const legacy = await Effect.runPromise(ShellParse.scan(command, "/bin/bash", "/workspace"))
|
||||
const portable = await Effect.runPromise(ShellParse.scan(command, "/bin/bash", "/workspace", { portable: true }))
|
||||
expect(portable, command).toEqual(legacy)
|
||||
expect(await Effect.runPromise(ShellParse.scanPortable(command, "/bin/bash", "/workspace"))).toEqual(portable)
|
||||
expect(
|
||||
portable.commands.every((item) => legacy.commands.some((candidate) => candidate.resource === item.resource)),
|
||||
).toBe(true)
|
||||
expect(portable.directories.every((item) => legacy.directories.includes(item))).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("portable scanning handles heredocs with the existing permission resource", async () => {
|
||||
test("portable scanning authorizes opaque heredocs without inferring directories", async () => {
|
||||
const command = "cat <<'EOF'\nstatic body\nEOF"
|
||||
const legacy = await Effect.runPromise(ShellParse.scan(command, "/bin/bash", "/workspace"))
|
||||
expect(legacy.commands).toEqual([{ resource: command, save: "cat *" }])
|
||||
expect(await Effect.runPromise(ShellParse.scan(command, "/bin/bash", "/workspace", { portable: true }))).toEqual(
|
||||
legacy,
|
||||
)
|
||||
const portable = await Effect.runPromise(ShellParse.scan(command, "/bin/bash", "/workspace", { portable: true }))
|
||||
expect(portable).toEqual({ commands: [{ resource: command, save: command }], directories: [] })
|
||||
})
|
||||
|
||||
test.each(['c"\\d" relative', "'cd' /tmp", "c''d /tmp", "c\\\nd /tmp"])(
|
||||
@@ -57,53 +54,6 @@ describe("ShellParse", () => {
|
||||
},
|
||||
)
|
||||
|
||||
test.each(["declare", "typeset", "export", "readonly", "local", "unset", "unsetenv"])(
|
||||
"preserves declaration permission behavior for %s without hiding nested commands",
|
||||
async (name) => {
|
||||
for (const command of [`${name} X`, `${name} "$(printf X)"; git status`]) {
|
||||
const legacy = await Effect.runPromise(ShellParse.scan(command, "/bin/bash", "/workspace"))
|
||||
expect(legacy.commands).toEqual(
|
||||
command.includes("$(")
|
||||
? [
|
||||
{ resource: "printf X", save: "printf *" },
|
||||
{ resource: "git status", save: "git status *" },
|
||||
]
|
||||
: [],
|
||||
)
|
||||
expect(
|
||||
await Effect.runPromise(ShellParse.scan(command, "/bin/bash", "/workspace", { portable: true })),
|
||||
).toEqual(legacy)
|
||||
}
|
||||
|
||||
for (const command of [`"${name}" X`, `FOO=bar ${name} X`, `command ${name} X`, `>${name}.txt ${name} X`]) {
|
||||
const legacy = await Effect.runPromise(ShellParse.scan(command, "/bin/bash", "/workspace"))
|
||||
expect(legacy.commands).toHaveLength(1)
|
||||
expect(
|
||||
await Effect.runPromise(ShellParse.scan(command, "/bin/bash", "/workspace", { portable: true })),
|
||||
).toEqual(legacy)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
test("declaration filtering retains directory checks inside command substitutions", async () => {
|
||||
const command = "export X=$(cd /outside; printf value)"
|
||||
const expected = { commands: [{ resource: "printf value", save: "printf *" }], directories: ["/outside"] }
|
||||
expect(await Effect.runPromise(ShellParse.scan(command, "/bin/bash", "/workspace"))).toEqual(expected)
|
||||
expect(await Effect.runPromise(ShellParse.scan(command, "/bin/bash", "/workspace", { portable: true }))).toEqual(
|
||||
expected,
|
||||
)
|
||||
})
|
||||
|
||||
test("does not treat PowerShell commands as Bash declarations", async () => {
|
||||
expect(await Effect.runPromise(ShellParse.scanPortable("export X; unset X", "pwsh", "/workspace"))).toEqual({
|
||||
commands: [
|
||||
{ resource: "export X", save: "export *" },
|
||||
{ resource: "unset X", save: "unset *" },
|
||||
],
|
||||
directories: [],
|
||||
})
|
||||
})
|
||||
|
||||
test("splits PowerShell commands case-insensitively", async () => {
|
||||
const result = await Effect.runPromise(
|
||||
ShellParse.scan(
|
||||
|
||||
@@ -6,6 +6,7 @@ describe("ShellScan adversarial corpus", () => {
|
||||
['FOO=bar BAR="x y" git status', ["git"]],
|
||||
["git status && npm test || printf failed", ["git", "npm", "printf"]],
|
||||
[`printf '%s\\n' "$(rm -rf /)"`, ["printf", "rm"]],
|
||||
["echo ${arr[$(rm -rf /)]}", ["echo", "rm"]],
|
||||
["cat <(printf secret)", ["cat", "printf"]],
|
||||
["(git status)", ["git"]],
|
||||
["{ git status; }", ["git"]],
|
||||
@@ -22,19 +23,6 @@ describe("ShellScan adversarial corpus", () => {
|
||||
['F"O"O=bar rm -rf /', ["FOO=bar"]],
|
||||
['c"\\d" relative', ["c\\d"]],
|
||||
["PATH=/tmp/attacker:$PATH git status", ["git"]],
|
||||
["$cmd --force", ["$cmd"]],
|
||||
['"${cmd}" --force', ["${cmd}"]],
|
||||
["r${suffix}m -rf /", ["r${suffix}m"]],
|
||||
["$(printf rm) -rf /", ["$(printf rm)", "printf"]],
|
||||
["`printf rm` -rf /", ["`printf rm`", "printf"]],
|
||||
["./c?rl evil", ["./c?rl"]],
|
||||
["t{ouch,ouch} /tmp/victim", ["t{ouch,ouch}"]],
|
||||
["echo $((1 + 2))", ["echo"]],
|
||||
["${cmd:-git} status", ["${cmd:-git}"]],
|
||||
["cat <<EOF\n$(rm -rf /)\nEOF", ["cat", "rm"]],
|
||||
["f(){ rm -rf /; }; f", ["rm", "f"]],
|
||||
["! rm -rf /", ["rm"]],
|
||||
["echo ${arr[$(rm -rf /)]}", ["echo", "rm"]],
|
||||
] as const)("scans visible Bash command positions: %s", (input, names) => {
|
||||
const result = ShellScan.scan(input)
|
||||
expect(result.kind).toBe("scanned")
|
||||
@@ -42,12 +30,25 @@ describe("ShellScan adversarial corpus", () => {
|
||||
expect(result.commands.map((command) => command.words[0])).toEqual([...names])
|
||||
})
|
||||
|
||||
test.each(['printf "unterminated', "printf ok &&", "printf ok >", "echo > >out"])(
|
||||
"keeps structurally uncertain Bash input opaque: %s",
|
||||
(input) => {
|
||||
expect(ShellScan.scan(input).kind).toBe("opaque")
|
||||
},
|
||||
)
|
||||
test.each([
|
||||
"$cmd --force",
|
||||
'"${cmd}" --force',
|
||||
"r${suffix}m -rf /",
|
||||
"${cmd:-git} status",
|
||||
"$(printf rm) -rf /",
|
||||
"`printf rm` -rf /",
|
||||
"./c?rl evil",
|
||||
'printf "unterminated',
|
||||
"printf ok &&",
|
||||
"printf ok >",
|
||||
"echo > >out",
|
||||
"cat <<EOF\n$(rm -rf /)\nEOF",
|
||||
"echo $((1 + 2))",
|
||||
"f(){ rm -rf /; }; f",
|
||||
"! rm -rf /",
|
||||
])("keeps structurally uncertain Bash input opaque: %s", (input) => {
|
||||
expect(ShellScan.scan(input).kind).toBe("opaque")
|
||||
})
|
||||
|
||||
test.each([
|
||||
['pwsh --command "Remove-Item victim.txt"', ["pwsh"]],
|
||||
@@ -55,15 +56,7 @@ describe("ShellScan adversarial corpus", () => {
|
||||
["Invoke-Expression 'Remove-Item victim.txt'", ["Invoke-Expression"]],
|
||||
[". ./deploy.ps1", ["./deploy.ps1"]],
|
||||
["& git status", ["git"]],
|
||||
["& $Command status", ["$Command"]],
|
||||
["Set-Location $HOME/$target; Get-ChildItem", ["Set-Location", "Get-ChildItem"]],
|
||||
["Get-ChildItem | ForEach-Object { Remove-Item $_ }", ["Get-ChildItem", "ForEach-Object", "Remove-Item"]],
|
||||
['Write-Output "$(Get-ChildItem)"', ["Write-Output", "Get-ChildItem"]],
|
||||
["Remove-`Item victim", ["Remove-Item"]],
|
||||
["Remove-Item`\r\n victim", ["Remove-Item\r\n"]],
|
||||
["Invoke-`\nExpression 'Remove-Item victim'", ["Invoke-\nExpression"]],
|
||||
["<# ignored #> Remove-Item victim", ["Remove-Item"]],
|
||||
["[string]$x = Remove-Item victim", ["Remove-Item"]],
|
||||
] as const)("scans visible PowerShell command positions: %s", (input, names) => {
|
||||
const result = ShellScan.scanPowerShell(input)
|
||||
expect(result.kind).toBe("scanned")
|
||||
@@ -71,7 +64,19 @@ describe("ShellScan adversarial corpus", () => {
|
||||
expect(result.commands.map((command) => command.words[0])).toEqual([...names])
|
||||
})
|
||||
|
||||
test.each(['Write-Output "unterminated', "Get-ChildItem |"])("reports incomplete PowerShell input: %s", (input) => {
|
||||
test.each([
|
||||
"$Command status",
|
||||
"& $Command status",
|
||||
'Write-Output "$(Get-ChildItem)"',
|
||||
"Set-Location $HOME/$target; Get-ChildItem",
|
||||
"Remove-`Item victim",
|
||||
"Remove-Item`\r\n victim",
|
||||
"Invoke-`\nExpression 'Remove-Item victim'",
|
||||
"<# ignored #> Remove-Item victim",
|
||||
"[string]$x = Remove-Item victim",
|
||||
'Write-Output "unterminated',
|
||||
"Get-ChildItem |",
|
||||
])("keeps structurally uncertain PowerShell input opaque: %s", (input) => {
|
||||
expect(ShellScan.scanPowerShell(input).kind).toBe("opaque")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ShellScan } from "../../src/shell/scan.js"
|
||||
|
||||
describe("Bash arithmetic expansions", () => {
|
||||
test.each([
|
||||
"$((1+1))",
|
||||
"$((1 + 1))",
|
||||
"$(((1 + 2) * (3 + (4))))",
|
||||
"$((value + $other + ${third}))",
|
||||
"$((value += 2, value > 1 ? value << 2 : ~value))",
|
||||
"$((16#ff & 0xff | 2 ** 3))",
|
||||
"$((1 + $((2 * 3))))",
|
||||
"$((1 + \\\n2))",
|
||||
"$((1 +\n2))",
|
||||
"$((1 + ${value:-2}))",
|
||||
"$((array[index]))",
|
||||
"$((1 + $[2]))",
|
||||
'$((1 + "2"))',
|
||||
"$((1 + '2'))",
|
||||
])("preserves arithmetic without evaluating it: %s", (expression) => {
|
||||
for (const argument of [expression, `"${expression}"`]) {
|
||||
expect(ShellScan.scan(`echo ${argument}`)).toEqual({
|
||||
kind: "scanned",
|
||||
commands: [{ resource: `echo ${argument}`, words: ["echo", expression], rawWords: ["echo", argument] }],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test.each([
|
||||
"$((1 + $(printf 2)))",
|
||||
"$((1 + `printf 2`))",
|
||||
"$((1 + $((2 * $(printf 2)))))",
|
||||
'$((1 + $(printf "%s" "$(printf 2)")))',
|
||||
"$((array[$(printf 2)]))",
|
||||
"$((1 + `printf \\2`))",
|
||||
])("reports explicit commands inside arithmetic: %s", (expression) => {
|
||||
for (const argument of [expression, `"${expression}"`]) {
|
||||
const result = ShellScan.scan(`echo ${argument}; pwd`)
|
||||
expect(result.kind).toBe("scanned")
|
||||
if (result.kind === "opaque") return
|
||||
expect(result.commands[0]).toEqual({
|
||||
resource: `echo ${argument}`,
|
||||
words: ["echo", expression],
|
||||
rawWords: ["echo", argument],
|
||||
})
|
||||
expect(result.commands.slice(1, -1).map((command) => command.words[0])).toEqual(
|
||||
expression.includes('"$(printf') ? ["printf", "printf"] : ["printf"],
|
||||
)
|
||||
expect(result.commands.at(-1)).toEqual({ resource: "pwd", words: ["pwd"], rawWords: ["pwd"] })
|
||||
}
|
||||
})
|
||||
|
||||
test.each([
|
||||
'VALUE=$((1 + $(printf 2))) echo ok >"$((3 + $(printf 4)))"',
|
||||
'echo "$(echo $((1 + $(printf 2))))"',
|
||||
'{ echo "$((1 + $(printf 2)))"; }',
|
||||
'(echo "$((1 + $(printf 2)))")',
|
||||
'if true; then echo "$((1 + $(printf 2)))"; fi',
|
||||
])("recognizes arithmetic in existing shell contexts: %s", (source) => {
|
||||
const result = ShellScan.scan(source)
|
||||
expect(result.kind).toBe("scanned")
|
||||
if (result.kind === "opaque") return
|
||||
expect(result.commands.map((command) => command.words[0])).toContain("printf")
|
||||
expect(result.commands.map((command) => command.words[0])).toContain("echo")
|
||||
})
|
||||
|
||||
test("does not expand arithmetic inside single quotes", () => {
|
||||
expect(ShellScan.scan("echo '$((1 + $(ignored)))'")).toEqual({
|
||||
kind: "scanned",
|
||||
commands: [
|
||||
{
|
||||
resource: "echo '$((1 + $(ignored)))'",
|
||||
words: ["echo", "$((1 + $(ignored)))"],
|
||||
rawWords: ["echo", "'$((1 + $(ignored)))'"],
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test.each([
|
||||
"echo $((1 + 2)",
|
||||
"echo $(((1 + 2))",
|
||||
"echo $((1 + $(printf 2))) &&",
|
||||
"echo $((1 + $(printf 2 &&)))",
|
||||
"echo $((1; printf 2))",
|
||||
])("rejects malformed arithmetic syntax: %s", (source) => {
|
||||
expect(ShellScan.scan(source).kind).toBe("opaque")
|
||||
})
|
||||
|
||||
test("bounds arithmetic nesting, input size, and repeated conditional work", () => {
|
||||
expect(ShellScan.scan(`echo $((${"(".repeat(33)}1${")".repeat(33)}))`).kind).toBe("opaque")
|
||||
expect(ShellScan.scan(`echo ${"$((".repeat(33)}1${"))".repeat(33)}`).kind).toBe("opaque")
|
||||
expect(ShellScan.scan(`echo $((${"1+".repeat(32 * 1024)}1))`).kind).toBe("opaque")
|
||||
const source = Array.from({ length: 16 }).reduce<string>(
|
||||
(source) => `if true; then echo $((1 + $(${source}))); fi`,
|
||||
`printf ${"1".repeat(1024)}`,
|
||||
)
|
||||
expect(ShellScan.scan(source).kind).toBe("scanned")
|
||||
expect(ShellScan.scan("echo $((1+1))").kind).toBe("scanned")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Bash arithmetic real-shell oracle", () => {
|
||||
const expressions = [
|
||||
["$((1+1))", "2"],
|
||||
["$(((1 + 2) * (3 + (4))))", "21"],
|
||||
["$((value + $other + ${third}))", "9"],
|
||||
["$((value += 2, value > 1 ? value << 2 : ~value))", "16"],
|
||||
["$(((16#ff & 0xff) | (2 ** 3)))", "255"],
|
||||
["$((1 + $((2 * 3))))", "7"],
|
||||
["$((1 + $(scan_probe)))", "3"],
|
||||
["$((1 + `scan_probe`))", "3"],
|
||||
["$(((1 + $(scan_probe)) * $((2 + $(scan_probe)))))", "12"],
|
||||
['$((1 + $(printf "%s" "$(scan_probe)")))', "3"],
|
||||
] as const
|
||||
|
||||
for (const shell of ["bash", "zsh"]) {
|
||||
const executable = Bun.which(shell)
|
||||
test.skipIf(!executable).each(expressions)(`${shell} evaluates %s independently`, (expression, output) => {
|
||||
for (const argument of [expression, `"${expression}"`]) {
|
||||
const source = `value=2; other=3; third=4; printf '%s\\n' ${argument}`
|
||||
const execution = Bun.spawnSync(
|
||||
[
|
||||
executable!,
|
||||
...(shell === "bash" ? ["--noprofile", "--norc"] : ["-f"]),
|
||||
"-c",
|
||||
`scan_probe() { printf 'scan_probe\\n' >&2; printf 2; }; ${source}`,
|
||||
],
|
||||
{ env: { PATH: "/usr/bin:/bin", LC_ALL: "C" } },
|
||||
)
|
||||
expect(execution.exitCode).toBe(0)
|
||||
expect(execution.stdout.toString()).toBe(`${output}\n`)
|
||||
const result = ShellScan.scan(source)
|
||||
expect(result.kind).toBe("scanned")
|
||||
if (result.kind === "opaque") return
|
||||
const observed = execution.stderr.toString().trim().split("\n").filter(Boolean)
|
||||
expect(observed).toEqual(Array.from({ length: expression.split("scan_probe").length - 1 }, () => "scan_probe"))
|
||||
expect(result.commands.filter((command) => command.words[0] === "scan_probe")).toHaveLength(observed.length)
|
||||
expect(result.commands[0]?.words).toEqual(["printf", "%s\\n", expression])
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -1,266 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ShellScan } from "../../src/shell/scan.js"
|
||||
|
||||
const executions = [
|
||||
['printf safe; "scan_probe"', "scan_probe"],
|
||||
["printf safe; 'scan_probe'", "scan_probe"],
|
||||
["printf safe; $(printf scan_probe)", "$(printf scan_probe)"],
|
||||
["X=${unset:-a b} scan_probe", "scan_probe"],
|
||||
["X=value # comment\nscan_probe", "scan_probe"],
|
||||
['printf "%s" `\\$(scan_probe)`', "scan_probe"],
|
||||
['printf %s `printf \\\\"; scan_probe; printf \\\\"`', "scan_probe"],
|
||||
["if true; then X=x scan_probe; fi", "scan_probe"],
|
||||
["if true; then >/dev/null X=x scan_probe; fi", "scan_probe"],
|
||||
["printf safe; { scan_probe; }", "scan_probe"],
|
||||
["if true; then { scan_probe; }; fi", "scan_probe"],
|
||||
["s{can_probe,can_probe}", "s{can_probe,can_probe}"],
|
||||
['printf safe; # comment\n"scan_probe"', "scan_probe"],
|
||||
["printf safe; \\\n'scan_probe'", "scan_probe"],
|
||||
] as const
|
||||
|
||||
describe("Bash execution safety", () => {
|
||||
for (const shell of ["bash", "zsh"]) {
|
||||
const executable = Bun.which(shell)
|
||||
test.skipIf(!executable).each([...executions])(`${shell} command syntax is visible: %s`, (source, head) => {
|
||||
const execution = Bun.spawnSync(
|
||||
[
|
||||
executable!,
|
||||
...(shell === "bash" ? ["--noprofile", "--norc"] : ["-f"]),
|
||||
"-c",
|
||||
`scan_probe() { printf 'executed\\n' >&2; }; ${source}`,
|
||||
],
|
||||
{ env: { PATH: "/usr/bin:/bin", LC_ALL: "C" } },
|
||||
)
|
||||
expect(execution.stderr.toString()).toContain("executed\n")
|
||||
const result = ShellScan.scan(source)
|
||||
expect(result.kind).toBe("scanned")
|
||||
if (result.kind === "opaque") throw new Error(result.reason)
|
||||
expect(result.commands.map((command) => command.words[0])).toContain(head)
|
||||
})
|
||||
}
|
||||
|
||||
test.each([
|
||||
"printf ok && # comment",
|
||||
'"if" true; then printf safe; fi',
|
||||
"X=x if true; then printf safe; fi",
|
||||
"(printf ok) &&",
|
||||
"{ printf ok; } ||",
|
||||
"(printf ok) |",
|
||||
"(printf ok) |&",
|
||||
])("rejects malformed command positions: %s", (source) => {
|
||||
expect(ShellScan.scan(source).kind).toBe("opaque")
|
||||
})
|
||||
|
||||
test.each([" ", "\t"])("recognizes shell whitespace %j", (space) => {
|
||||
expect(ShellScan.scan(`printf${space}ok`)).toMatchObject({
|
||||
kind: "scanned",
|
||||
commands: [{ resource: `printf${space}ok`, words: ["printf", "ok"] }],
|
||||
})
|
||||
})
|
||||
|
||||
test.each(["\r", "\v", "\f", "\u00a0", "\ufeff"])("does not normalize non-shell whitespace %j", (space) => {
|
||||
expect(ShellScan.scan(`${space}printf ok`).kind).toBe("opaque")
|
||||
expect(ShellScan.scan(`printf${space}ok`).kind).toBe("opaque")
|
||||
})
|
||||
|
||||
test.each(["'123'", '"123"', "1\\23"])("does not consume quoted command names as fd prefixes: %s", (head) => {
|
||||
expect(ShellScan.scan(`${head}>/dev/null argument`)).toMatchObject({
|
||||
kind: "scanned",
|
||||
commands: [{ resource: `${head}>/dev/null argument`, words: ["123", "argument"] }],
|
||||
})
|
||||
})
|
||||
|
||||
test.each([
|
||||
"'' > output",
|
||||
"''",
|
||||
"> output (printf ok)",
|
||||
"(printf ok) >output pwd",
|
||||
"{ printf ok; } >output X=x",
|
||||
"(printf ok) &>output pwd",
|
||||
])("keeps unsupported group positions and empty command names opaque: %s", (source) => {
|
||||
expect(ShellScan.scan(source).kind).toBe("opaque")
|
||||
})
|
||||
|
||||
test.each(["X=value > /dev/null", "X=x 2> output", "X=x < input", "> /dev/null", "2>> output", ">output 2>&1"])(
|
||||
"scans redirects without inventing an executable command: %s",
|
||||
(source) => {
|
||||
expect(ShellScan.scan(source)).toEqual({ kind: "scanned", commands: [] })
|
||||
},
|
||||
)
|
||||
|
||||
test.each([">", "X=x >", "X=x > # comment\nprintf ok", ">; printf ok", "(printf ok) >", "{ printf ok; } >"])(
|
||||
"still requires redirect targets: %s",
|
||||
(source) => {
|
||||
expect(ShellScan.scan(source)).toEqual({ kind: "opaque", reason: "invalid-redirect" })
|
||||
},
|
||||
)
|
||||
|
||||
test.each([
|
||||
"{ printf ok; } > output",
|
||||
"{ printf ok; } 2> output",
|
||||
"{ printf ok; } &> output",
|
||||
"(printf ok) > output",
|
||||
"{ printf ok; }; X=x > output",
|
||||
])("scans leading groups and their redirects without synthetic commands: %s", (source) => {
|
||||
expect(ShellScan.scan(source)).toEqual({
|
||||
kind: "scanned",
|
||||
commands: [{ resource: "printf ok", words: ["printf", "ok"], rawWords: ["printf", "ok"] }],
|
||||
})
|
||||
})
|
||||
|
||||
test.each(["X=x >$(printf output)", ">$(printf output)", "{ :; } >$(printf output)", "(:) >$(printf output)"])(
|
||||
"retains explicit substitutions in otherwise commandless redirects: %s",
|
||||
(source) => {
|
||||
const result = ShellScan.scan(source)
|
||||
expect(result.kind).toBe("scanned")
|
||||
if (result.kind === "opaque") return
|
||||
expect(result.commands.map((command) => command.words[0])).toEqual(
|
||||
source.includes(":") ? [":", "printf"] : ["printf"],
|
||||
)
|
||||
expect(result.commands.at(-1)).toEqual({
|
||||
resource: "printf output",
|
||||
words: ["printf", "output"],
|
||||
rawWords: ["printf", "output"],
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
test("scans a list following a group redirect suffix", () => {
|
||||
expect(ShellScan.scan("(printf ok) >output && pwd")).toEqual({
|
||||
kind: "scanned",
|
||||
commands: [
|
||||
{ resource: "printf ok", words: ["printf", "ok"], rawWords: ["printf", "ok"] },
|
||||
{ resource: "pwd", words: ["pwd"], rawWords: ["pwd"] },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("retains explicit colon commands and their redirects after a group", () => {
|
||||
expect(ShellScan.scan("(printf ok); : > output")).toMatchObject({
|
||||
kind: "scanned",
|
||||
commands: [
|
||||
{ resource: "printf ok", words: ["printf", "ok"] },
|
||||
{ resource: ": > output", words: [":"] },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("does not repeatedly rescan nested conditionals", () => {
|
||||
const source = Array.from({ length: 16 }).reduce<string>(
|
||||
(source) => `if true; then echo $(${source}); fi`,
|
||||
`printf ${"x".repeat(1024)}`,
|
||||
)
|
||||
expect(ShellScan.scan(source).kind).toBe("scanned")
|
||||
expect(ShellScan.scan("if true; then echo $(printf safe); fi")).toMatchObject({
|
||||
kind: "scanned",
|
||||
commands: [
|
||||
{ resource: "true", words: ["true"] },
|
||||
{ resource: "echo $(printf safe)", words: ["echo", "$(printf safe)"] },
|
||||
{ resource: "printf safe", words: ["printf", "safe"] },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test.each([
|
||||
"(printf safe # ) ignored\nscan_probe)",
|
||||
"{ printf safe; # } ignored\nscan_probe; }",
|
||||
'echo "$(printf "\'"; scan_probe)"',
|
||||
'echo "$(printf "%s" "$(printf ")")"; scan_probe)"',
|
||||
])("does not lose commands through delimiter or quote confusion: %s", (source) => {
|
||||
const result = ShellScan.scan(source)
|
||||
expect(result.kind).toBe("scanned")
|
||||
if (result.kind === "opaque") throw new Error(result.reason)
|
||||
expect(result.commands.map((command) => command.words[0])).toContain("scan_probe")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Bash real-shell differential grammar", () => {
|
||||
const probes = ["scan_first", "scan_second", "scan_third"]
|
||||
const words = [
|
||||
"scan_first",
|
||||
"'scan_first'",
|
||||
's"can_"first',
|
||||
"scan_first 'literal; $(not_a_command)'",
|
||||
"X=value scan_first",
|
||||
'X="$(scan_second)" scan_first',
|
||||
'scan_first "$(scan_second)"',
|
||||
"scan_first `scan_second`",
|
||||
'scan_first "$(printf "\'"; scan_second)"',
|
||||
]
|
||||
const contexts = [
|
||||
(source: string) => source,
|
||||
(source: string) => `\n\n${source}\n\n`,
|
||||
(source: string) => `${source}; scan_third`,
|
||||
(source: string) => `${source} && scan_third`,
|
||||
(source: string) => `${source} | scan_third`,
|
||||
(source: string) => `(${source})`,
|
||||
(source: string) => `{ ${source}; }`,
|
||||
(source: string) => `scan_third "$(${source})"`,
|
||||
(source: string) => `if true; then ${source}; fi`,
|
||||
(source: string) => `Y="$(${source})" scan_third`,
|
||||
(source: string) => `${source} >/dev/null`,
|
||||
]
|
||||
|
||||
for (const shell of ["bash", "zsh", Bun.which("dash") ? "dash" : "sh"]) {
|
||||
const executable = Bun.which(shell)
|
||||
const sources = (
|
||||
shell === "bash" || shell === "zsh"
|
||||
? [...words, "scan_first <(scan_second)", 'values=(value "$(scan_second)"); scan_first']
|
||||
: words
|
||||
).flatMap((source) => contexts.map((context) => context(source)))
|
||||
|
||||
test.skipIf(!executable).each(sources)(`${shell}: %s`, (source) => {
|
||||
const execution = Bun.spawnSync(
|
||||
[
|
||||
executable!,
|
||||
...(shell === "bash" ? ["--noprofile", "--norc"] : shell === "zsh" ? ["-f"] : []),
|
||||
"-c",
|
||||
probes.map((name) => `${name}() { printf '${name}\\n' >&2; }; `).join("") + source,
|
||||
],
|
||||
{ env: { PATH: "/usr/bin:/bin", LC_ALL: "C" } },
|
||||
)
|
||||
expect(execution.exitCode).toBe(0)
|
||||
const observed = execution.stderr.toString().trim().split("\n")
|
||||
expect(observed.length).toBeGreaterThan(0)
|
||||
expect(observed.every((name) => probes.includes(name))).toBe(true)
|
||||
const result = ShellScan.scan(source)
|
||||
expect(result.kind).toBe("scanned")
|
||||
if (result.kind === "opaque") throw new Error(result.reason)
|
||||
for (const name of observed) expect(result.commands.map((command) => command.words[0])).toContain(name)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe("Bash real-shell redirect-only statements", () => {
|
||||
for (const shell of ["bash", "zsh"]) {
|
||||
const executable = Bun.which(shell)
|
||||
test
|
||||
.skipIf(!executable)
|
||||
.each([
|
||||
"VALUE=ok >/dev/null; scan_probe",
|
||||
">/dev/null; scan_probe",
|
||||
"(scan_probe) >/dev/null",
|
||||
"{ scan_probe; } >/dev/null",
|
||||
"{ scan_probe; } >$(scan_target)",
|
||||
"VALUE=ok >$(scan_target); scan_probe",
|
||||
])(`${shell} preserves commands around redirect-only syntax: %s`, (source) => {
|
||||
const execution = Bun.spawnSync(
|
||||
[
|
||||
executable!,
|
||||
...(shell === "bash" ? ["--noprofile", "--norc"] : ["-f"]),
|
||||
"-c",
|
||||
`scan_probe() { printf 'scan_probe\\n' >&2; }; scan_target() { printf 'scan_target\\n' >&2; printf /dev/null; }; ${source}`,
|
||||
],
|
||||
{ env: { PATH: "/usr/bin:/bin", LC_ALL: "C" } },
|
||||
)
|
||||
expect(execution.exitCode).toBe(0)
|
||||
expect(execution.stdout.toString()).toBe("")
|
||||
const observed = execution.stderr.toString().trim().split("\n").sort()
|
||||
expect(observed).toEqual(source.includes("scan_target") ? ["scan_probe", "scan_target"] : ["scan_probe"])
|
||||
const result = ShellScan.scan(source)
|
||||
expect(result.kind).toBe("scanned")
|
||||
if (result.kind === "opaque") return
|
||||
expect(result.commands.map((command) => command.words[0]).sort()).toEqual(observed)
|
||||
})
|
||||
}
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user