mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-24 17:41:37 +08:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 83975d39c2 | |||
| 363d2b7a1c | |||
| 54e043ca39 | |||
| 7d19d78601 | |||
| ed0a8e4256 | |||
| fdd8abc9df |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Include both paths of renamed files in new snapshot change lists so undo restores the original file instead of only deleting the renamed file.
|
||||
@@ -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.
|
||||
+16
-35
@@ -81,33 +81,6 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Find affected verification checks
|
||||
id: checks
|
||||
env:
|
||||
TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && format('{0}^1', github.sha) || github.event.before }}
|
||||
TURBO_SCM_HEAD: ${{ github.sha }}
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
for package in cli codemode sdk; do
|
||||
echo "$package=true" >> "$GITHUB_OUTPUT"
|
||||
done
|
||||
exit 0
|
||||
fi
|
||||
bun turbo ls --affected --output=json > affected.json
|
||||
bun -e '
|
||||
const result = await Bun.file("affected.json").json()
|
||||
const packages = new Set(result.packages.items.map((item) => item.name))
|
||||
const affected = (name) => packages.has(`@opencode-ai/${name}`)
|
||||
const checks = {
|
||||
// CLI builds embed the web app outside the workspace dependency graph.
|
||||
cli: affected("cli") || affected("app"),
|
||||
// Codemode publishing imports the shared script package directly.
|
||||
codemode: affected("codemode") || affected("script"),
|
||||
sdk: affected("sdk"),
|
||||
}
|
||||
for (const [name, enabled] of Object.entries(checks)) console.log(`${name}=${enabled}`)
|
||||
' >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Test Effect simplification rules
|
||||
if: runner.os == 'Linux'
|
||||
run: bun run test:effect-simplification-rules
|
||||
@@ -150,17 +123,25 @@ jobs:
|
||||
TURBO_SCM_HEAD: ${{ github.sha }}
|
||||
|
||||
- name: Verify published codemode package
|
||||
if: runner.os == 'Linux' && steps.checks.outputs.codemode == 'true'
|
||||
if: runner.os == 'Linux'
|
||||
working-directory: packages/codemode
|
||||
run: bun run script/publish.ts --dry-run
|
||||
|
||||
- name: Verify packed workerd SDK
|
||||
if: runner.os == 'Linux' && steps.checks.outputs.sdk == 'true'
|
||||
if: runner.os == 'Linux'
|
||||
timeout-minutes: 15
|
||||
run: bun turbo verify:package --filter=@opencode-ai/sdk
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
bun turbo verify:package --filter=@opencode-ai/sdk
|
||||
exit 0
|
||||
fi
|
||||
bun turbo verify:package --affected --filter=@opencode-ai/sdk
|
||||
env:
|
||||
TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && format('{0}^1', github.sha) || github.event.before }}
|
||||
TURBO_SCM_HEAD: ${{ github.sha }}
|
||||
|
||||
- name: Verify compiled service lifecycle
|
||||
if: steps.checks.outputs.cli == 'true'
|
||||
if: always()
|
||||
timeout-minutes: 10
|
||||
working-directory: packages/cli
|
||||
env:
|
||||
@@ -170,13 +151,13 @@ jobs:
|
||||
bun run script/service-smoke.ts
|
||||
|
||||
- name: Setup Node build runtime
|
||||
if: steps.checks.outputs.cli == 'true'
|
||||
if: always()
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "26.4.0"
|
||||
|
||||
- name: Verify Node build
|
||||
if: steps.checks.outputs.cli == 'true'
|
||||
if: always()
|
||||
timeout-minutes: 15
|
||||
working-directory: packages/cli
|
||||
env:
|
||||
@@ -256,9 +237,9 @@ jobs:
|
||||
working-directory: packages/app
|
||||
run: bunx playwright install chromium
|
||||
|
||||
- name: Run app e2e tests against production build
|
||||
- name: Run app e2e tests
|
||||
if: env.E2E_ENABLED == 'true'
|
||||
run: bun --cwd packages/app test:e2e:built
|
||||
run: bun --cwd packages/app test:e2e:local
|
||||
env:
|
||||
CI: true
|
||||
timeout-minutes: 30
|
||||
|
||||
@@ -125,7 +125,7 @@
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/pty": "0.1.12",
|
||||
"@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.12",
|
||||
"@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",
|
||||
@@ -893,7 +879,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 +2150,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 +2158,19 @@
|
||||
|
||||
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
|
||||
|
||||
"@opencode-ai/pty": ["@opencode-ai/pty@0.1.12", "", { "optionalDependencies": { "@opencode-ai/pty-darwin-arm64": "0.1.12", "@opencode-ai/pty-darwin-x64": "0.1.12", "@opencode-ai/pty-linux-arm64-gnu": "0.1.12", "@opencode-ai/pty-linux-arm64-musl": "0.1.12", "@opencode-ai/pty-linux-x64-gnu": "0.1.12", "@opencode-ai/pty-linux-x64-musl": "0.1.12" }, "bin": { "opencode-pty": "bin/opencode-pty.js" } }, "sha512-dl4FyJUhTXThsWYY8txG/8/nwN7dE0M5Sic9r4L9f2pvtJnbR5zrCrPoiPIBIxZle1wVks1dhz4z/CfqLf5sCg=="],
|
||||
"@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.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tMvoriq3VegVlj1uEglc6qE0M7VXy61nyf9Si7tTO7xa8JiyxuFJSXOZ1pGeErDu+pe24hvTyVOR+gkdew8w9g=="],
|
||||
"@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.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-Sn5vMLL5giHOhx7J5H6zwDp4YjjXorY+QV0IEYY+SCT4wQfRBliokIyj23pRl6P2RK3u9bDLXJHDNMfDVZ2Rxg=="],
|
||||
"@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.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-HbnlKZy052l7G527wK0+05EXaUpZ4ykVAmNBEzqWCoi4TeQj2+Nr9kJ9trx9o1KrVcT4Ki58CCvN5QOls6Z0yQ=="],
|
||||
"@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.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-2nTN7ggu1h9XgjNcoQMYjP5sirfYnAskpdFCOqjokLqhytX/IMMmkRTQs+foaEaPz0dAIQD3DQplR2jZIgxp1w=="],
|
||||
"@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.12", "", { "os": "linux", "cpu": "x64" }, "sha512-FnD5ndnObTQKAoaVvxLKi5W+r3/+dsaMsobz6uK0B9hlmffXxY5CQ6HQyUU/h3aIKLWXxho5XYkA2b9yrp8/gA=="],
|
||||
"@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.12", "", { "os": "linux", "cpu": "x64" }, "sha512-prkrNu6uvjqoffxdGiDHSU5C0Y+kCSfv+lslu7dfRPgPKenVELNpRTAbOduyrWPac2vGt8j5NM61icJyodbJmA=="],
|
||||
"@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-2IiQK8EPwoDber8epX9NlZhaQwvDyN17nTMSPEjtk4s=",
|
||||
"aarch64-linux": "sha256-/ggedGSQ2RWbhX/CahTpLz1TLqlf+hu95u1ys9F/CdI=",
|
||||
"aarch64-darwin": "sha256-RaEiXwQEfpFiehG4m3IMulopP1b54PtrAECY+e2aJ6Q=",
|
||||
"x86_64-darwin": "sha256-MANNi8GyHkvJzU7Xw6FQJHsx3fBCFJfGqlSOY9zez7k="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
+4
-28
@@ -31,7 +31,7 @@ Your app is ready to be deployed!
|
||||
|
||||
## E2E Testing
|
||||
|
||||
Locally, Playwright starts the Vite dev server automatically via `webServer`, or reuses one already running at the configured address. The browser suite uses isolated API fixtures rather than a live opencode backend.
|
||||
Playwright starts the Vite dev server automatically via `webServer`, and UI tests expect an opencode backend at `localhost:4096` by default.
|
||||
|
||||
```bash
|
||||
bunx playwright install chromium
|
||||
@@ -39,35 +39,11 @@ bun run test:e2e:local
|
||||
bun run test:e2e:local -- --grep "settings"
|
||||
```
|
||||
|
||||
CI builds the app once and runs the same suite against Vite preview, serving production assets from `dist`. Managed built runs never reuse an existing server, so a running dev server cannot silently replace the production build. To run this mode locally:
|
||||
|
||||
```bash
|
||||
bun run test:e2e:built
|
||||
bun run test:e2e:built -- --grep "settings"
|
||||
```
|
||||
|
||||
To test an already-running dev server without starting or building a server:
|
||||
|
||||
```bash
|
||||
PLAYWRIGHT_BASE_URL=http://127.0.0.1:4444 bun run test:e2e
|
||||
```
|
||||
|
||||
For an already-running production build, also set `PLAYWRIGHT_BUILD=1` so the fixture API uses the app's origin:
|
||||
|
||||
```bash
|
||||
PLAYWRIGHT_BUILD=1 PLAYWRIGHT_BASE_URL=http://127.0.0.1:4444 bun run test:e2e
|
||||
```
|
||||
|
||||
External targets must use HTTP because fixture URLs use HTTP. `PLAYWRIGHT_BASE_URL` skips server startup and building in either mode.
|
||||
|
||||
Compiled CLI startup and service lifecycle coverage runs separately in CI via `packages/cli/script/service-smoke.ts`.
|
||||
|
||||
Environment options:
|
||||
|
||||
- `PLAYWRIGHT_BUILD=1` (build and preview locally; always enabled when `CI` is set)
|
||||
- `PLAYWRIGHT_SERVER_HOST` / `PLAYWRIGHT_SERVER_PORT` (dev fixture API address, default: `127.0.0.1:4096`; built runs use the app's origin, matching production)
|
||||
- `PLAYWRIGHT_PORT` (managed dev or preview server port, default: `3000`)
|
||||
- `PLAYWRIGHT_BASE_URL` (use an externally managed app instead of starting a server; otherwise defaults to `http://127.0.0.1:<PLAYWRIGHT_PORT>`)
|
||||
- `PLAYWRIGHT_SERVER_HOST` / `PLAYWRIGHT_SERVER_PORT` (backend address, default: `localhost:4096`)
|
||||
- `PLAYWRIGHT_PORT` (Vite dev server port, default: `3000`)
|
||||
- `PLAYWRIGHT_BASE_URL` (override base URL, default: `http://localhost:<PLAYWRIGHT_PORT>`)
|
||||
|
||||
## Deployment
|
||||
|
||||
|
||||
@@ -44,7 +44,6 @@ The suite contains:
|
||||
- single-session tab close timing through stable home restoration
|
||||
- cached session repaint and mutation tracing
|
||||
- streaming timeline throughput, RAF-gap, long-task, geometry, and remount diagnostics
|
||||
- retained renderer heap with a large model catalog across repeated session navigation
|
||||
|
||||
All benchmarks import the shared `benchmark` fixture. Pages created through Playwright's `page` fixture automatically capture main-frame navigation history and emit a Chrome trace when `OPENCODE_PERFORMANCE_TRACE_DIR` is set. Benchmarks that need isolated browser contexts use `withBenchmarkPage`, which owns the context and the same diagnostics lifecycle.
|
||||
|
||||
@@ -79,17 +78,6 @@ Benchmarks do not assert machine-dependent performance budgets. Streaming proces
|
||||
|
||||
Committed smoke and regression tests continue to own correctness coverage for pagination, tab paint, context resize, collapse state, and composer spacing.
|
||||
|
||||
## Retained renderer memory
|
||||
|
||||
Run the catalog workload against the production app bundle:
|
||||
|
||||
```sh
|
||||
bunx playwright test --config e2e/performance/playwright.config.ts \
|
||||
timeline/provider-memory-benchmark.spec.ts --repeat-each=3
|
||||
```
|
||||
|
||||
`PROVIDER_MEMORY_MODELS` defaults to 1,200 and `PROVIDER_MEMORY_SWITCHES` defaults to 10. Each sample records Chromium's `Runtime.getHeapUsage` and `Memory.getDOMCounters` after an explicit garbage collection. This measures retained state, not allocation peaks or normal GC timing. It does not include worker heaps, the Electron main/GPU processes, or the OpenCode server, and must not be reported as total desktop RAM. Use identical model counts and navigation sequences for before/after comparisons.
|
||||
|
||||
## Chrome traces
|
||||
|
||||
Set `OPENCODE_PERFORMANCE_TRACE_DIR` to emit a standard Chrome DevTools trace for every benchmark page automatically:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import config from "../../playwright.config"
|
||||
|
||||
const port = Number(process.env.PLAYWRIGHT_PORT ?? 3000)
|
||||
process.env.PLAYWRIGHT_SERVER_PORT = new URL(process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${port}`).port || "80"
|
||||
process.env.PLAYWRIGHT_SERVER_PORT = String(port)
|
||||
process.env.OPENCODE_PERFORMANCE_RUN_ID ??= `${new Date().toISOString().replace(/[:.]/g, "-")}-${process.pid}`
|
||||
|
||||
export default {
|
||||
@@ -12,11 +12,9 @@ export default {
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
reporter: [["html", { outputFolder: "../playwright-report/performance", open: "never" }], ["line"]],
|
||||
webServer: config.webServer
|
||||
? {
|
||||
...config.webServer,
|
||||
command: `bun run build && bun run serve -- --host 0.0.0.0 --port ${port} --strictPort`,
|
||||
reuseExistingServer: false,
|
||||
}
|
||||
: undefined,
|
||||
webServer: {
|
||||
...config.webServer,
|
||||
command: `bun run build && bun run serve -- --host 0.0.0.0 --port ${port} --strictPort`,
|
||||
reuseExistingServer: false,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { benchmark, expect } from "../benchmark"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
import { expectSessionTitle } from "../../utils/waits"
|
||||
import { fixture, pageMessages } from "./session-timeline-stress.fixture"
|
||||
import { installStressSessionTabs, installTimelineSettings, stressSessionHref } from "./timeline-test-helpers"
|
||||
import { waitForStableTimeline } from "./session-tab-switch-probe"
|
||||
import type { CatalogUpdated } from "@opencode-ai/client/promise"
|
||||
|
||||
benchmark("measures retained renderer memory with a large model catalog", async ({ page, report }) => {
|
||||
benchmark.setTimeout(120_000)
|
||||
const count = Number(process.env.PROVIDER_MEMORY_MODELS ?? 1200)
|
||||
const switches = Number(process.env.PROVIDER_MEMORY_SWITCHES ?? 10)
|
||||
const provider = fixture.provider.all[0]
|
||||
const selected = { ...provider.models["claude-opus-4-6"] }
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
sessions: fixture.sessions,
|
||||
pageMessages,
|
||||
provider: {
|
||||
...fixture.provider,
|
||||
all: [
|
||||
{
|
||||
...provider,
|
||||
models: {
|
||||
[selected.id]: selected,
|
||||
...Object.fromEntries(
|
||||
Array.from({ length: count - 1 }, (_, index) => {
|
||||
const id = `catalog-model-${index}`
|
||||
return [
|
||||
id,
|
||||
{
|
||||
id,
|
||||
name: `Catalog model ${index}`,
|
||||
cost: { input: 1, output: 2 },
|
||||
limit: { context: 200_000, output: 8192 },
|
||||
variants: { high: { reasoningEffort: "high" } },
|
||||
},
|
||||
]
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
await installTimelineSettings(page)
|
||||
await installStressSessionTabs(page)
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
|
||||
await expect(page.locator('[data-action="composer-model"]')).toContainText("Claude Opus 4.6")
|
||||
const cdp = await page.context().newCDPSession(page)
|
||||
const samples = []
|
||||
for (let index = 0; index <= switches; index++) {
|
||||
if (index > 0) {
|
||||
const target = index % 2 === 1
|
||||
const id = target ? fixture.targetID : fixture.sourceID
|
||||
await page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(id)}"]`).click()
|
||||
await expectSessionTitle(page, target ? fixture.expected.targetTitle : fixture.expected.sourceTitle)
|
||||
await waitForStableTimeline(
|
||||
page,
|
||||
(target ? fixture.expected.targetMessageIDs : fixture.expected.sourceMessageIDs).at(-1)!,
|
||||
)
|
||||
await expect(page.locator('[data-action="composer-model"]')).toContainText("Claude Opus 4.6")
|
||||
}
|
||||
// GC is an explicit retained-heap measurement, not an application optimization or readiness wait.
|
||||
await cdp.send("HeapProfiler.collectGarbage")
|
||||
samples.push({
|
||||
switches: index,
|
||||
heap: await cdp.send("Runtime.getHeapUsage"),
|
||||
dom: await cdp.send("Memory.getDOMCounters"),
|
||||
})
|
||||
}
|
||||
expect(samples).toHaveLength(switches + 1)
|
||||
expect(samples.every((sample) => sample.heap.usedSize > 0)).toBe(true)
|
||||
selected.name = "Updated catalog model"
|
||||
await page.evaluate(
|
||||
(event) => {
|
||||
const host = window as Window & { __mockServerStream?: { push: (events: CatalogUpdated[]) => void } }
|
||||
if (!host.__mockServerStream) throw new Error("Missing fixture event stream")
|
||||
host.__mockServerStream.push([event])
|
||||
},
|
||||
{
|
||||
id: "evt_catalog_refresh",
|
||||
created: Date.now(),
|
||||
type: "catalog.updated",
|
||||
location: { directory: fixture.directory },
|
||||
data: {},
|
||||
} satisfies CatalogUpdated,
|
||||
)
|
||||
await expect(page.locator('[data-action="composer-model"]')).toContainText(selected.name)
|
||||
report(
|
||||
{ samples },
|
||||
{ models: count, switches, gc: "explicit", scope: "renderer main isolate; not total desktop RAM" },
|
||||
)
|
||||
await cdp.detach()
|
||||
})
|
||||
@@ -3,7 +3,7 @@ import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { currentSession } from "../utils/mock-server"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
|
||||
const serverA = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const serverA = "http://127.0.0.1:4096"
|
||||
const serverB = "http://127.0.0.1:4097"
|
||||
const sessionA = session("ses_server_a", "C:/server-a", "Server A session")
|
||||
const sessionB = session("ses_server_b", "/home/server-b", "Server B session")
|
||||
@@ -12,17 +12,17 @@ test("closing the active server's last tab opens the remaining server tab", asyn
|
||||
const requests: string[] = []
|
||||
await mockServers(page, requests)
|
||||
await page.addInitScript(
|
||||
({ serverA, serverB, sessionA, sessionB }) => {
|
||||
({ serverB, sessionA, sessionB }) => {
|
||||
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([
|
||||
{ type: "session", server: serverA, sessionId: sessionA },
|
||||
{ type: "session", server: "http://127.0.0.1:4096", sessionId: sessionA },
|
||||
{ type: "session", server: serverB, sessionId: sessionB },
|
||||
]),
|
||||
)
|
||||
},
|
||||
{ serverA, serverB, sessionA: sessionA.id, sessionB: sessionB.id },
|
||||
{ serverB, sessionA: sessionA.id, sessionB: sessionB.id },
|
||||
)
|
||||
|
||||
const hrefA = `/server/${base64Encode(serverA)}/session/${sessionA.id}`
|
||||
@@ -55,7 +55,7 @@ function session(id: string, directory: string, title: string) {
|
||||
async function mockServers(page: Page, requests: string[]) {
|
||||
await installSseTransport(page, { server: serverA })
|
||||
await installSseTransport(page, { server: serverB })
|
||||
await page.route("**/api/**", async (route) => {
|
||||
await page.route("**/*", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
|
||||
requests.push(url.toString())
|
||||
|
||||
@@ -3,7 +3,7 @@ import { expect, test, type Page, type Route } from "@playwright/test"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
import { currentSession } from "../utils/mock-server"
|
||||
|
||||
const serverA = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const serverA = `http://127.0.0.1:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const serverB = "http://127.0.0.1:4097"
|
||||
const directoryA = "C:/server-a"
|
||||
const directoryB = "/home/server-b"
|
||||
@@ -26,13 +26,17 @@ test("session settings use the remote server context", async ({ page }) => {
|
||||
await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
|
||||
const sessionHeading = page.getByRole("heading", { name: sessionB.title, exact: true, includeHidden: true })
|
||||
await expect(sessionHeading).toBeVisible()
|
||||
const activeTabs = page.locator('[data-titlebar-tab-slot][data-active="true"]')
|
||||
await expect(activeTabs).toHaveCount(1)
|
||||
await page.keyboard.press("Control+,")
|
||||
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await expect(settings).toBeVisible()
|
||||
await expect(activeTabs).toHaveCount(0)
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0)
|
||||
await expect(settings.getByRole("tablist")).toHaveCSS("width", "328px")
|
||||
await expect(sessionHeading).toBeAttached()
|
||||
await expect(page).toHaveURL((url) => url.pathname === "/settings" && url.searchParams.get("server") === serverB)
|
||||
await expect(sessionHeading).not.toBeAttached()
|
||||
await expect(sessionHeading).toBeHidden()
|
||||
const autoAccept = settings.locator('[data-action="settings-auto-accept-permissions"]')
|
||||
const input = autoAccept.getByRole("switch")
|
||||
@@ -62,11 +66,205 @@ test("session settings use the remote server context", async ({ page }) => {
|
||||
])
|
||||
|
||||
await settings.getByRole("tab", { name: "Models" }).click()
|
||||
await expect(page).toHaveURL((url) => url.pathname === "/settings" && url.searchParams.get("tab") === "models")
|
||||
await expect(settings.getByRole("switch", { name: "Server B Model" })).toBeEnabled()
|
||||
await expect(settings.getByRole("switch", { name: "Server A Model" })).toHaveCount(0)
|
||||
await page.reload()
|
||||
await expect(settings.getByRole("tab", { name: "Models" })).toHaveAttribute("aria-selected", "true")
|
||||
await expect(settings.getByRole("switch", { name: "Server B Model" })).toBeEnabled()
|
||||
await expect(page).toHaveURL((url) => url.searchParams.get("directory") === directoryB)
|
||||
await expect(activeTabs).toHaveCount(0)
|
||||
await settings.getByRole("button", { name: "Back to app" }).click()
|
||||
await expect(settings).toBeHidden()
|
||||
await expect(sessionHeading).toBeVisible()
|
||||
await expect(activeTabs).toHaveCount(1)
|
||||
await page.goForward()
|
||||
await expect(settings.getByRole("tab", { name: "Models" })).toHaveAttribute("aria-selected", "true")
|
||||
await expect(activeTabs).toHaveCount(0)
|
||||
await page.goBack()
|
||||
await expect(sessionHeading).toBeVisible()
|
||||
await page.keyboard.press("Control+,")
|
||||
await expect(settings).toBeVisible()
|
||||
await page.locator(`[data-titlebar-tab-slot]:has(a[href$="/session/${sessionB.id}"])`).click()
|
||||
await expect(settings).toBeHidden()
|
||||
await expect(sessionHeading).toBeVisible()
|
||||
await expect(activeTabs).toHaveCount(1)
|
||||
})
|
||||
|
||||
test("settings returns to a vertical draft tab without losing its prompt", async ({ page }) => {
|
||||
await installSseTransport(page, { server: serverA })
|
||||
await installSseTransport(page, { server: serverB })
|
||||
await mockServers(page, [])
|
||||
await configureServers(page)
|
||||
await page.addInitScript(
|
||||
({ server, directory }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ appearance: { tabLayout: "vertical" } }))
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "draft", draftID: "settings-draft", server, directory }]),
|
||||
)
|
||||
},
|
||||
{ server: serverB, directory: directoryB },
|
||||
)
|
||||
await page.goto("/new-session?draftId=settings-draft")
|
||||
const editor = page.locator('[data-component="composer-editor"][contenteditable="true"]')
|
||||
await expect(editor).toBeEditable()
|
||||
await editor.fill("Keep this draft while visiting settings")
|
||||
const activeTabs = page.locator('[data-slot="vertical-tabs-sidebar"] [data-titlebar-tab-slot][data-active="true"]')
|
||||
await expect(activeTabs).toHaveCount(1)
|
||||
await page.keyboard.press("Control+,")
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await expect(settings).toBeFocused()
|
||||
await expect(activeTabs).toHaveCount(0)
|
||||
await expect(page).toHaveURL(
|
||||
(url) =>
|
||||
url.pathname === "/settings" &&
|
||||
url.searchParams.get("server") === serverB &&
|
||||
url.searchParams.get("directory") === directoryB,
|
||||
)
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(page).toHaveURL(/\/new-session\?draftId=settings-draft$/)
|
||||
await expect(editor).toHaveText("Keep this draft while visiting settings")
|
||||
await expect(activeTabs).toHaveCount(1)
|
||||
})
|
||||
|
||||
test("direct settings entry works on mobile and returns home", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 720 })
|
||||
await installSseTransport(page, { server: serverA })
|
||||
await installSseTransport(page, { server: serverB })
|
||||
await mockServers(page, [])
|
||||
await configureServers(page, [{ type: "session", server: serverB, sessionId: sessionB.id }])
|
||||
await page.goto("/settings?tab=unknown")
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await expect(settings).toBeFocused()
|
||||
await expect(settings.getByRole("tab", { name: "Preferences" })).toHaveAttribute("aria-selected", "true")
|
||||
await expect(page.locator('[data-titlebar-tab-slot][data-active="true"]')).toHaveCount(0)
|
||||
await settings.getByRole("button", { name: "Back to app" }).click()
|
||||
await expect(page).toHaveURL((url) => url.pathname === "/")
|
||||
await expect(settings).not.toBeAttached()
|
||||
})
|
||||
|
||||
test("closing the previous session tab does not leave settings", async ({ page }) => {
|
||||
await installSseTransport(page, { server: serverA })
|
||||
await installSseTransport(page, { server: serverB })
|
||||
await mockServers(page, [])
|
||||
await configureServers(page)
|
||||
await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
|
||||
await expect(page.getByRole("heading", { name: sessionB.title, exact: true })).toBeVisible()
|
||||
await page.keyboard.press("Control+,")
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await expect(settings).toBeFocused()
|
||||
const tab = page.locator(`[data-titlebar-tab-slot]:has(a[href$="/session/${sessionB.id}"])`)
|
||||
await tab.hover()
|
||||
await tab.locator('[data-slot="tab-close"]').click()
|
||||
await expect(tab).not.toBeAttached()
|
||||
await expect(settings).toBeVisible()
|
||||
await expect(page).toHaveURL((url) => url.pathname === "/settings")
|
||||
})
|
||||
|
||||
test("new tabs opened from settings keep the originating remote directory and model", async ({ page }) => {
|
||||
await installSseTransport(page, { server: serverA })
|
||||
await installSseTransport(page, { server: serverB })
|
||||
await mockServers(page, [])
|
||||
await configureServers(page)
|
||||
await page.route(`${serverB}/api/model**`, (route) => {
|
||||
if (new URL(route.request().url()).pathname !== "/api/model") return route.fallback()
|
||||
return json(route, {
|
||||
location: { directory: directoryB },
|
||||
data: [model(true), { ...model(true), id: "alternate", modelID: "alternate", name: "Alternate B Model" }],
|
||||
})
|
||||
})
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:model",
|
||||
JSON.stringify({
|
||||
user: [{ providerID: "server-b", modelID: "alternate", visibility: "show" }],
|
||||
}),
|
||||
)
|
||||
})
|
||||
await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
|
||||
await expect(page.getByRole("heading", { name: sessionB.title, exact: true })).toBeVisible()
|
||||
const modelControl = page.locator('[data-action="composer-model"]')
|
||||
await modelControl.click()
|
||||
await page.getByRole("menuitemradio", { name: "Alternate B Model", exact: true }).click()
|
||||
await expect(modelControl).toContainText("Alternate B Model")
|
||||
await page.keyboard.press("Control+,")
|
||||
await expect(page.getByTestId("settings-screen")).toBeFocused()
|
||||
await page.locator('[data-slot="titlebar-v2"]').getByRole("button", { name: "New session", exact: true }).click()
|
||||
await expect(page).toHaveURL((url) => url.pathname === "/new-session" && !!url.searchParams.get("draftId"))
|
||||
await expect(page.locator('[data-component="composer-editor"][contenteditable="true"]')).toBeEditable()
|
||||
await expect(modelControl).toContainText("Alternate B Model")
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
const draftID = new URLSearchParams(location.search).get("draftId")
|
||||
const tabs = JSON.parse(localStorage.getItem("opencode.window.browser.dat:tabs") ?? "[]")
|
||||
return tabs.find((tab: { draftID?: string }) => tab.draftID === draftID)
|
||||
}),
|
||||
)
|
||||
.toMatchObject({ type: "draft", server: serverB, directory: directoryB })
|
||||
})
|
||||
|
||||
test("settings navigation stays aligned with app Back and Forward commands", async ({ page }) => {
|
||||
await installSseTransport(page, { server: serverA })
|
||||
await installSseTransport(page, { server: serverB })
|
||||
await mockServers(page, [])
|
||||
await configureServers(page, [{ type: "session", server: serverB, sessionId: sessionB.id }])
|
||||
await page.goto("/")
|
||||
await page.locator(`[data-titlebar-tab-slot]:has(a[href$="/session/${sessionB.id}"])`).click()
|
||||
const heading = page.getByRole("heading", { name: sessionB.title, exact: true })
|
||||
await expect(heading).toBeVisible()
|
||||
await page.keyboard.press("Control+,")
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await expect(settings).toBeFocused()
|
||||
await settings.getByRole("tab", { name: "Models" }).click()
|
||||
await expect(settings.getByRole("switch", { name: "Server B Model" })).toBeEnabled()
|
||||
await settings.getByRole("button", { name: "Back to app" }).click()
|
||||
await expect(heading).toBeVisible()
|
||||
await page.keyboard.press("Control+[")
|
||||
await expect(page).toHaveURL((url) => url.pathname === "/")
|
||||
await page.keyboard.press("Control+]")
|
||||
await expect(heading).toBeVisible()
|
||||
await page.keyboard.press("Control+]")
|
||||
await expect(settings.getByRole("tab", { name: "Models" })).toHaveAttribute("aria-selected", "true")
|
||||
await settings.getByRole("button", { name: "Back to app" }).click()
|
||||
await expect(heading).toBeVisible()
|
||||
})
|
||||
|
||||
test("new session works while draft settings are open", async ({ page }) => {
|
||||
await installSseTransport(page, { server: serverA })
|
||||
await installSseTransport(page, { server: serverB })
|
||||
await mockServers(page, [])
|
||||
await configureServers(page)
|
||||
|
||||
await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
|
||||
await expect(page.getByRole("heading", { name: sessionB.title, exact: true })).toBeVisible()
|
||||
const newSession = page.locator('[data-slot="titlebar-v2"]').getByRole("button", { name: "New session", exact: true })
|
||||
await newSession.click()
|
||||
await expect(page).toHaveURL((url) => url.pathname === "/new-session" && !!url.searchParams.get("draftId"))
|
||||
const draftURL = page.url()
|
||||
const editor = page.locator('[data-component="composer-editor"][contenteditable="true"]')
|
||||
await expect(editor).toBeEditable()
|
||||
await editor.fill("Keep the original draft")
|
||||
const tabs = page.locator("[data-titlebar-tab-slot]")
|
||||
const activeTabs = page.locator('[data-titlebar-tab-slot][data-active="true"]')
|
||||
await expect(tabs).toHaveCount(2)
|
||||
await expect(activeTabs).toHaveCount(1)
|
||||
await page.keyboard.press("Control+,")
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await expect(settings).toBeVisible()
|
||||
await expect(activeTabs).toHaveCount(0)
|
||||
|
||||
await newSession.click()
|
||||
await expect(page).toHaveURL(
|
||||
(url) => url.pathname === "/new-session" && !!url.searchParams.get("draftId") && url.href !== draftURL,
|
||||
)
|
||||
await expect(settings).toBeHidden()
|
||||
await expect(tabs).toHaveCount(3)
|
||||
await expect(activeTabs).toHaveCount(1)
|
||||
await expect(activeTabs.locator("a")).toHaveAttribute("href", `/new-session${new URL(page.url()).search}`)
|
||||
await expect(editor).toBeEditable()
|
||||
await expect(editor).toBeEmpty()
|
||||
})
|
||||
|
||||
test("auto-accept responds for an unfocused server session", async ({ page }) => {
|
||||
@@ -308,7 +506,7 @@ async function mockServers(
|
||||
permissionResponses: PermissionResponse[] = [],
|
||||
options: MockServerOptions = {},
|
||||
) {
|
||||
await page.route("**/api/**", async (route) => {
|
||||
await page.route("**/*", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
|
||||
const remote = url.origin === serverB
|
||||
@@ -385,9 +583,11 @@ async function mockServers(
|
||||
return json(route, { data: [], cursor: {} })
|
||||
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/inbox`))
|
||||
return json(route, { data: [] })
|
||||
if (url.pathname === "/api/location") return json(route, { directory })
|
||||
if (url.pathname === "/api/location")
|
||||
return json(route, { directory, project: { id: remote ? sessionB.projectID : "project-server-a" } })
|
||||
if (url.pathname === "/api/vcs")
|
||||
return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } })
|
||||
return json(route, { location: { directory }, data: { branch: { current: "main", default: "main" } } })
|
||||
if (url.pathname === "/api/vcs/branches") return json(route, { location: { directory }, data: ["main"] })
|
||||
if (url.pathname === "/api/pty/shells") return json(route, { location: { directory }, data: [] })
|
||||
return json(route, {})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import { expect, test, type Page, type Route } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { currentSession } from "../utils/mock-server"
|
||||
|
||||
const serverA = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const serverA = "http://127.0.0.1:4096"
|
||||
const serverB = "http://127.0.0.1:4097"
|
||||
const sessionA = session("ses_server_a", "C:/server-a", "Server A session")
|
||||
const sessionB = session("ses_server_b", "/home/server-b", "Server B session")
|
||||
@@ -51,7 +51,7 @@ function session(id: string, directory: string, title: string) {
|
||||
}
|
||||
|
||||
async function mockServers(page: Page) {
|
||||
await page.route("**/api/**", async (route) => {
|
||||
await page.route("**/*", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
|
||||
const current = url.origin === serverA ? sessionA : sessionB
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const directory = "/settings-history"
|
||||
const draft = "/new-session?draftId=settings-history"
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: "proj_settings_history",
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "settings-history",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ server, directory }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "draft", draftID: "settings-history", server, directory }]),
|
||||
)
|
||||
},
|
||||
{ server, directory },
|
||||
)
|
||||
})
|
||||
|
||||
test("settings close, app commands, and browser traversal share one history", async ({ page }) => {
|
||||
await page.goto("/")
|
||||
await page.locator(`[data-titlebar-tab-slot] a[href="${draft}"]`).click()
|
||||
const editor = page.locator('[data-component="composer-editor"][contenteditable="true"]')
|
||||
await expect(editor).toBeEditable()
|
||||
await page.keyboard.press("Control+,")
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await expect(settings).toBeFocused()
|
||||
await settings.getByRole("tab", { name: "Appearance", exact: true }).click()
|
||||
|
||||
await page.keyboard.press("Control+[")
|
||||
await expect(editor).toBeEditable()
|
||||
await expect(page).toHaveURL((url) => `${url.pathname}${url.search}` === draft)
|
||||
await page.goForward()
|
||||
await expect(settings.getByRole("tab", { name: "Appearance", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(editor).toBeEditable()
|
||||
await page.keyboard.press("Control+]")
|
||||
await expect(settings).toBeFocused()
|
||||
await expect(settings.getByRole("tab", { name: "Appearance", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
await settings.getByRole("button", { name: "Back to app" }).click()
|
||||
await expect(editor).toBeEditable()
|
||||
|
||||
await page.keyboard.press("Control+[")
|
||||
await expect(page).toHaveURL((url) => url.pathname === "/")
|
||||
await expect(settings).not.toBeAttached()
|
||||
await page.goForward()
|
||||
await expect(editor).toBeEditable()
|
||||
await page.goForward()
|
||||
await expect(settings).toBeFocused()
|
||||
await page.goBack()
|
||||
await expect(editor).toBeEditable()
|
||||
await page.keyboard.press("Control+]")
|
||||
await expect(settings).toBeFocused()
|
||||
})
|
||||
|
||||
test("direct settings entry and reload fall back home without leaving the app", async ({ page }) => {
|
||||
await page.goto("/settings?tab=appearance")
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await expect(settings).toBeFocused()
|
||||
await page.reload()
|
||||
await expect(settings).toBeFocused()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(page).toHaveURL((url) => url.pathname === "/")
|
||||
await expect(settings).not.toBeAttached()
|
||||
await page.keyboard.press("Control+]")
|
||||
await expect(page).toHaveURL((url) => url.pathname === "/")
|
||||
})
|
||||
|
||||
test("reload retains known browser history but direct entry does not adopt stale history", async ({ page }) => {
|
||||
await page.goto(draft)
|
||||
const editor = page.locator('[data-component="composer-editor"][contenteditable="true"]')
|
||||
await expect(editor).toBeEditable()
|
||||
await page.keyboard.press("Control+,")
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await expect(settings).toBeFocused()
|
||||
await settings.getByRole("tab", { name: "Appearance", exact: true }).click()
|
||||
const url = page.url()
|
||||
await page.reload()
|
||||
await expect(settings).toBeFocused()
|
||||
await settings.getByRole("button", { name: "Back to app" }).click()
|
||||
await expect(editor).toBeEditable()
|
||||
await expect(page).toHaveURL((url) => `${url.pathname}${url.search}` === draft)
|
||||
await page.goForward()
|
||||
await expect(settings.getByRole("tab", { name: "Appearance", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(editor).toBeEditable()
|
||||
|
||||
await page.goto(url)
|
||||
await expect(settings).toBeFocused()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(page).toHaveURL((url) => url.pathname === "/")
|
||||
})
|
||||
@@ -2,7 +2,7 @@ import { expect, test, type Page, type Route } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { currentSession } from "../utils/mock-server"
|
||||
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const server = "http://127.0.0.1:4096"
|
||||
const sessionA = session("ses_tab_a", "Tab A session")
|
||||
const sessionB = session("ses_tab_b", "Tab B session")
|
||||
const sessionC = session("ses_tab_c", "Tab C session")
|
||||
@@ -238,7 +238,7 @@ function session(id: string, title: string) {
|
||||
|
||||
async function mockServer(page: Page) {
|
||||
const sessions = [sessionA, sessionB, sessionC]
|
||||
await page.route("**/api/**", async (route) => {
|
||||
await page.route("**/*", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.origin !== server) return route.fallback()
|
||||
if (url.pathname === `/api/session/${unresolvedSessionID}`) return new Promise(() => {})
|
||||
|
||||
@@ -144,14 +144,12 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
)
|
||||
page.on("close", () => void transport.dispose())
|
||||
|
||||
await page.route("**/api/**", async (route) => {
|
||||
await page.route("**/*", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const appPort = new URL(
|
||||
process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${process.env.PLAYWRIGHT_PORT ?? "3000"}`,
|
||||
).port
|
||||
if (url.origin !== server && url.port !== appPort) return route.fallback()
|
||||
// Production serves the UI and API from one origin; leave app assets to Vite.
|
||||
if (!url.pathname.startsWith("/api/")) return route.fallback()
|
||||
if (route.request().method() === "OPTIONS") {
|
||||
return route.fulfill({ status: 204, headers: corsHeaders })
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
"test:browser": "bun test --conditions=browser --preload ./happydom.ts ./test-browser",
|
||||
"test:unit:watch": "bun test --conditions=solid --watch --preload ./happydom.ts ./src",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:built": "PLAYWRIGHT_BUILD=1 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",
|
||||
|
||||
@@ -2,19 +2,10 @@ import { defineConfig, devices } from "@playwright/test"
|
||||
|
||||
const port = Number(process.env.PLAYWRIGHT_PORT ?? 3000)
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${port}`
|
||||
const url = new URL(baseURL)
|
||||
if (url.protocol !== "http:") throw new Error("E2E fixtures require an http:// app URL")
|
||||
const built = !!process.env.CI || process.env.PLAYWRIGHT_BUILD === "1"
|
||||
// Production connects to its own origin, so fixture URLs must match the preview server.
|
||||
if (built) {
|
||||
process.env.PLAYWRIGHT_SERVER_HOST = url.hostname
|
||||
process.env.PLAYWRIGHT_SERVER_PORT = url.port || "80"
|
||||
}
|
||||
const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"
|
||||
const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||
const command = built
|
||||
? `bun run build && bun run serve -- --host 127.0.0.1 --port ${port} --strictPort`
|
||||
: `bun run dev -- --host 127.0.0.1 --port ${port} --strictPort`
|
||||
const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
|
||||
const reuse = !process.env.CI
|
||||
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
@@ -32,18 +23,16 @@ export default defineConfig({
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers,
|
||||
reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
|
||||
webServer: process.env.PLAYWRIGHT_BASE_URL
|
||||
? undefined
|
||||
: {
|
||||
command,
|
||||
url: baseURL,
|
||||
reuseExistingServer: !built,
|
||||
timeout: 120_000,
|
||||
env: {
|
||||
VITE_OPENCODE_SERVER_HOST: serverHost,
|
||||
VITE_OPENCODE_SERVER_PORT: serverPort,
|
||||
},
|
||||
},
|
||||
webServer: {
|
||||
command,
|
||||
url: baseURL,
|
||||
reuseExistingServer: reuse,
|
||||
timeout: 120_000,
|
||||
env: {
|
||||
VITE_OPENCODE_SERVER_HOST: serverHost,
|
||||
VITE_OPENCODE_SERVER_PORT: serverPort,
|
||||
},
|
||||
},
|
||||
use: {
|
||||
baseURL,
|
||||
trace: "on-first-retry",
|
||||
|
||||
@@ -17,7 +17,7 @@ export function Home() {
|
||||
return (
|
||||
<div
|
||||
class={`
|
||||
mx-2 mb-2 mt-[var(--shell-top-inset,8px)] min-h-0 flex-1 self-stretch overflow-hidden rounded-[10px]
|
||||
m-2 min-h-0 flex-1 self-stretch overflow-hidden rounded-[10px]
|
||||
bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]
|
||||
`}
|
||||
>
|
||||
|
||||
@@ -73,7 +73,7 @@ export default function NewSessionPage(props: { draftId: string }) {
|
||||
<div class="relative size-full overflow-hidden flex flex-col">
|
||||
{suspendUntilPromptReady()}
|
||||
<NewSessionStatus visible={settings.visibility.status()} />
|
||||
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-2 pt-[var(--shell-top-inset,8px)]">
|
||||
<div class="flex-1 min-h-0 flex flex-col gap-2 p-2">
|
||||
<NewSessionView composer={model} project={project} workspace={workspace} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import type { AgentListOutput, ModelListOutput, ProviderListOutput } from "@opencode-ai/client/promise"
|
||||
import type { Agent, Project, Provider, ProviderListResponse } from "@/runtime/server/types"
|
||||
import type { Project as CurrentProject } from "@opencode-ai/client/promise"
|
||||
import { unwrap } from "solid-js/store"
|
||||
export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/workspaces/path-key"
|
||||
|
||||
export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
|
||||
const providerCatalogs = new WeakMap<
|
||||
ProviderListOutput["data"],
|
||||
WeakMap<ModelListOutput["data"], ProviderListResponse>
|
||||
>()
|
||||
|
||||
export function normalizeAgentList(input: AgentListOutput["data"] | Agent[]): Agent[] {
|
||||
if (input.every((agent) => !("request" in agent))) return input as Agent[]
|
||||
return (input as AgentListOutput["data"]).map((agent) => ({
|
||||
@@ -36,16 +30,12 @@ export function normalizeAgentList(input: AgentListOutput["data"] | Agent[]): Ag
|
||||
}
|
||||
|
||||
export function normalizeProviderList(
|
||||
input: ProviderListOutput["data"] | ProviderListResponse,
|
||||
catalog?: ModelListOutput["data"],
|
||||
providers: ProviderListOutput["data"] | ProviderListResponse,
|
||||
models?: ModelListOutput["data"],
|
||||
): ProviderListResponse {
|
||||
if (!Array.isArray(input)) return input
|
||||
// Client sync replaces whole catalog lists. Track those reads at the caller,
|
||||
// not every model field, and share conversions without retaining old lists.
|
||||
const providers = unwrap(input)
|
||||
const models = unwrap(catalog)
|
||||
const cached = models && providerCatalogs.get(providers)?.get(models)
|
||||
if (cached) return cached
|
||||
if (!Array.isArray(providers)) {
|
||||
return providers
|
||||
}
|
||||
const all = new Map<string, Provider>()
|
||||
|
||||
for (const provider of providers) {
|
||||
@@ -111,7 +101,7 @@ export function normalizeProviderList(
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
return {
|
||||
all,
|
||||
connected: providers.map((provider) => provider.id),
|
||||
default: Object.fromEntries(
|
||||
@@ -121,12 +111,6 @@ export function normalizeProviderList(
|
||||
}),
|
||||
),
|
||||
}
|
||||
if (models) {
|
||||
const cache = providerCatalogs.get(providers) ?? new WeakMap<ModelListOutput["data"], ProviderListResponse>()
|
||||
cache.set(models, result)
|
||||
providerCatalogs.set(providers, cache)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function normalizeProjectInfo(project: Project | CurrentProject): Project {
|
||||
|
||||
@@ -44,7 +44,9 @@ function TargetSessionSettingsCommand() {
|
||||
return null
|
||||
}
|
||||
|
||||
function SessionRouteErrorBoundary(props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key }>) {
|
||||
function SessionRouteErrorBoundary(
|
||||
props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key }>,
|
||||
) {
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallback={(error) => (
|
||||
@@ -104,7 +106,7 @@ function PendingSessionState(props: { sessionID: string }) {
|
||||
|
||||
function SessionStatePanel(props: ParentProps) {
|
||||
return (
|
||||
<div class="flex min-h-0 flex-1 px-2 pb-2 pt-[var(--shell-top-inset,8px)]">
|
||||
<div class="flex min-h-0 flex-1 p-2">
|
||||
<SessionPanelFrame raised>{props.children}</SessionPanelFrame>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -164,7 +164,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
return (
|
||||
<>
|
||||
<SessionHeader />
|
||||
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-2 pt-[var(--shell-top-inset,8px)]">
|
||||
<div class="flex-1 min-h-0 flex flex-col gap-2 p-2">
|
||||
<div ref={screen.panel.ref} class="relative flex-1 min-h-0 flex flex-col md:flex-row gap-2">
|
||||
<div
|
||||
classList={{
|
||||
|
||||
@@ -2,10 +2,7 @@ import type { ParentProps } from "solid-js"
|
||||
|
||||
export function SessionRouteFrame(props: ParentProps<{ padded?: boolean }>) {
|
||||
return (
|
||||
<div
|
||||
class="relative flex size-full flex-col overflow-hidden"
|
||||
classList={{ "px-2 pb-2 pt-[var(--shell-top-inset,8px)]": props.padded }}
|
||||
>
|
||||
<div class="relative flex size-full flex-col overflow-hidden" classList={{ "p-2": props.padded }}>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -481,6 +481,7 @@ function MessageTimelineView(
|
||||
const backgroundHintPresence = createAnimatedPresence(backgroundHintPartID, () => backgroundHintRef() ?? null)
|
||||
return (
|
||||
<VirtualizedTimeline
|
||||
workspaceSession={workspaceSession}
|
||||
bottomSpacer={
|
||||
<Show when={backgroundHintPresence.present()}>
|
||||
<div
|
||||
|
||||
@@ -55,6 +55,7 @@ type Input = {
|
||||
type ViewProps = {
|
||||
header: JSX.Element
|
||||
bottomSpacer?: JSX.Element
|
||||
workspaceSession: Accessor<boolean>
|
||||
deferred: (row: TimelineRow.TimelineRow) => boolean
|
||||
renderRow: (row: Accessor<TimelineRow.TimelineRow>, onSizeChange?: () => void) => JSX.Element
|
||||
}
|
||||
@@ -370,7 +371,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="relative w-full h-full min-w-0">
|
||||
<div class="relative w-full h-full min-w-0" data-workspace-session={props.workspaceSession() ? "" : undefined}>
|
||||
<div
|
||||
class="absolute left-1/2 -translate-x-1/2 z-[60] pointer-events-none transition-all duration-200 ease-out"
|
||||
classList={{
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useSettingsSurface } from "./surface"
|
||||
import { useSettingsNavigation } from "./navigation"
|
||||
|
||||
export function useSettingsDialog(defaultValue?: string) {
|
||||
const settings = useSettingsSurface()
|
||||
const settings = useSettingsNavigation()
|
||||
return () => settings.open(defaultValue)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useLocation, useNavigate } from "@solidjs/router"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { useTitlebarHistory } from "@/shell/titlebar/history-context"
|
||||
|
||||
export function useSettingsNavigation() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const layout = useLayout()
|
||||
const tabs = useTabs()
|
||||
const global = useGlobal()
|
||||
const history = useTitlebarHistory()
|
||||
|
||||
return {
|
||||
open(tab = "general") {
|
||||
const route = layout.route()
|
||||
const query = new URLSearchParams(route.type === "settings" ? location.search : "")
|
||||
query.set("tab", tab)
|
||||
if (route.type !== "settings") {
|
||||
query.set("from", `${location.pathname}${location.search}${location.hash}`)
|
||||
const draft = route.type === "draft" ? tabs.draft(route.draftID) : undefined
|
||||
const server = route.type === "session" ? route.server : (draft?.server ?? layout.home.selection().server)
|
||||
const connection = global.servers.list().find((item) => ServerConnection.key(item) === server)
|
||||
const directory =
|
||||
route.type === "session" && connection
|
||||
? global.ensureServerCtx(connection).data.session.get(route.sessionId)?.location.directory
|
||||
: draft?.directory
|
||||
query.set("server", server)
|
||||
if (directory) query.set("directory", directory)
|
||||
}
|
||||
navigate(`/settings?${query}`, {
|
||||
replace: route.type === "settings",
|
||||
})
|
||||
},
|
||||
close() {
|
||||
if (layout.route().type !== "settings") return
|
||||
history.back()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, createEffect, createMemo, createSignal, onCleanup, onMount, startTransition } from "solid-js"
|
||||
import { createEffect, createMemo, onMount } from "solid-js"
|
||||
import { useLocation, useNavigate } from "@solidjs/router"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
@@ -14,50 +15,55 @@ import { SettingsProjects } from "./workspaces/projects"
|
||||
import { SettingsExtensions } from "./providers/extensions"
|
||||
import { SettingsServerScope } from "./server-scope"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { ServerConnection, useServers } from "@/runtime/server/registry"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { useSettingsSurface } from "./surface"
|
||||
import { useSettingsNavigation } from "./navigation"
|
||||
import { useSettingsCommand } from "./command"
|
||||
import "@/settings/settings.css"
|
||||
|
||||
export const SettingsScreen: Component<{
|
||||
defaultValue?: string
|
||||
}> = (props) => {
|
||||
export function SettingsScreen() {
|
||||
const language = useLanguage()
|
||||
const dialog = useDialog()
|
||||
const command = useCommand()
|
||||
const surface = useSettingsSurface()
|
||||
const layout = useLayout()
|
||||
const navigation = useSettingsNavigation()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const servers = useServers()
|
||||
const tabs = useTabs()
|
||||
const global = useGlobal()
|
||||
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
|
||||
useSettingsCommand()
|
||||
const tab = () => {
|
||||
const value = location.query.tab
|
||||
return typeof value === "string" &&
|
||||
[
|
||||
"general",
|
||||
"appearance",
|
||||
"notifications",
|
||||
"shortcuts",
|
||||
"servers",
|
||||
"projects",
|
||||
"workspaces",
|
||||
"providers",
|
||||
"models",
|
||||
"extensions",
|
||||
].includes(value)
|
||||
? value
|
||||
: "general"
|
||||
}
|
||||
const setTab = (value: string) => {
|
||||
const query = new URLSearchParams(location.search)
|
||||
query.set("tab", value)
|
||||
navigate(`/settings?${query}`, { replace: true, scroll: false })
|
||||
}
|
||||
let root: HTMLDivElement | undefined
|
||||
|
||||
onMount(() => {
|
||||
command.keybinds(false)
|
||||
root?.focus({ preventScroll: true })
|
||||
})
|
||||
onCleanup(() => command.keybinds(true))
|
||||
|
||||
createEffect(() => setTab(props.defaultValue ?? "general"))
|
||||
|
||||
const server = createMemo(() => {
|
||||
const route = layout.route()
|
||||
switch (route.type) {
|
||||
case "draft": {
|
||||
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
|
||||
return servers.list.find((item) => ServerConnection.key(item) === draft?.server)
|
||||
}
|
||||
case "session":
|
||||
return servers.list.find((item) => ServerConnection.key(item) === route.server)
|
||||
case "home":
|
||||
return servers.list.find((item) => ServerConnection.key(item) === layout.home.selection().server)
|
||||
}
|
||||
})
|
||||
const serverCtx = useServerCtx(server)
|
||||
const server = createMemo(
|
||||
() =>
|
||||
servers.list.find((item) => ServerConnection.key(item) === location.query.server) ??
|
||||
global.settings.server.selected(),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const current = server()
|
||||
@@ -68,13 +74,8 @@ export const SettingsScreen: Component<{
|
||||
const selected = global.settings.server.selected()
|
||||
const current = server()
|
||||
if (!selected || !current || ServerConnection.key(selected) !== ServerConnection.key(current)) return
|
||||
const route = layout.route()
|
||||
if (route.type === "draft") {
|
||||
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
|
||||
return draft?.type === "draft" ? draft.directory : undefined
|
||||
}
|
||||
if (route.type === "session") return serverCtx()?.data.session.get(route.sessionId)?.location.directory
|
||||
return undefined
|
||||
if (location.query.server !== ServerConnection.key(current)) return
|
||||
return typeof location.query.directory === "string" ? location.query.directory : undefined
|
||||
})
|
||||
|
||||
const showProviders = () => {
|
||||
@@ -91,19 +92,13 @@ export const SettingsScreen: Component<{
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Escape" || event.defaultPrevented || dialog.active) return
|
||||
event.preventDefault()
|
||||
surface.close()
|
||||
navigation.close()
|
||||
}}
|
||||
>
|
||||
<Tabs
|
||||
orientation="vertical"
|
||||
variant="settings"
|
||||
value={tab()}
|
||||
onChange={(value) => void startTransition(() => setTab(value))}
|
||||
class="settings"
|
||||
>
|
||||
<Tabs orientation="vertical" variant="settings" value={tab()} onChange={setTab} class="settings">
|
||||
<Tabs.List>
|
||||
<div class="settings-nav">
|
||||
<button type="button" class="settings-back" onClick={surface.close}>
|
||||
<button type="button" class="settings-back" onClick={navigation.close}>
|
||||
<Icon name="arrow-left" size="small" class="settings-back-icon" />
|
||||
<span>{language.t("settings.backToApp")}</span>
|
||||
</button>
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { useLocation } from "@solidjs/router"
|
||||
import { createEffect, on } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
|
||||
export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = createSimpleContext({
|
||||
name: "SettingsSurface",
|
||||
gate: false,
|
||||
init: () => {
|
||||
const location = useLocation()
|
||||
const [store, setStore] = createStore({ open: false, tab: "general" })
|
||||
let focus: HTMLElement | undefined
|
||||
|
||||
const close = () => {
|
||||
if (!store.open) return
|
||||
setStore("open", false)
|
||||
if (focus?.isConnected) focus.focus({ preventScroll: true })
|
||||
focus = undefined
|
||||
}
|
||||
|
||||
createEffect(on(() => `${location.pathname}${location.search}`, close, { defer: true }))
|
||||
|
||||
return {
|
||||
store,
|
||||
open(tab = "general") {
|
||||
if (!store.open && document.activeElement instanceof HTMLElement) focus = document.activeElement
|
||||
setStore({ open: true, tab })
|
||||
},
|
||||
close,
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -6,7 +6,7 @@ import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { SessionPanelFrame, SessionRouteFrame } from "@/session/session-frame"
|
||||
import { LayoutProvider } from "@/shell/state/layout"
|
||||
import { SettingsSurfaceProvider } from "@/settings/surface"
|
||||
import { TitlebarHistoryProvider } from "@/shell/titlebar/history-context"
|
||||
import Shell from "@/shell/shell"
|
||||
import { requireServerKey } from "./session"
|
||||
|
||||
@@ -14,12 +14,14 @@ export const File = lazy(() => import("@opencode-ai/session-ui/file").then((modu
|
||||
const loadDraftRoute = () => Promise.all([import("@/new-session/route"), File.preload()]).then(([module]) => module)
|
||||
const loadSessionRoute = () => Promise.all([import("@/session/route"), File.preload()]).then(([module]) => module)
|
||||
const DraftRoute = lazy(() => loadDraftRoute().then((module) => ({ default: module.DraftRoute })))
|
||||
const SettingsRoute = lazy(() => import("@/settings/shell").then((module) => ({ default: module.SettingsScreen })))
|
||||
const TargetSessionRouteContent = lazy(() =>
|
||||
loadSessionRoute().then((module) => ({ default: module.TargetSessionRouteContent })),
|
||||
)
|
||||
|
||||
export function preloadRoute(url: string) {
|
||||
const pathname = url.split(/[?#]/, 1)[0]
|
||||
if (pathname === "/settings") return SettingsRoute.preload().then(() => undefined)
|
||||
if (pathname === "/new-session") return DraftRoute.preload().then(() => undefined)
|
||||
if (/^\/server\/[^/]+\/session\/[^/]+$/.test(pathname))
|
||||
return TargetSessionRouteContent.preload().then(() => undefined)
|
||||
@@ -30,13 +32,14 @@ export function AppRoutes() {
|
||||
return (
|
||||
<Route component={AppLayout}>
|
||||
<Route path="/" component={Home} />
|
||||
<Route path="/settings" component={SettingsRoute} />
|
||||
<Route
|
||||
path="/server/:serverKey/session/:id"
|
||||
component={() => (
|
||||
<SessionRouteFrame>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div class="flex min-h-0 flex-1 px-2 pb-2 pt-[var(--shell-top-inset,8px)]">
|
||||
<div class="flex min-h-0 flex-1 p-2">
|
||||
<SessionPanelFrame raised />
|
||||
</div>
|
||||
}
|
||||
@@ -70,9 +73,9 @@ function TargetServerRoute(props: ParentProps) {
|
||||
function AppLayout(props: ParentProps) {
|
||||
return (
|
||||
<LayoutProvider>
|
||||
<SettingsSurfaceProvider>
|
||||
<TitlebarHistoryProvider>
|
||||
<Shell>{props.children}</Shell>
|
||||
</SettingsSurfaceProvider>
|
||||
</TitlebarHistoryProvider>
|
||||
</LayoutProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,15 +6,12 @@ import { Titlebar, type TitlebarUpdate } from "@/shell/titlebar/titlebar"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { ToastRegion } from "@/shell/notifications/toast"
|
||||
import { TitlebarRightProvider } from "@/shell/titlebar/right-slot"
|
||||
import { useSettingsSurface } from "@/settings/surface"
|
||||
import { useSettings } from "@/settings/model"
|
||||
|
||||
const DebugBar = lazy(() => import("@/shell/debug/debug-bar").then((module) => ({ default: module.DebugBar })))
|
||||
const SettingsScreen = lazy(() => import("@/settings/shell").then((module) => ({ default: module.SettingsScreen })))
|
||||
|
||||
export default function Layout(props: ParentProps) {
|
||||
const platform = usePlatform()
|
||||
const settings = useSettingsSurface()
|
||||
const preferences = useSettings()
|
||||
const mobile = createMediaQuery("(max-width: 767px)")
|
||||
const [state, setState] = createStore({
|
||||
@@ -43,13 +40,6 @@ export default function Layout(props: ParentProps) {
|
||||
style={{
|
||||
"padding-top": "env(safe-area-inset-top, 0px)",
|
||||
"padding-bottom": "env(safe-area-inset-bottom, 0px)",
|
||||
// The native Windows titlebar already includes the gap above the content panels.
|
||||
"--shell-top-inset":
|
||||
platform.platform === "desktop" &&
|
||||
platform.os === "windows" &&
|
||||
!(mobile() && preferences.general.mobileTitlebarPosition() === "bottom")
|
||||
? "0px"
|
||||
: "8px",
|
||||
}}
|
||||
>
|
||||
<Titlebar
|
||||
@@ -66,7 +56,7 @@ export default function Layout(props: ParentProps) {
|
||||
<aside
|
||||
ref={(element) => setState("tabsMount", element)}
|
||||
data-slot="vertical-tabs-sidebar"
|
||||
class="relative flex min-h-0 shrink-0 flex-col bg-v2-background-bg-deep px-2.5 pb-2 pt-[var(--shell-top-inset,8px)]"
|
||||
class="relative flex min-h-0 shrink-0 flex-col bg-v2-background-bg-deep px-2.5 py-2"
|
||||
style={{ width: `${state.tabsWidth}px` }}
|
||||
>
|
||||
<ResizeHandle
|
||||
@@ -80,19 +70,9 @@ export default function Layout(props: ParentProps) {
|
||||
</aside>
|
||||
</Show>
|
||||
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
|
||||
<div
|
||||
class="flex size-full min-h-0 min-w-0 flex-col"
|
||||
hidden={settings.store.open}
|
||||
inert={settings.store.open}
|
||||
aria-hidden={settings.store.open}
|
||||
>
|
||||
<div class="flex size-full min-h-0 min-w-0 flex-col">
|
||||
<Suspense>{props.children}</Suspense>
|
||||
</div>
|
||||
<Show when={settings.store.open}>
|
||||
<Suspense>
|
||||
<SettingsScreen defaultValue={settings.store.tab} />
|
||||
</Suspense>
|
||||
</Show>
|
||||
</main>
|
||||
</div>
|
||||
<Show when={import.meta.env.DEV && state.debugTools}>
|
||||
|
||||
@@ -62,6 +62,7 @@ export type ReviewPanelSource = "context-button" | "other"
|
||||
|
||||
export type LayoutRoute =
|
||||
| { type: "home" }
|
||||
| { type: "settings" }
|
||||
| { type: "draft"; draftID: string }
|
||||
| { type: "session"; sessionId: string; server: ServerConnection.Key }
|
||||
|
||||
@@ -100,6 +101,7 @@ const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => {
|
||||
export const currentRoute = (pathname: string, search: string): LayoutRoute => {
|
||||
const parts = pathname.split("/").filter(Boolean)
|
||||
if (parts.length === 0) return { type: "home" }
|
||||
if (parts.length === 1 && parts[0] === "settings") return { type: "settings" }
|
||||
|
||||
if (parts[0] === "new-session") {
|
||||
const draftID = new URLSearchParams(search).get("draftId")
|
||||
|
||||
@@ -174,7 +174,12 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
if (!tab) return
|
||||
const key = tabKey(tab)
|
||||
const draftID = tab.type === "draft" ? tab.draftID : undefined
|
||||
const nextTab = nextTabAfterClose(store, index, recentKey() === key && location.pathname !== "/")
|
||||
const route = currentRoute()
|
||||
const nextTab = nextTabAfterClose(
|
||||
store,
|
||||
index,
|
||||
recentKey() === key && (route.type === "session" || route.type === "draft"),
|
||||
)
|
||||
closing.add(key)
|
||||
void startTransition(() => {
|
||||
setStore(
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useBeforeLeave, useLocation, useNavigate } from "@solidjs/router"
|
||||
import { createEffect, on } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { makePersisted } from "@solid-primitives/storage"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { applyPath, type TitlebarAction, type TitlebarHistory } from "./history"
|
||||
|
||||
export function createTitlebarHistory(input: { storage?: Storage; restore?: boolean } = {}) {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const path = () => `${location.pathname}${location.search}${location.hash}`
|
||||
const state = createStore<TitlebarHistory>({ stack: [], index: 0 })
|
||||
const [store, setStore] = input.storage
|
||||
? makePersisted(state, { storage: input.storage, name: "opencode.navigation-history" })
|
||||
: state
|
||||
if (!input.restore || !Array.isArray(store.stack) || store.stack[store.index] !== path()) {
|
||||
setStore({ stack: [], index: 0 })
|
||||
}
|
||||
let action: TitlebarAction | undefined
|
||||
|
||||
useBeforeLeave((event) => {
|
||||
action = typeof event.to === "number" ? event.to : { replace: event.options?.replace }
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(path, (path) => {
|
||||
setStore(applyPath(store, path, action))
|
||||
action = undefined
|
||||
}),
|
||||
)
|
||||
|
||||
const go = (delta: number) => {
|
||||
if (!store.stack[store.index + delta]) return false
|
||||
// Set the intent ourselves as MemoryRouter.go bypasses useBeforeLeave.
|
||||
action = delta
|
||||
navigate(delta)
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
back() {
|
||||
if (!go(-1) && location.pathname !== "/") navigate("/", { replace: true })
|
||||
},
|
||||
forward() {
|
||||
go(1)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const { use: useTitlebarHistory, provider: TitlebarHistoryProvider } = createSimpleContext({
|
||||
name: "TitlebarHistory",
|
||||
init: () => {
|
||||
const platform = usePlatform()
|
||||
// Browser history survives reload; desktop recreates its MemoryRouter with one entry.
|
||||
return createTitlebarHistory(
|
||||
platform.platform === "web"
|
||||
? {
|
||||
storage: sessionStorage,
|
||||
restore: performance
|
||||
.getEntriesByType("navigation")
|
||||
.some((entry) => entry instanceof PerformanceNavigationTiming && entry.type === "reload"),
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -1,63 +1,44 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { applyPath, backPath, forwardPath, type TitlebarHistory } from "./history"
|
||||
|
||||
function history(): TitlebarHistory {
|
||||
return { stack: [], index: 0, action: undefined }
|
||||
}
|
||||
import { applyPath, type TitlebarHistory } from "./history"
|
||||
|
||||
describe("titlebar history", () => {
|
||||
test("starts with only the actual entry", () => {
|
||||
expect(applyPath({ stack: [], index: 0 }, "/settings")).toEqual({ stack: ["/settings"], index: 0 })
|
||||
})
|
||||
|
||||
test("append and trim keeps max bounded", () => {
|
||||
let state = history()
|
||||
state = applyPath(state, "/", 3)
|
||||
state = applyPath(state, "/a", 3)
|
||||
state = applyPath(state, "/b", 3)
|
||||
state = applyPath(state, "/c", 3)
|
||||
|
||||
expect(state.stack).toEqual(["/a", "/b", "/c"])
|
||||
expect(state.stack.length).toBe(3)
|
||||
expect(state.index).toBe(2)
|
||||
const state = ["/", "/a", "/b", "/c"].reduce<TitlebarHistory>((state, path) => applyPath(state, path, {}, 3), {
|
||||
stack: [],
|
||||
index: 0,
|
||||
})
|
||||
expect(state).toEqual({ stack: ["/a", "/b", "/c"], index: 2 })
|
||||
expect(applyPath(state, "/b", -1).index).toBe(1)
|
||||
})
|
||||
|
||||
test("back and forward indexes stay correct after trimming", () => {
|
||||
let state = history()
|
||||
state = applyPath(state, "/", 3)
|
||||
state = applyPath(state, "/a", 3)
|
||||
state = applyPath(state, "/b", 3)
|
||||
state = applyPath(state, "/c", 3)
|
||||
|
||||
expect(state.stack).toEqual(["/a", "/b", "/c"])
|
||||
expect(state.index).toBe(2)
|
||||
|
||||
const back = backPath(state)
|
||||
expect(back?.to).toBe("/b")
|
||||
expect(back?.state.index).toBe(1)
|
||||
|
||||
const afterBack = applyPath(back!.state, back!.to, 3)
|
||||
expect(afterBack.stack).toEqual(["/a", "/b", "/c"])
|
||||
expect(afterBack.index).toBe(1)
|
||||
|
||||
const forward = forwardPath(afterBack)
|
||||
expect(forward?.to).toBe("/c")
|
||||
expect(forward?.state.index).toBe(2)
|
||||
|
||||
const afterForward = applyPath(forward!.state, forward!.to, 3)
|
||||
expect(afterForward.stack).toEqual(["/a", "/b", "/c"])
|
||||
expect(afterForward.index).toBe(2)
|
||||
test("replacing settings tabs keeps the forward entries", () => {
|
||||
const state = { stack: ["/session", "/settings?tab=general", "/other"], index: 1 }
|
||||
const next = applyPath(state, "/settings?tab=models", { replace: true })
|
||||
expect(next).toEqual({ stack: ["/session", "/settings?tab=models", "/other"], index: 1 })
|
||||
expect(applyPath(next, "/session", -1).index).toBe(0)
|
||||
expect(applyPath(next, "/other", 1).index).toBe(2)
|
||||
})
|
||||
|
||||
test("action-driven navigation does not push duplicate history entries", () => {
|
||||
const state: TitlebarHistory = {
|
||||
stack: ["/", "/a", "/b"],
|
||||
index: 2,
|
||||
action: undefined,
|
||||
}
|
||||
test("native traversal does not append entries", () => {
|
||||
const state = { stack: ["/session", "/settings?tab=models"], index: 1 }
|
||||
const back = applyPath(state, "/session", -1)
|
||||
expect(back).toEqual({ ...state, index: 0 })
|
||||
expect(applyPath(back, "/settings?tab=models", 1)).toEqual(state)
|
||||
})
|
||||
|
||||
const back = backPath(state)
|
||||
expect(back?.to).toBe("/a")
|
||||
test("memory history traversal without a leave event preserves the stack", () => {
|
||||
const state = { stack: ["/session", "/settings?tab=models"], index: 1 }
|
||||
const back = applyPath(state, "/session")
|
||||
expect(back).toEqual({ ...state, index: 0 })
|
||||
expect(applyPath(back, "/settings?tab=models")).toEqual(state)
|
||||
})
|
||||
|
||||
const next = applyPath(back!.state, back!.to, 10)
|
||||
expect(next.stack).toEqual(["/", "/a", "/b"])
|
||||
expect(next.index).toBe(1)
|
||||
expect(next.action).toBeUndefined()
|
||||
test("pushing a previously visited path is not mistaken for traversal", () => {
|
||||
const state = { stack: ["/session", "/settings", "/other"], index: 1 }
|
||||
expect(applyPath(state, "/session", {})).toEqual({ stack: ["/session", "/settings", "/session"], index: 2 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,26 +1,39 @@
|
||||
export const MAX_TITLEBAR_HISTORY = 100
|
||||
|
||||
export type TitlebarAction = "back" | "forward" | undefined
|
||||
export type TitlebarAction = number | { replace?: boolean }
|
||||
|
||||
export type TitlebarHistory = {
|
||||
stack: string[]
|
||||
index: number
|
||||
action: TitlebarAction
|
||||
}
|
||||
|
||||
export function applyPath(state: TitlebarHistory, current: string, max = MAX_TITLEBAR_HISTORY): TitlebarHistory {
|
||||
if (!state.stack.length) {
|
||||
const stack = current === "/" ? ["/"] : ["/", current]
|
||||
return { stack, index: stack.length - 1, action: undefined }
|
||||
export function applyPath(
|
||||
state: TitlebarHistory,
|
||||
current: string,
|
||||
action?: TitlebarAction,
|
||||
max = MAX_TITLEBAR_HISTORY,
|
||||
): TitlebarHistory {
|
||||
if (!state.stack.length) return { stack: [current], index: 0 }
|
||||
|
||||
if (typeof action === "number") {
|
||||
const index = state.index + action
|
||||
if (state.stack[index] === current) return { ...state, index }
|
||||
return { stack: [current], index: 0 }
|
||||
}
|
||||
|
||||
const active = state.stack[state.index]
|
||||
if (current === active) {
|
||||
if (!state.action) return state
|
||||
return { ...state, action: undefined }
|
||||
if (action?.replace) {
|
||||
return { ...state, stack: state.stack.map((path, index) => (index === state.index ? current : path)) }
|
||||
}
|
||||
|
||||
if (state.action) return { ...state, action: undefined }
|
||||
if (current === state.stack[state.index]) return state
|
||||
|
||||
// MemoryRouter history traversal does not notify useBeforeLeave.
|
||||
if (!action) {
|
||||
const before = state.stack.findLastIndex((path, index) => index < state.index && path === current)
|
||||
const after = state.stack.findIndex((path, index) => index > state.index && path === current)
|
||||
const index = before < 0 ? after : after < 0 || state.index - before <= after - state.index ? before : after
|
||||
if (index >= 0) return { ...state, index }
|
||||
}
|
||||
|
||||
return pushPath(state, current, max)
|
||||
}
|
||||
@@ -28,7 +41,7 @@ export function applyPath(state: TitlebarHistory, current: string, max = MAX_TIT
|
||||
export function pushPath(state: TitlebarHistory, path: string, max = MAX_TITLEBAR_HISTORY): TitlebarHistory {
|
||||
const stack = state.stack.slice(0, state.index + 1).concat(path)
|
||||
const next = trimHistory(stack, stack.length - 1, max)
|
||||
return { ...state, ...next, action: undefined }
|
||||
return { ...state, ...next }
|
||||
}
|
||||
|
||||
export function trimHistory(stack: string[], index: number, max = MAX_TITLEBAR_HISTORY) {
|
||||
@@ -39,19 +52,3 @@ export function trimHistory(stack: string[], index: number, max = MAX_TITLEBAR_H
|
||||
index: Math.max(0, index - cut),
|
||||
}
|
||||
}
|
||||
|
||||
export function backPath(state: TitlebarHistory) {
|
||||
if (state.index <= 0) return
|
||||
const index = state.index - 1
|
||||
const to = state.stack[index]
|
||||
if (!to) return
|
||||
return { state: { ...state, index, action: "back" as const }, to }
|
||||
}
|
||||
|
||||
export function forwardPath(state: TitlebarHistory) {
|
||||
if (state.index >= state.stack.length - 1) return
|
||||
const index = state.index + 1
|
||||
const to = state.stack[index]
|
||||
if (!to) return
|
||||
return { state: { ...state, index, action: "forward" as const }, to }
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createEffect, createMemo, createResource, Match, createSignal, Show, Switch, untrack } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createEffect, createMemo, createResource, Match, createSignal, Show, Switch } from "solid-js"
|
||||
import { Portal } from "solid-js/web"
|
||||
import { useLocation, useNavigate } from "@solidjs/router"
|
||||
import { useLocation } from "@solidjs/router"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Keybind } from "@opencode-ai/ui/keybind"
|
||||
@@ -13,21 +12,20 @@ import { useCommand } from "@/shell/commands/command"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { WindowsAppMenu } from "./windows-menu"
|
||||
import { applyPath, backPath, forwardPath } from "./history"
|
||||
import { useTitlebarHistory } from "./history-context"
|
||||
import { TitlebarTabStrip } from "@/shell/titlebar/tab-strip"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/shell/titlebar/session-events"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { tabKey, useTabs } from "@/shell/tabs/tabs"
|
||||
import { tabHref, tabKey, useTabs } from "@/shell/tabs/tabs"
|
||||
import type { ComposerState } from "@/composer/persistence"
|
||||
import "./titlebar.css"
|
||||
import { newTabTooltipKeybind } from "@/shell/commands/tooltip-keybind"
|
||||
import { TitlebarRightMount } from "@/shell/titlebar/right-slot"
|
||||
|
||||
const titlebarHeight = 36
|
||||
const windowsTitlebarHeight = 44 // Includes the content inset; matches the native Windows overlay.
|
||||
const minTitlebarZoom = 0.25
|
||||
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
|
||||
const macTrafficLightsBaseWidth = 84
|
||||
@@ -47,7 +45,7 @@ export function Titlebar(props: {
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const navigate = useNavigate()
|
||||
const history = useTitlebarHistory()
|
||||
const location = useLocation()
|
||||
const mobile = createMediaQuery("(max-width: 767px)")
|
||||
const bottom = createMemo(() => mobile() && settings.general.mobileTitlebarPosition() === "bottom")
|
||||
@@ -60,29 +58,11 @@ export function Titlebar(props: {
|
||||
const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom())
|
||||
const minHeight = () => {
|
||||
if (mac()) return `${titlebarHeight / zoom()}px`
|
||||
if (windows()) return `env(titlebar-area-height, ${windowsTitlebarHeight / Math.min(titlebarZoom(), 1)}px)`
|
||||
if (windows()) return `env(titlebar-area-height, ${titlebarHeight / Math.min(titlebarZoom(), 1)}px)`
|
||||
return undefined
|
||||
}
|
||||
const windowsControlsWidth = () => `${windowsControlsBaseWidth / Math.max(titlebarZoom(), 1)}px`
|
||||
|
||||
const [history, setHistory] = createStore({
|
||||
stack: [] as string[],
|
||||
index: 0,
|
||||
action: undefined as "back" | "forward" | undefined,
|
||||
})
|
||||
|
||||
const path = () => `${location.pathname}${location.search}${location.hash}`
|
||||
|
||||
createEffect(() => {
|
||||
const current = path()
|
||||
|
||||
untrack(() => {
|
||||
const next = applyPath(history, current)
|
||||
if (next === history) return
|
||||
setHistory(next)
|
||||
})
|
||||
})
|
||||
|
||||
const updateState = createMemo<TitlebarUpdatePillState>(() => {
|
||||
const installing = props.update?.installing ?? false
|
||||
const version = props.update?.version
|
||||
@@ -99,34 +79,20 @@ export function Titlebar(props: {
|
||||
update: updateState(),
|
||||
}))
|
||||
|
||||
const back = () => {
|
||||
const next = backPath(history)
|
||||
if (!next) return
|
||||
setHistory(next.state)
|
||||
navigate(next.to)
|
||||
}
|
||||
|
||||
const forward = () => {
|
||||
const next = forwardPath(history)
|
||||
if (!next) return
|
||||
setHistory(next.state)
|
||||
navigate(next.to)
|
||||
}
|
||||
|
||||
command.register(() => [
|
||||
{
|
||||
id: "common.goBack",
|
||||
title: language.t("common.goBack"),
|
||||
category: language.t("command.category.view"),
|
||||
keybind: "mod+[",
|
||||
onSelect: back,
|
||||
onSelect: history.back,
|
||||
},
|
||||
{
|
||||
id: "common.goForward",
|
||||
title: language.t("common.goForward"),
|
||||
category: language.t("command.category.view"),
|
||||
keybind: "mod+]",
|
||||
onSelect: forward,
|
||||
onSelect: history.forward,
|
||||
},
|
||||
])
|
||||
|
||||
@@ -255,15 +221,19 @@ export function Titlebar(props: {
|
||||
return
|
||||
}
|
||||
case "draft": {
|
||||
const activeTab = currentTab()
|
||||
const activeTab = matchRoute(route)
|
||||
if (activeTab?.type !== "draft") return
|
||||
|
||||
const model = tabs.stateValue<ComposerState>(activeTab, "prompt")?.model.current()
|
||||
void tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "", model)
|
||||
return
|
||||
}
|
||||
case "home": {
|
||||
const selection = layout.home.selection()
|
||||
case "home":
|
||||
case "settings": {
|
||||
const selection =
|
||||
route.type === "settings"
|
||||
? { server: location.query.server, directory: location.query.directory }
|
||||
: layout.home.selection()
|
||||
const conn =
|
||||
global.servers.list().find((item) => ServerConnection.key(item) === selection.server) ??
|
||||
global.servers.list()[0]
|
||||
@@ -272,8 +242,17 @@ export function Titlebar(props: {
|
||||
projects?.list().find((item) => item.worktree === selection.directory) ??
|
||||
projects?.list().find((item) => item.worktree === projects.last()) ??
|
||||
projects?.list()[0]
|
||||
if (conn && project) {
|
||||
void tabs.newDraft({ server: ServerConnection.key(conn), directory: project.worktree }, "")
|
||||
const directory =
|
||||
route.type === "settings" && typeof selection.directory === "string"
|
||||
? selection.directory
|
||||
: project?.worktree
|
||||
if (conn && directory) {
|
||||
const origin =
|
||||
route.type === "settings"
|
||||
? tabsStore.find((tab) => tabHref(tab) === location.query.from)
|
||||
: undefined
|
||||
const model = origin ? tabs.stateValue<ComposerState>(origin, "prompt")?.model.current() : undefined
|
||||
void tabs.newDraft({ server: ServerConnection.key(conn), directory }, "", model)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import path from "node:path"
|
||||
|
||||
test.each([
|
||||
{ name: "local development", ci: "", build: "", built: false },
|
||||
{ name: "local production build", ci: "", build: "1", built: true },
|
||||
{ name: "CI production build", ci: "true", build: "", built: true },
|
||||
{ name: "CI cannot opt into development", ci: "true", build: "0", built: true },
|
||||
])("Playwright uses $name", ({ ci, build, built }) => {
|
||||
const result = Bun.spawnSync({
|
||||
cmd: [process.execPath, "-e", 'import config from "./playwright.config.ts"; console.log(JSON.stringify(config))'],
|
||||
cwd: path.resolve(import.meta.dirname, ".."),
|
||||
env: {
|
||||
...process.env,
|
||||
CI: ci,
|
||||
PLAYWRIGHT_BUILD: build,
|
||||
PLAYWRIGHT_BASE_URL: undefined,
|
||||
PLAYWRIGHT_PORT: "4321",
|
||||
PLAYWRIGHT_SERVER_HOST: "127.0.0.2",
|
||||
PLAYWRIGHT_SERVER_PORT: "4322",
|
||||
},
|
||||
})
|
||||
expect(result.exitCode).toBe(0)
|
||||
const config = JSON.parse(result.stdout.toString())
|
||||
expect(config.use.baseURL).toBe("http://127.0.0.1:4321")
|
||||
expect(config.webServer.url).toBe(config.use.baseURL)
|
||||
expect(config.webServer.command).toBe(
|
||||
built
|
||||
? "bun run build && bun run serve -- --host 127.0.0.1 --port 4321 --strictPort"
|
||||
: "bun run dev -- --host 127.0.0.1 --port 4321 --strictPort",
|
||||
)
|
||||
expect(config.webServer.reuseExistingServer).toBe(!built)
|
||||
expect(config.webServer.env).toEqual({
|
||||
VITE_OPENCODE_SERVER_HOST: built ? "127.0.0.1" : "127.0.0.2",
|
||||
VITE_OPENCODE_SERVER_PORT: built ? "4321" : "4322",
|
||||
})
|
||||
})
|
||||
|
||||
test.each([
|
||||
"./playwright.config.ts",
|
||||
"./e2e/performance/playwright.config.ts",
|
||||
"./e2e/performance/playwright.uncapped.config.ts",
|
||||
"./e2e/performance/timeline-stability/playwright.config.ts",
|
||||
])("%s leaves an explicit external app unmanaged", (file) => {
|
||||
const result = Bun.spawnSync({
|
||||
cmd: [
|
||||
process.execPath,
|
||||
"-e",
|
||||
`import config from ${JSON.stringify(file)}; console.log(JSON.stringify({ ...config, fixtureHost: process.env.PLAYWRIGHT_SERVER_HOST, fixturePort: process.env.PLAYWRIGHT_SERVER_PORT }))`,
|
||||
],
|
||||
cwd: path.resolve(import.meta.dirname, ".."),
|
||||
env: { ...process.env, CI: "true", PLAYWRIGHT_BASE_URL: "http://127.0.0.1:4444" },
|
||||
})
|
||||
expect(result.exitCode).toBe(0)
|
||||
const config = JSON.parse(result.stdout.toString())
|
||||
expect(config.webServer).toBeUndefined()
|
||||
expect(config.use.baseURL).toBe("http://127.0.0.1:4444")
|
||||
expect(config.fixtureHost).toBe("127.0.0.1")
|
||||
expect(config.fixturePort).toBe("4444")
|
||||
})
|
||||
|
||||
test("Playwright rejects HTTPS targets unsupported by the API fixtures", () => {
|
||||
const result = Bun.spawnSync({
|
||||
cmd: [process.execPath, "-e", 'import "./playwright.config.ts"'],
|
||||
cwd: path.resolve(import.meta.dirname, ".."),
|
||||
env: { ...process.env, CI: "true", PLAYWRIGHT_BASE_URL: "https://e2e.example.com" },
|
||||
})
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr.toString()).toContain("E2E fixtures require an http:// app URL")
|
||||
})
|
||||
@@ -1,52 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createMemo, createRoot } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { normalizeProviderList } from "@/runtime/server/global-sync/utils"
|
||||
|
||||
test("preserves an already normalized reactive catalog", () => {
|
||||
const [store] = createStore({ catalog: { all: new Map<string, never>(), connected: [], default: {} } })
|
||||
expect(normalizeProviderList(store.catalog)).toBe(store.catalog)
|
||||
})
|
||||
|
||||
test("shares catalog snapshots and reacts to replacement lists", () => {
|
||||
createRoot((dispose) => {
|
||||
const provider = { id: "openai", name: "OpenAI", package: "@ai-sdk/openai", activation: "enabled" as const }
|
||||
const model = {
|
||||
id: "gpt-5",
|
||||
modelID: "gpt-5",
|
||||
providerID: "openai",
|
||||
name: "GPT-5",
|
||||
settings: {},
|
||||
headers: {},
|
||||
capabilities: { tools: true, input: ["text" as const], output: ["text" as const] },
|
||||
variants: [],
|
||||
time: { released: 1 },
|
||||
cost: [],
|
||||
status: "active" as const,
|
||||
enabled: true,
|
||||
limit: { context: 128_000, output: 8192 },
|
||||
}
|
||||
const [store, setStore] = createStore({ providers: [provider], models: [model] })
|
||||
const first = createMemo(() => normalizeProviderList(store.providers, store.models))
|
||||
const second = createMemo(() => normalizeProviderList(store.providers, store.models))
|
||||
expect(first()).toBe(second())
|
||||
const initial = first()
|
||||
setStore("models", [{ ...model, name: "Renamed", limit: { context: 256_000, output: 16384 } }])
|
||||
expect(first()).not.toBe(initial)
|
||||
expect(first()).toBe(second())
|
||||
expect(first().all.get("openai")?.models[model.id]).toMatchObject({
|
||||
name: "Renamed",
|
||||
limit: { context: 256_000, output: 16384 },
|
||||
})
|
||||
const renamed = first()
|
||||
setStore("providers", [{ ...provider, name: "Custom OpenAI" }])
|
||||
expect(first()).not.toBe(renamed)
|
||||
expect(first()).toBe(second())
|
||||
expect(first().all.get("openai")?.name).toBe("Custom OpenAI")
|
||||
expect(normalizeProviderList(store.providers).all.get("openai")?.models).toEqual({})
|
||||
setStore("models", [])
|
||||
expect(first().all.get("openai")?.models).toEqual({})
|
||||
expect(first().default).toEqual({})
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { MemoryRouter, Route, createMemoryHistory, useIsRouting, useLocation, useNavigate } from "@solidjs/router"
|
||||
import { createComponent, createEffect, createRoot } from "solid-js"
|
||||
import { render } from "solid-js/web"
|
||||
import { createTitlebarHistory } from "../src/shell/titlebar/history-context"
|
||||
|
||||
const cleanup: (() => void)[] = []
|
||||
afterEach(() => cleanup.splice(0).forEach((dispose) => dispose()))
|
||||
|
||||
function setup(initial = "/new-session?draftId=example") {
|
||||
const host = document.createElement("div")
|
||||
document.body.append(host)
|
||||
const memory = createMemoryHistory()
|
||||
memory.set({ value: initial, replace: true, scroll: false })
|
||||
const state = {} as {
|
||||
history: ReturnType<typeof createTitlebarHistory>
|
||||
navigate: ReturnType<typeof useNavigate>
|
||||
location: ReturnType<typeof useLocation>
|
||||
routing: ReturnType<typeof useIsRouting>
|
||||
}
|
||||
const dispose = render(
|
||||
() =>
|
||||
createComponent(MemoryRouter, {
|
||||
history: memory,
|
||||
root: (props) => {
|
||||
state.history = createTitlebarHistory()
|
||||
state.navigate = useNavigate()
|
||||
state.location = useLocation()
|
||||
state.routing = useIsRouting()
|
||||
return props.children
|
||||
},
|
||||
get children() {
|
||||
return createComponent(Route, { path: "*all", component: () => null })
|
||||
},
|
||||
}),
|
||||
host,
|
||||
)
|
||||
cleanup.push(() => {
|
||||
dispose()
|
||||
host.remove()
|
||||
})
|
||||
return {
|
||||
...state,
|
||||
memory,
|
||||
async at(path: string) {
|
||||
await new Promise<void>((resolve) => {
|
||||
createRoot((dispose) => {
|
||||
createEffect(() => {
|
||||
if (state.routing()) return
|
||||
dispose()
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
expect(`${state.location.pathname}${state.location.search}`).toBe(path)
|
||||
expect(memory.get()).toBe(path)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("settings memory history", () => {
|
||||
test("closing and app forward traverse the same entries after replacing the settings tab", async () => {
|
||||
const app = setup()
|
||||
app.navigate("/settings?tab=general")
|
||||
await app.at("/settings?tab=general")
|
||||
app.navigate("/settings?tab=models", { replace: true })
|
||||
await app.at("/settings?tab=models")
|
||||
app.history.back()
|
||||
await app.at("/new-session?draftId=example")
|
||||
app.history.forward()
|
||||
await app.at("/settings?tab=models")
|
||||
app.history.back()
|
||||
await app.at("/new-session?draftId=example")
|
||||
})
|
||||
|
||||
test("native back and forward retain the return destination without location state", async () => {
|
||||
const app = setup()
|
||||
app.navigate("/settings?tab=models")
|
||||
await app.at("/settings?tab=models")
|
||||
app.memory.back()
|
||||
await app.at("/new-session?draftId=example")
|
||||
app.memory.forward()
|
||||
await app.at("/settings?tab=models")
|
||||
expect(app.location.state).toBeUndefined()
|
||||
app.history.back()
|
||||
await app.at("/new-session?draftId=example")
|
||||
app.history.forward()
|
||||
await app.at("/settings?tab=models")
|
||||
})
|
||||
|
||||
test("navigating after going back discards the actual forward history", async () => {
|
||||
const app = setup()
|
||||
app.navigate("/settings")
|
||||
await app.at("/settings")
|
||||
app.history.back()
|
||||
await app.at("/new-session?draftId=example")
|
||||
app.navigate("/new-session?draftId=other")
|
||||
await app.at("/new-session?draftId=other")
|
||||
app.history.forward()
|
||||
await app.at("/new-session?draftId=other")
|
||||
app.memory.back()
|
||||
await app.at("/new-session?draftId=example")
|
||||
})
|
||||
|
||||
test("direct settings entry and a restarted memory router fall back home", async () => {
|
||||
const app = setup("/settings?tab=models")
|
||||
app.history.back()
|
||||
await app.at("/")
|
||||
app.history.forward()
|
||||
await app.at("/")
|
||||
app.memory.back()
|
||||
await app.at("/")
|
||||
})
|
||||
})
|
||||
@@ -27,7 +27,7 @@
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/pty": "0.1.12",
|
||||
"@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)
|
||||
|
||||
@@ -118,7 +118,7 @@
|
||||
"@ff-labs/fff-node": "0.10.5",
|
||||
"@opencode-ai/codemode": "workspace:*",
|
||||
"@opencode-ai/ai": "workspace:*",
|
||||
"@opencode-ai/pty": "0.1.12",
|
||||
"@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})
|
||||
`)
|
||||
}
|
||||
|
||||
|
||||
@@ -507,11 +507,9 @@ const layer = Layer.effect(
|
||||
from: TreeID
|
||||
to: TreeID
|
||||
}) {
|
||||
// Undo needs both paths of a rename, not only its destination.
|
||||
return (yield* repositoryOperation("list_files", input.repository, [
|
||||
"diff",
|
||||
"--name-only",
|
||||
"--no-renames",
|
||||
"-z",
|
||||
input.from,
|
||||
input.to,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -73,7 +73,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
|
||||
|
||||
return {
|
||||
app,
|
||||
location: locationInfo(),
|
||||
options: {},
|
||||
agent: {
|
||||
get: (input) => {
|
||||
@@ -289,6 +288,26 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
|
||||
if (ref && !isCurrentLocation(ref)) return runtime.location.mcp.list(ref)
|
||||
return response(mcp.servers())
|
||||
},
|
||||
add: (input) => {
|
||||
const ref = locationRef(input)
|
||||
if (ref && !isCurrentLocation(ref)) return runtime.location.mcp.add(ref, input.server, input.config)
|
||||
return mcp.add(input.server, input.config)
|
||||
},
|
||||
remove: (input) => {
|
||||
const ref = locationRef(input)
|
||||
if (ref && !isCurrentLocation(ref)) return runtime.location.mcp.remove(ref, input.server)
|
||||
return mcp.remove(input.server)
|
||||
},
|
||||
connect: (input) => {
|
||||
const ref = locationRef(input)
|
||||
if (ref && !isCurrentLocation(ref)) return runtime.location.mcp.connect(ref, input.server)
|
||||
return mcp.connect(input.server)
|
||||
},
|
||||
disconnect: (input) => {
|
||||
const ref = locationRef(input)
|
||||
if (ref && !isCurrentLocation(ref)) return runtime.location.mcp.disconnect(ref, input.server)
|
||||
return mcp.disconnect(input.server)
|
||||
},
|
||||
reload: mcp.reload,
|
||||
transform: (callback) =>
|
||||
mcp.transform((draft) => {
|
||||
@@ -365,7 +384,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
|
||||
add: (tool) => draft.add(tool),
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.as({ dispose: Effect.void })),
|
||||
.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",
|
||||
|
||||
@@ -2,6 +2,7 @@ export * as PluginRuntime from "./runtime.js"
|
||||
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Agent } from "../agent.js"
|
||||
import { Mcp } from "@opencode-ai/schema/mcp"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Job } from "../job.js"
|
||||
import { Location } from "../location.js"
|
||||
@@ -39,6 +40,10 @@ export interface Interface {
|
||||
readonly list: (
|
||||
ref: Location.Ref,
|
||||
) => Effect.Effect<{ readonly location: Location.Info; readonly data: MCP.ServerInfo[] }, unknown>
|
||||
readonly add: (ref: Location.Ref, server: string, config: Mcp.ServerConfig) => Effect.Effect<void, unknown>
|
||||
readonly remove: (ref: Location.Ref, server: string) => Effect.Effect<void, unknown>
|
||||
readonly connect: (ref: Location.Ref, server: string) => Effect.Effect<void, unknown>
|
||||
readonly disconnect: (ref: Location.Ref, server: string) => Effect.Effect<void, unknown>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,6 +101,10 @@ export const layerWithCell = (cell: Cell) =>
|
||||
},
|
||||
mcp: {
|
||||
list: (ref) => require(cell, (runtime) => runtime.location.mcp.list(ref)),
|
||||
add: (ref, server, config) => require(cell, (runtime) => runtime.location.mcp.add(ref, server, config)),
|
||||
remove: (ref, server) => require(cell, (runtime) => runtime.location.mcp.remove(ref, server)),
|
||||
connect: (ref, server) => require(cell, (runtime) => runtime.location.mcp.connect(ref, server)),
|
||||
disconnect: (ref, server) => require(cell, (runtime) => runtime.location.mcp.disconnect(ref, server)),
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -140,6 +149,14 @@ export const providerLayerWithCell = (cell: Cell) =>
|
||||
data: yield* mcp.servers(),
|
||||
}
|
||||
}).pipe(Effect.provide(locations.get(ref))),
|
||||
add: (ref, server, config) =>
|
||||
MCP.Service.use((mcp) => mcp.add(server, config)).pipe(Effect.provide(locations.get(ref))),
|
||||
remove: (ref, server) =>
|
||||
MCP.Service.use((mcp) => mcp.remove(server)).pipe(Effect.provide(locations.get(ref))),
|
||||
connect: (ref, server) =>
|
||||
MCP.Service.use((mcp) => mcp.connect(server)).pipe(Effect.provide(locations.get(ref))),
|
||||
disconnect: (ref, server) =>
|
||||
MCP.Service.use((mcp) => mcp.disconnect(server)).pipe(Effect.provide(locations.get(ref))),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
@@ -308,6 +319,12 @@ const layer = Layer.effect(
|
||||
}
|
||||
})
|
||||
|
||||
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 })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -157,7 +157,6 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
|
||||
providerMetadata: reuseProviderMetadata ? providerMetadata(providerMetadataKey, item.state) : undefined,
|
||||
},
|
||||
]
|
||||
// Let the destination adapter handle readable reasoning after a model/provider switch.
|
||||
if (item.type === "reasoning")
|
||||
return reuseProviderMetadata
|
||||
? [
|
||||
@@ -168,7 +167,7 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
|
||||
},
|
||||
]
|
||||
: item.text.length > 0
|
||||
? [{ type: message.error === undefined ? "reasoning" : "text", text: item.text }]
|
||||
? [{ type: "text", text: item.text }]
|
||||
: []
|
||||
// Call-side metadata is model-scoped proof of generation (Gemini thought
|
||||
// signatures, OpenAI encrypted reasoning): only the producing model may
|
||||
|
||||
@@ -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
+33
-30
@@ -25,7 +25,7 @@ export class RegistrationError extends Schema.TaggedError<RegistrationError>()("
|
||||
export interface Interface {
|
||||
readonly transform: (
|
||||
callback: (draft: { readonly add: (tool: Tool.Info) => void }) => void,
|
||||
) => Effect.Effect<void, never, Scope.Scope>
|
||||
) => Effect.Effect<void, RegistrationError, Scope.Scope>
|
||||
readonly snapshot: (permissions?: Permission.Ruleset) => Effect.Effect<Snapshot>
|
||||
}
|
||||
|
||||
@@ -140,35 +140,45 @@ const layer = Layer.effect(
|
||||
const transform: Interface["transform"] = Effect.fn("Tool.transform")(function* (callback) {
|
||||
const tools: Array<Tool.Info> = []
|
||||
yield* Effect.sync(() => callback({ add: (tool) => tools.push(tool) }))
|
||||
const valid = yield* Effect.filter(normalizedEntries(tools), (entry) =>
|
||||
Effect.gen(function* () {
|
||||
if (entry.tool.options?.namespace !== undefined) yield* validateNamespace(entry.tool.options.namespace)
|
||||
yield* validateName(normalizedName(entry.tool))
|
||||
if (entry.tool.options?.codemode === false && entry.key === "execute")
|
||||
return yield* new RegistrationError({
|
||||
name: entry.key,
|
||||
message: 'Tool name "execute" is reserved for CodeMode',
|
||||
})
|
||||
yield* Effect.try({
|
||||
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)}`,
|
||||
}),
|
||||
})
|
||||
return true
|
||||
}).pipe(Effect.catchTag("Tool.RegistrationError", (error) => skipRegistration(entry.tool, error))),
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
// Reject every ambiguous entry rather than choosing a winner.
|
||||
const entries = yield* Effect.filter(valid, (entry) => {
|
||||
if (!valid.some((candidate) => candidate !== entry && candidate.key === entry.key)) return Effect.succeed(true)
|
||||
return skipRegistration(
|
||||
entry.tool,
|
||||
new RegistrationError({ name: entry.key, message: `Duplicate normalized tool name: ${entry.key}` }),
|
||||
)
|
||||
})
|
||||
if (entries.length === 0) return
|
||||
yield* Effect.uninterruptible(
|
||||
lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
@@ -260,13 +270,6 @@ function schemaMakeError(error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
const skipRegistration = (tool: Tool.Info, error: RegistrationError) =>
|
||||
Effect.logError("Skipping invalid tool registration", {
|
||||
name: tool.name,
|
||||
namespace: tool.options?.namespace,
|
||||
error: error.message,
|
||||
}).pipe(Effect.as(false))
|
||||
|
||||
const validateName = (name: string) =>
|
||||
/^[A-Za-z0-9_-]{1,64}$/.test(name)
|
||||
? Effect.void
|
||||
|
||||
@@ -115,7 +115,7 @@ export const layer = Layer.effect(
|
||||
})
|
||||
}
|
||||
})
|
||||
.pipe(Scope.provide(next))
|
||||
.pipe(Scope.provide(next), Effect.orDie)
|
||||
if (current) yield* Scope.close(current, Exit.void)
|
||||
current = next
|
||||
}),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -131,37 +131,6 @@ describe("Git worktrees", () => {
|
||||
})
|
||||
|
||||
describe("Git trees", () => {
|
||||
it.live("lists both sides of a rename as separate file changes", () =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(async () => {
|
||||
await initRepo(root.path)
|
||||
await $`git config diff.renames true`.cwd(root.path).quiet()
|
||||
await Bun.write(path.join(root.path, "old name.txt"), "Preserve this content.\n")
|
||||
})
|
||||
const git = yield* Git.Service
|
||||
const repository = yield* git.repo.discover(AbsolutePath.make(root.path))
|
||||
if (!repository) throw new Error("Repository not found")
|
||||
const before = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
|
||||
yield* Effect.promise(() => fs.rename(path.join(root.path, "old name.txt"), path.join(root.path, "new name.txt")))
|
||||
const after = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
|
||||
|
||||
expect(yield* git.tree.files({ repository, from: before, to: after })).toEqual([
|
||||
RelativePath.make("new name.txt"),
|
||||
RelativePath.make("old name.txt"),
|
||||
])
|
||||
expect(
|
||||
(yield* git.tree.diff({ repository, from: before, to: after })).map((file) => [file.file, file.status]),
|
||||
).toEqual([
|
||||
["new name.txt", "added"],
|
||||
["old name.txt", "deleted"],
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("captures, compares, previews, and restores scoped trees", () =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* Effect.acquireRelease(
|
||||
|
||||
@@ -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(
|
||||
@@ -1032,58 +996,6 @@ test("adds, disconnects, and reconnects MCP servers at runtime", async () => {
|
||||
)
|
||||
})
|
||||
|
||||
testEffect(resourceMcpLayer(new ConfigMCP.Local({ type: "local", command: ["unused"], disabled: true }))).live(
|
||||
"manages live MCP servers entirely through scoped transforms",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* MCP.Service
|
||||
|
||||
yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* service.transform((draft) => {
|
||||
draft.set("dynamic", {
|
||||
type: "local",
|
||||
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
|
||||
})
|
||||
})
|
||||
expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status.status).toBe(
|
||||
"connected",
|
||||
)
|
||||
expect(yield* service.tools()).toHaveLength(2)
|
||||
|
||||
const settings = { disabled: true }
|
||||
yield* service.transform((draft) => {
|
||||
draft.update("dynamic", (server) => {
|
||||
server.disabled = settings.disabled
|
||||
})
|
||||
})
|
||||
expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status.status).toBe("disabled")
|
||||
expect(yield* service.tools()).toEqual([])
|
||||
|
||||
settings.disabled = false
|
||||
yield* service.reload()
|
||||
expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status.status).toBe(
|
||||
"connected",
|
||||
)
|
||||
expect(yield* service.tools()).toHaveLength(2)
|
||||
|
||||
const removed = yield* service.transform((draft) => draft.remove("dynamic"))
|
||||
expect((yield* service.servers()).some((server) => server.name === "dynamic")).toBe(false)
|
||||
expect(yield* service.tools()).toEqual([])
|
||||
|
||||
yield* removed.dispose
|
||||
expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status.status).toBe(
|
||||
"connected",
|
||||
)
|
||||
expect(yield* service.tools()).toHaveLength(2)
|
||||
}),
|
||||
)
|
||||
|
||||
expect((yield* service.servers()).map((server) => server.name)).toEqual([MCP.ServerName.make("resources")])
|
||||
expect(yield* service.tools()).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
test("restores runtime MCP config when a transform is disposed", async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
@@ -1281,86 +1193,6 @@ test("serializes concurrent MCP lifecycle operations", async () => {
|
||||
)
|
||||
})
|
||||
|
||||
testEffect(Layer.empty).live("isolates invalid MCP tools and keeps catalog updates alive", () =>
|
||||
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
|
||||
yield* registration.flush
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual([
|
||||
"demo_search",
|
||||
"other_lookup",
|
||||
"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",
|
||||
"other_lookup",
|
||||
"execute",
|
||||
])
|
||||
yield* Effect.forEach(["demo_search", "other_lookup"], (name) =>
|
||||
executeTool(registry, {
|
||||
sessionID: Session.ID.make("ses_mcp_invalid_catalog"),
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: `call_${name}`, name, input: {} },
|
||||
}).pipe(Effect.tap((result) => Effect.sync(() => expect(result).toMatchObject({ status: "completed" })))),
|
||||
)
|
||||
|
||||
yield* Ref.set(catalog, [tool("demo", "status"), ...healthy, 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_search",
|
||||
"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.effect("advertises MCP output schemas to Code Mode", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* Tool.Service
|
||||
|
||||
@@ -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 })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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([])
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -25,34 +25,6 @@ class Secret extends Context.Service<Secret, string>()("@opencode/test/PluginSec
|
||||
const versioned = <R>(plugin: EffectPlugin.Plugin<R>, version = "1") => ({ ...plugin, version })
|
||||
|
||||
describe("Plugin", () => {
|
||||
it.effect("exposes the current location to activated plugins", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const location = yield* Location.Service
|
||||
const seen: Location.Info[] = []
|
||||
yield* plugins.activate([
|
||||
versioned(
|
||||
EffectPlugin.define({
|
||||
id: "location-context",
|
||||
effect: (ctx) =>
|
||||
Effect.sync(() => {
|
||||
seen.push(ctx.location)
|
||||
}),
|
||||
}),
|
||||
"1",
|
||||
),
|
||||
])
|
||||
|
||||
expect(seen).toEqual([
|
||||
new Location.Info({
|
||||
directory: location.directory,
|
||||
workspaceID: location.workspaceID,
|
||||
project: location.project,
|
||||
}),
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("exposes public events through the plugin context", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
@@ -71,7 +43,7 @@ describe("Plugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("exposes MCP reads and transforms and routes explicit read locations", () =>
|
||||
it.effect("routes explicit MCP locations through the plugin runtime", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
@@ -100,6 +72,10 @@ describe("Plugin", () => {
|
||||
data: [],
|
||||
}
|
||||
}),
|
||||
add: (ref) => Effect.sync(() => routed.push(`add:${ref.directory}`)),
|
||||
remove: (ref) => Effect.sync(() => routed.push(`remove:${ref.directory}`)),
|
||||
connect: (ref) => Effect.sync(() => routed.push(`connect:${ref.directory}`)),
|
||||
disconnect: (ref) => Effect.sync(() => routed.push(`disconnect:${ref.directory}`)),
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -107,9 +83,14 @@ describe("Plugin", () => {
|
||||
)
|
||||
const location = { directory: target }
|
||||
|
||||
expect(Object.keys(host.mcp).sort()).toEqual(["list", "reload", "transform"])
|
||||
yield* host.mcp
|
||||
.add({ location, server: "routed", config: { type: "local", command: ["unused"], disabled: true } })
|
||||
.pipe(Effect.orDie)
|
||||
yield* host.mcp.remove({ location, server: "routed" }).pipe(Effect.orDie)
|
||||
yield* host.mcp.connect({ location, server: "routed" }).pipe(Effect.orDie)
|
||||
yield* host.mcp.disconnect({ location, server: "routed" }).pipe(Effect.orDie)
|
||||
expect((yield* host.mcp.list({ location }).pipe(Effect.orDie)).location.directory).toBe(target)
|
||||
expect(routed).toEqual(["list:/target"])
|
||||
expect(routed).toEqual(["add:/target", "remove:/target", "connect:/target", "disconnect:/target", "list:/target"])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -301,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
|
||||
|
||||
@@ -18,16 +18,6 @@ type Overrides = Partial<Omit<Plugin.Context, "options" | "session">> & {
|
||||
export function host(overrides: Overrides = {}): Plugin.Context {
|
||||
return {
|
||||
app: overrides.app ?? { name: "test", version: "test", channel: "test" },
|
||||
location:
|
||||
overrides.location ??
|
||||
new Location.Info({
|
||||
directory: AbsolutePath.make("/workspace"),
|
||||
project: {
|
||||
id: Project.ID.global,
|
||||
directory: AbsolutePath.make("/workspace"),
|
||||
canonical: AbsolutePath.make("/workspace"),
|
||||
},
|
||||
}),
|
||||
options: {},
|
||||
agent: overrides.agent ?? {
|
||||
get: () => Effect.die("unused agent.get"),
|
||||
@@ -87,6 +77,10 @@ export function host(overrides: Overrides = {}): Plugin.Context {
|
||||
},
|
||||
mcp: overrides.mcp ?? {
|
||||
list: () => Effect.die("unused mcp.list"),
|
||||
add: () => Effect.die("unused mcp.add"),
|
||||
remove: () => Effect.die("unused mcp.remove"),
|
||||
connect: () => Effect.die("unused mcp.connect"),
|
||||
disconnect: () => Effect.die("unused mcp.disconnect"),
|
||||
transform: () => Effect.die("unused mcp.transform"),
|
||||
reload: () => Effect.die("unused mcp.reload"),
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -17,7 +17,6 @@ import { SessionInbox } from "@opencode-ai/core/session/inbox"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { define } from "@opencode-ai/plugin/promise/plugin"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
@@ -29,41 +28,6 @@ import { host as testHost } from "./host"
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
describe("fromPromise", () => {
|
||||
it.effect("exposes the host location including workspace and project metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const location = yield* Location.Service
|
||||
const expected = new Location.Info({
|
||||
directory: AbsolutePath.make("/worktree/packages/app"),
|
||||
workspaceID: Workspace.ID.make("wrk_plugin_location"),
|
||||
project: {
|
||||
id: Project.ID.global,
|
||||
directory: AbsolutePath.make("/worktree"),
|
||||
canonical: AbsolutePath.make("/project"),
|
||||
},
|
||||
})
|
||||
const host = yield* PluginHost.make(plugins).pipe(
|
||||
Effect.provideService(Location.Service, {
|
||||
...location,
|
||||
directory: expected.directory,
|
||||
workspaceID: expected.workspaceID,
|
||||
project: expected.project,
|
||||
}),
|
||||
)
|
||||
const seen: Location.Info[] = []
|
||||
yield* PluginPromise.fromPromise(
|
||||
define({
|
||||
id: "promise-location",
|
||||
setup: (ctx) => {
|
||||
seen.push(ctx.location)
|
||||
},
|
||||
}),
|
||||
).effect(host)
|
||||
|
||||
expect(seen).toEqual([expected])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("adapts plugin storage methods", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
@@ -278,14 +242,12 @@ describe("fromPromise", () => {
|
||||
const promisePlugin = define({
|
||||
id: "promise-client-reads",
|
||||
setup: async (ctx) => {
|
||||
expect(Object.keys(ctx.mcp).sort()).toEqual(["list", "reload", "transform"])
|
||||
const results = await Promise.all([
|
||||
ctx.agent.list(),
|
||||
ctx.catalog.provider.list(),
|
||||
ctx.catalog.model.list(),
|
||||
ctx.command.list(),
|
||||
ctx.integration.list(),
|
||||
ctx.mcp.list(),
|
||||
ctx.plugin.list(),
|
||||
ctx.reference.list(),
|
||||
ctx.skill.list(),
|
||||
@@ -297,7 +259,7 @@ describe("fromPromise", () => {
|
||||
|
||||
yield* PluginPromise.fromPromise(promisePlugin).effect(host)
|
||||
|
||||
expect(seen).toHaveLength(9)
|
||||
expect(seen).toHaveLength(8)
|
||||
expect(new Set(seen).size).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import { $ } from "bun"
|
||||
import { describe, expect } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionInbox } from "@opencode-ai/core/session/inbox"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, Session.node, LocationServiceMap.node]),
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[Global.node, tempGlobalLayer],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
describe("Session.revert files", () => {
|
||||
it.live("undoes and restores a file rename without losing either path", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
const directory = path.join(tmp.path, "project")
|
||||
const original = path.join(directory, "old name.txt")
|
||||
const renamed = path.join(directory, "new name.txt")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(directory)
|
||||
await Bun.write(original, "Preserve this content.\n")
|
||||
await Bun.write(path.join(directory, "unrelated.txt"), "Unrelated content.\n")
|
||||
await $`git init -q`.cwd(directory).quiet()
|
||||
await $`git -c core.fsmonitor=false add .`.cwd(directory).quiet()
|
||||
})
|
||||
|
||||
const session = yield* Session.Service
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
const created = yield* session.create({ location: { directory: AbsolutePath.make(directory) } })
|
||||
const prompt = yield* session.prompt({ sessionID: created.id, text: "Rename the file", resume: false })
|
||||
yield* SessionInbox.promote(database.db, bus, created.id, "steer")
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.flush
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const before = yield* snapshot.capture()
|
||||
if (!before) throw new Error("Initial snapshot missing")
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
yield* bus.publish(SessionEvent.Step.Started, {
|
||||
sessionID: created.id,
|
||||
assistantMessageID,
|
||||
agent: Agent.defaultID,
|
||||
model: { id: Model.ID.make("test-model"), providerID: Provider.ID.make("test-provider") },
|
||||
snapshot: before,
|
||||
})
|
||||
yield* Effect.promise(() => fs.rename(original, renamed))
|
||||
const after = yield* snapshot.capture()
|
||||
if (!after) throw new Error("Renamed snapshot missing")
|
||||
yield* bus.publish(SessionEvent.Step.Ended, {
|
||||
sessionID: created.id,
|
||||
assistantMessageID,
|
||||
finish: "stop",
|
||||
cost: Money.USD.zero,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
snapshot: after,
|
||||
files: yield* snapshot.files({ from: before, to: after }),
|
||||
})
|
||||
|
||||
yield* Effect.promise(() => Bun.write(path.join(directory, "unrelated.txt"), "Keep this later edit.\n"))
|
||||
const reverted = yield* session.revert.stage({ sessionID: created.id, messageID: prompt.id })
|
||||
expect({
|
||||
original: yield* Effect.promise(() => Bun.file(original).exists()),
|
||||
renamed: yield* Effect.promise(() => Bun.file(renamed).exists()),
|
||||
}).toEqual({ original: true, renamed: false })
|
||||
expect(yield* Effect.promise(() => Bun.file(original).text())).toBe("Preserve this content.\n")
|
||||
expect(reverted.files?.map((file) => [file.file, file.status])).toEqual([
|
||||
["new name.txt", "deleted"],
|
||||
["old name.txt", "added"],
|
||||
])
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(directory, "unrelated.txt")).text())).toBe(
|
||||
"Keep this later edit.\n",
|
||||
)
|
||||
|
||||
yield* session.revert.clear(created.id)
|
||||
expect(yield* Effect.promise(() => Bun.file(original).exists())).toBe(false)
|
||||
expect(yield* Effect.promise(() => Bun.file(renamed).text())).toBe("Preserve this content.\n")
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(directory, "unrelated.txt")).text())).toBe(
|
||||
"Keep this later edit.\n",
|
||||
)
|
||||
expect((yield* session.get(created.id)).revert).toBeUndefined()
|
||||
}).pipe(Effect.provide(LocationServiceMap.Service.get(created.location)))
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -984,7 +984,7 @@ Recent work
|
||||
)
|
||||
|
||||
expect(messages[0]?.content).toEqual([
|
||||
{ type: "reasoning", text: "Visible thought" },
|
||||
{ type: "text", text: "Visible thought" },
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "hosted-old-model",
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Tool } from "@opencode-ai/core/tool"
|
||||
import type { Info } from "@opencode-ai/schema/tool"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { executeTool, toolDefinitions } from "./lib/tool"
|
||||
import { Cause, Deferred, Effect, Exit, Fiber, Layer, 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"
|
||||
|
||||
@@ -71,49 +71,30 @@ const transform = (service: Tool.Interface, tools: Readonly<Record<string, Info>
|
||||
)
|
||||
|
||||
describe("Tool", () => {
|
||||
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", () =>
|
||||
it.effect("rejects invalid dotted namespaces", () =>
|
||||
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 error = yield* transform(service, { echo: make() }, { namespace: "slack..admin" }).pipe(Effect.flip)
|
||||
|
||||
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("rejects invalid and colliding normalized names", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
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,
|
||||
)
|
||||
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"])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -178,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"])
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user