mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-11 20:19:53 -04:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1197209531 | |||
| 1c53c90d4e | |||
| edfd0bdb0b | |||
| 1b2e4750e1 | |||
| 59642a436f | |||
| 055eb78f06 | |||
| 722e0a04b2 |
@@ -124,66 +124,12 @@ jobs:
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: opencode-preview-cli-unsigned
|
||||
name: opencode-preview-cli
|
||||
path: packages/cli/dist/cli-*
|
||||
|
||||
outputs:
|
||||
version: ${{ needs.version.outputs.version }}
|
||||
|
||||
sign-cli-macos:
|
||||
needs: build-cli
|
||||
runs-on: macos-26
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
steps:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
|
||||
- uses: apple-actions/import-codesign-certs@8f3fb608891dd2244cdab3d69cd68c0d37a7fe93 # v2.0.0
|
||||
with:
|
||||
keychain: build
|
||||
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: opencode-preview-cli-unsigned
|
||||
path: packages/cli/dist
|
||||
|
||||
- name: Sign macOS CLI binaries
|
||||
run: |
|
||||
identity=$(security find-identity -v -p codesigning build.keychain | sed -n 's/.*"\(Developer ID Application:.*\)"/\1/p' | head -n 1)
|
||||
if [ -z "$identity" ]; then
|
||||
echo "Developer ID Application identity not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
found=0
|
||||
for file in packages/cli/dist/cli-darwin-*/bin/opencode2; do
|
||||
if [ ! -f "$file" ]; then
|
||||
continue
|
||||
fi
|
||||
found=1
|
||||
codesign \
|
||||
--force \
|
||||
--timestamp \
|
||||
--options runtime \
|
||||
--entitlements packages/cli/script/entitlements.plist \
|
||||
--sign "$identity" \
|
||||
"$file"
|
||||
codesign --verify --deep --strict --verbose=4 "$file"
|
||||
codesign --display --requirements - "$file"
|
||||
done
|
||||
|
||||
if [ "$found" -eq 0 ]; then
|
||||
echo "No macOS CLI binaries found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: opencode-preview-cli
|
||||
path: packages/cli/dist/cli-*
|
||||
if-no-files-found: error
|
||||
|
||||
build-node-cli:
|
||||
needs: version
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
|
||||
@@ -522,7 +468,6 @@ jobs:
|
||||
needs:
|
||||
- version
|
||||
- build-cli
|
||||
- sign-cli-macos
|
||||
- build-node-cli
|
||||
- sign-cli-windows
|
||||
- build-electron
|
||||
|
||||
@@ -348,10 +348,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
continue
|
||||
}
|
||||
}
|
||||
const previous = messages.at(-1)
|
||||
if (previous?.role === "user")
|
||||
messages[messages.length - 1] = { role: "user", content: [...previous.content, ...content] }
|
||||
else messages.push({ role: "user", content })
|
||||
messages.push({ role: "user", content })
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -395,10 +392,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
const cachePoint = BedrockCache.block(breakpoints, part.cache)
|
||||
if (cachePoint) content.push(cachePoint)
|
||||
}
|
||||
const previous = messages.at(-1)
|
||||
if (previous?.role === "user")
|
||||
messages[messages.length - 1] = { role: "user", content: [...previous.content, ...content] }
|
||||
else messages.push({ role: "user", content })
|
||||
messages.push({ role: "user", content })
|
||||
}
|
||||
|
||||
return messages
|
||||
|
||||
Vendored
-36
@@ -1,36 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:bedrock-converse",
|
||||
"provider:amazon-bedrock",
|
||||
"protocol:bedrock-converse",
|
||||
"tool",
|
||||
"tool-loop",
|
||||
"parallel"
|
||||
],
|
||||
"name": "bedrock-converse/continues-after-parallel-tool-results",
|
||||
"recordedAt": "2026-08-11T16:41:46.482Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.amazon.nova-micro-v1%3A0/converse-stream",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"modelId\":\"us.amazon.nova-micro-v1:0\",\"messages\":[{\"role\":\"user\",\"content\":[{\"text\":\"Compare the weather in Paris and London.\"}]},{\"role\":\"assistant\",\"content\":[{\"toolUse\":{\"toolUseId\":\"weather_paris\",\"name\":\"get_weather\",\"input\":{\"city\":\"Paris\"}}},{\"toolUse\":{\"toolUseId\":\"weather_london\",\"name\":\"get_weather\",\"input\":{\"city\":\"London\"}}}]},{\"role\":\"user\",\"content\":[{\"toolResult\":{\"toolUseId\":\"weather_paris\",\"content\":[{\"json\":{\"temperature\":22,\"condition\":\"sunny\"}}],\"status\":\"success\"}},{\"toolResult\":{\"toolUseId\":\"weather_london\",\"content\":[{\"json\":{\"temperature\":14,\"condition\":\"rainy\"}}],\"status\":\"success\"}}]}],\"system\":[{\"text\":\"After receiving both tool results, reply exactly: Paris is sunny; London is rainy.\"}],\"inferenceConfig\":{\"maxTokens\":40,\"temperature\":0},\"toolConfig\":{\"tools\":[{\"toolSpec\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"inputSchema\":{\"json\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}}]}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "application/vnd.amazon.eventstream"
|
||||
},
|
||||
"body": "AAAAqAAAAFKgEDvmCzpldmVudC10eXBlBwAMbWVzc2FnZVN0YXJ0DTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFEiLCJyb2xlIjoiYXNzaXN0YW50In189ig4AAAAzgAAAFfGCE2ECzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IlBhcmlzIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE1OT1BRUlNUVVYifWttETIAAADDAAAAVz6YiTULOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIGlzIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE0ifRHJ8Q0AAACqAAAAV6q6nAkLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIHN1bm55In0sInAiOiJhYmNkZWZnaGlqayJ98ZCy6gAAALAAAABXgOoTKgs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiI7In0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2In020bBKAAAAygAAAFcziOtECzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IiBMb25kb24ifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUCJ9ew04hAAAAK4AAABXXzo6yQs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIgaXMifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxciJ9yK3bdAAAALcAAABXMsrPOgs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIgcmFpbnkifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eCJ9JoCYVwAAALoAAABXyloLiws6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIuIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRiJ9WJwR8wAAAMEAAABXRFjaVQs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU4ifYzp4V0AAAChAAAAVqptnY4LOmV2ZW50LXR5cGUHABBjb250ZW50QmxvY2tTdG9wDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQyJ9AHyeLwAAAJUAAABRYKgWaws6ZXZlbnQtdHlwZQcAC21lc3NhZ2VTdG9wDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0Iiwic3RvcFJlYXNvbiI6ImVuZF90dXJuIn2HCXz0AAABBgAAAE6wWpX7CzpldmVudC10eXBlBwAIbWV0YWRhdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJtZXRyaWNzIjp7ImxhdGVuY3lNcyI6MTA5MH0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE1OT1BRUlNUVSIsInVzYWdlIjp7ImlucHV0VG9rZW5zIjo1MjEsIm91dHB1dFRva2VucyI6OSwic2VydmVyVG9vbFVzYWdlIjp7fSwidG90YWxUb2tlbnMiOjUzMH19Uwfxiw==",
|
||||
"bodyEncoding": "base64"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -255,57 +255,6 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("merges parallel tool results into one user message", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
id: "req_parallel_history",
|
||||
model,
|
||||
messages: [
|
||||
Message.user("Compare the weather."),
|
||||
Message.assistant([
|
||||
ToolCallPart.make({ id: "tool_paris", name: "lookup", input: { city: "Paris" } }),
|
||||
ToolCallPart.make({ id: "tool_london", name: "lookup", input: { city: "London" } }),
|
||||
]),
|
||||
Message.tool({ id: "tool_paris", name: "lookup", result: { forecast: "sunny" } }),
|
||||
Message.tool({ id: "tool_london", name: "lookup", result: { forecast: "rainy" } }),
|
||||
],
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "user", content: [{ text: "Compare the weather." }] },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ toolUse: { toolUseId: "tool_paris", name: "lookup", input: { city: "Paris" } } },
|
||||
{ toolUse: { toolUseId: "tool_london", name: "lookup", input: { city: "London" } } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
toolResult: {
|
||||
toolUseId: "tool_paris",
|
||||
content: [{ json: { forecast: "sunny" } }],
|
||||
status: "success",
|
||||
},
|
||||
},
|
||||
{
|
||||
toolResult: {
|
||||
toolUseId: "tool_london",
|
||||
content: [{ json: { forecast: "rainy" } }],
|
||||
status: "success",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers image content in tool-result messages", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -1216,39 +1165,4 @@ describe("Bedrock Converse recorded", () => {
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
recorded.effect.with("continues after parallel tool results", { tags: ["tool", "tool-loop", "parallel"] }, () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
id: "recorded_bedrock_parallel_tool_results",
|
||||
model: recordedModel(),
|
||||
system: "After receiving both tool results, reply exactly: Paris is sunny; London is rainy.",
|
||||
messages: [
|
||||
Message.user("Compare the weather in Paris and London."),
|
||||
Message.assistant([
|
||||
ToolCallPart.make({ id: "weather_paris", name: weatherToolName, input: { city: "Paris" } }),
|
||||
ToolCallPart.make({ id: "weather_london", name: weatherToolName, input: { city: "London" } }),
|
||||
]),
|
||||
Message.tool({
|
||||
id: "weather_paris",
|
||||
name: weatherToolName,
|
||||
result: { temperature: 22, condition: "sunny" },
|
||||
}),
|
||||
Message.tool({
|
||||
id: "weather_london",
|
||||
name: weatherToolName,
|
||||
result: { temperature: 14, condition: "rainy" },
|
||||
}),
|
||||
],
|
||||
tools: [weatherTool],
|
||||
cache: "none",
|
||||
generation: { maxTokens: 40, temperature: 0 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(response.text.trim()).toBe("Paris is sunny; London is rainy.")
|
||||
expect(response.finishReason?.normalized).toBe("stop")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -69,63 +69,6 @@ describe("v2 session reducer", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("prefers durable selection predecessors and derives them for older events", () => {
|
||||
const source: SessionMessageInfo[] = [
|
||||
{ id: "msg_previous_agent", type: "agent-switched", agent: "build", time: { created: 1 } },
|
||||
{
|
||||
id: "msg_previous_model",
|
||||
type: "model-switched",
|
||||
model: { id: "old", providerID: "provider" },
|
||||
time: { created: 1 },
|
||||
},
|
||||
]
|
||||
const reducer = createV2SessionReducer()
|
||||
|
||||
const agent = reducer.reduce(
|
||||
source,
|
||||
event({
|
||||
...base,
|
||||
id: "evt_agent",
|
||||
type: "session.agent.selected",
|
||||
data: { sessionID: "ses_1", agent: "plan", previous: "review" },
|
||||
}),
|
||||
)
|
||||
const model = reducer.reduce(
|
||||
source,
|
||||
event({
|
||||
...base,
|
||||
id: "evt_model",
|
||||
type: "session.model.selected",
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
model: { id: "new", providerID: "provider" },
|
||||
previous: { id: "durable", providerID: "provider" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
const legacyAgent = reducer.reduce(
|
||||
source,
|
||||
event({
|
||||
...base,
|
||||
id: "evt_legacy_agent",
|
||||
type: "session.agent.selected",
|
||||
data: { sessionID: "ses_1", agent: "plan" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(agent?.messages.at(-1)).toMatchObject({ type: "agent-switched", agent: "plan", previous: "review" })
|
||||
expect(model?.messages.at(-1)).toMatchObject({
|
||||
type: "model-switched",
|
||||
model: { id: "new" },
|
||||
previous: { id: "durable" },
|
||||
})
|
||||
expect(legacyAgent?.messages.at(-1)).toMatchObject({
|
||||
type: "agent-switched",
|
||||
agent: "plan",
|
||||
previous: "build",
|
||||
})
|
||||
})
|
||||
|
||||
test("folds tool, retry, and completion events", () => {
|
||||
const reducer = createV2SessionReducer()
|
||||
let messages: SessionMessageInfo[] = []
|
||||
|
||||
@@ -61,12 +61,6 @@ export function createV2SessionReducer() {
|
||||
type: "agent-switched",
|
||||
metadata: event.metadata,
|
||||
agent: event.data.agent,
|
||||
previous:
|
||||
event.data.previous ??
|
||||
source.findLast(
|
||||
(item): item is Extract<SessionMessageInfo, { type: "agent-switched" | "assistant" }> =>
|
||||
item.type === "agent-switched" || item.type === "assistant",
|
||||
)?.agent,
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.model.selected":
|
||||
@@ -75,12 +69,10 @@ export function createV2SessionReducer() {
|
||||
type: "model-switched",
|
||||
metadata: event.metadata,
|
||||
model: event.data.model,
|
||||
previous:
|
||||
event.data.previous ??
|
||||
source.findLast(
|
||||
(item): item is Extract<SessionMessageInfo, { type: "model-switched" | "assistant" }> =>
|
||||
item.type === "model-switched" || item.type === "assistant",
|
||||
)?.model,
|
||||
previous: source.findLast(
|
||||
(item): item is Extract<SessionMessageInfo, { type: "model-switched" | "assistant" }> =>
|
||||
item.type === "model-switched" || item.type === "assistant",
|
||||
)?.model,
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.synthetic":
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-executable-page-protection</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -422,7 +422,8 @@ async function loadCatalog(client: OpenCodeClient, cwd: string): Promise<Catalog
|
||||
defaultModel: {
|
||||
providerID: defaultModel.providerID,
|
||||
id: defaultModel.id,
|
||||
variant: defaultModel.variants.find((variant) => variant.id === "default")?.id,
|
||||
variant:
|
||||
defaultModel.variants.find((variant) => variant.id === "default")?.id ?? defaultModel.variants[0]?.id,
|
||||
},
|
||||
modes: agents.map((agent) => ({ id: agent.id, name: agent.name, description: agent.description })),
|
||||
defaultModeID: defaultAgent.id,
|
||||
|
||||
@@ -3,38 +3,6 @@ import type { SessionConfigOption } from "@agentclientprotocol/sdk"
|
||||
import { makeACPFixture, makeSession, secondModel } from "./service-fixture"
|
||||
|
||||
describe("acp service lifecycle", () => {
|
||||
test("does not persist the first catalog variant when no explicit default exists", async () => {
|
||||
const model = { ...secondModel, variants: [{ id: "none" }, { id: "high" }] }
|
||||
await using fixture = makeACPFixture({
|
||||
models: [model],
|
||||
defaultModel: model,
|
||||
fetch(request) {
|
||||
if (request.method === "POST" && request.path === "/api/session") {
|
||||
return Response.json({
|
||||
data: makeSession("ses_default_variant", {
|
||||
model: { providerID: model.providerID, id: model.id },
|
||||
}),
|
||||
})
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
|
||||
const created = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
|
||||
|
||||
expect(fixture.requests).toContainEqual({
|
||||
method: "POST",
|
||||
path: "/api/session",
|
||||
query: {},
|
||||
body: {
|
||||
location: { directory: "/workspace" },
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "second-model" },
|
||||
},
|
||||
})
|
||||
expect(currentValue(created, "effort")).toBe("none")
|
||||
})
|
||||
|
||||
test("loads and forks with paginated replay while resume does not replay", async () => {
|
||||
await using fixture = makeACPFixture({
|
||||
fetch(request) {
|
||||
|
||||
@@ -339,11 +339,7 @@ export type Endpoint5_31Output =
|
||||
readonly type: "session.agent.selected"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly previous?: Agent.ID | undefined
|
||||
}
|
||||
readonly data: { readonly sessionID: Session.ID; readonly agent: Agent.ID }
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
@@ -352,11 +348,7 @@ export type Endpoint5_31Output =
|
||||
readonly type: "session.model.selected"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly model: Model.Ref
|
||||
readonly previous?: Model.Ref | undefined
|
||||
}
|
||||
readonly data: { readonly sessionID: Session.ID; readonly model: Model.Ref }
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
|
||||
@@ -436,7 +436,7 @@ export type SessionAgentSelected = {
|
||||
type: "session.agent.selected"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; agent: string; previous?: string }
|
||||
data: { sessionID: string; agent: string }
|
||||
}
|
||||
|
||||
export type SessionModelSelected = {
|
||||
@@ -446,7 +446,7 @@ export type SessionModelSelected = {
|
||||
type: "session.model.selected"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; model: ModelRef; previous?: ModelRef }
|
||||
data: { sessionID: string; model: ModelRef }
|
||||
}
|
||||
|
||||
export type SessionMoved = {
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
},
|
||||
"imports": {
|
||||
"#sqlite": {
|
||||
"workerd": "./src/database/sqlite.workerd.ts",
|
||||
"bun": "./src/database/sqlite.bun.ts",
|
||||
"node": "./src/database/sqlite.node.ts",
|
||||
"default": "./src/database/sqlite.bun.ts"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"id": "00924d88-1842-4d71-ac74-5682ddc47e1c",
|
||||
"prevIds": ["15060ec5-05f7-4b86-b2a5-9108609432b3"],
|
||||
"id": "15060ec5-05f7-4b86-b2a5-9108609432b3",
|
||||
"prevIds": ["1551a157-8959-4ba9-a52b-4ea3b7b28cae"],
|
||||
"ddl": [
|
||||
{
|
||||
"name": "account_state",
|
||||
@@ -1302,16 +1302,6 @@
|
||||
"entityType": "columns",
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "0",
|
||||
"generated": null,
|
||||
"name": "resume_attempts",
|
||||
"entityType": "columns",
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
|
||||
@@ -51,27 +51,6 @@ export function map(input: MapInput): Mapping | undefined {
|
||||
...mapGoogleOptions(input.settings),
|
||||
},
|
||||
}
|
||||
case "@ai-sdk/google-vertex/anthropic":
|
||||
return {
|
||||
package: "@opencode-ai/ai/providers/google-vertex/messages",
|
||||
settings: {
|
||||
...baseSettings,
|
||||
...(typeof input.settings.accessToken === "string" ? { accessToken: input.settings.accessToken } : {}),
|
||||
...(typeof input.settings.location === "string" ? { location: input.settings.location } : {}),
|
||||
...(typeof input.settings.project === "string" ? { project: input.settings.project } : {}),
|
||||
...(isRecord(input.settings.thinking) || typeof input.settings.effort === "string"
|
||||
? {
|
||||
providerOptions: {
|
||||
anthropic: {
|
||||
...(isRecord(input.settings.thinking) ? { thinking: input.settings.thinking } : {}),
|
||||
...(typeof input.settings.effort === "string" ? { effort: input.settings.effort } : {}),
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
|
||||
}
|
||||
case "@openrouter/ai-sdk-provider":
|
||||
return mapOpenRouter(input.settings, baseSettings)
|
||||
case "@ai-sdk/xai":
|
||||
@@ -109,13 +88,9 @@ function mapBedrockSettings(
|
||||
: typeof settings.bearerToken === "string"
|
||||
? settings.bearerToken
|
||||
: undefined
|
||||
const region = bedrockRegion(settings)
|
||||
const credentials = mapBedrockCredentials(settings, region)
|
||||
const credentials = mapBedrockCredentials(settings)
|
||||
return {
|
||||
...baseSettings,
|
||||
...(typeof baseSettings.baseURL === "string" && region !== undefined
|
||||
? { baseURL: baseSettings.baseURL.replaceAll("${AWS_REGION}", region) }
|
||||
: {}),
|
||||
...(typeof settings.baseURL !== "string" && typeof settings.endpoint === "string"
|
||||
? { baseURL: settings.endpoint }
|
||||
: {}),
|
||||
@@ -180,8 +155,14 @@ function mapBedrockRequest(input: MapInput): Pick<Mapping, "headers" | "body"> {
|
||||
}
|
||||
}
|
||||
|
||||
function mapBedrockCredentials(settings: Readonly<Record<string, unknown>>, region: string | undefined) {
|
||||
function mapBedrockCredentials(settings: Readonly<Record<string, unknown>>) {
|
||||
const credentials = isRecord(settings.credentials) ? settings.credentials : settings
|
||||
const region =
|
||||
typeof settings.region === "string"
|
||||
? settings.region
|
||||
: typeof credentials.region === "string"
|
||||
? credentials.region
|
||||
: undefined
|
||||
if (
|
||||
region === undefined ||
|
||||
typeof credentials.accessKeyId !== "string" ||
|
||||
@@ -196,15 +177,6 @@ function mapBedrockCredentials(settings: Readonly<Record<string, unknown>>, regi
|
||||
}
|
||||
}
|
||||
|
||||
function bedrockRegion(settings: Readonly<Record<string, unknown>>) {
|
||||
const credentials = isRecord(settings.credentials) ? settings.credentials : settings
|
||||
return typeof settings.region === "string"
|
||||
? settings.region
|
||||
: typeof credentials.region === "string"
|
||||
? credentials.region
|
||||
: undefined
|
||||
}
|
||||
|
||||
function mapOpenAIOptions(settings: Readonly<Record<string, unknown>>) {
|
||||
const options = {
|
||||
...(typeof settings.reasoningEffort === "string" ? { reasoningEffort: settings.reasoningEffort } : {}),
|
||||
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
} from "@opencode-ai/ai"
|
||||
import { Auth, Endpoint, RequestExecutor, type AnyRoute } from "@opencode-ai/ai/route"
|
||||
import { ProviderShared } from "@opencode-ai/ai/protocols/shared"
|
||||
import type { Content } from "@opencode-ai/schema/tool"
|
||||
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
|
||||
import type { ID, Info } from "./model"
|
||||
import { Provider } from "./provider"
|
||||
@@ -41,15 +40,9 @@ import { State } from "./state"
|
||||
|
||||
type SDK = any
|
||||
type UserContent = Extract<LanguageModelV3Message, { role: "user" }>["content"]
|
||||
type UserFileContent = Extract<UserContent[number], { type: "file" }>
|
||||
type AssistantContent = Extract<LanguageModelV3Message, { role: "assistant" }>["content"]
|
||||
type ToolResultContent = Extract<AssistantContent[number], { type: "tool-result" }>
|
||||
|
||||
const SYNTHETIC_ATTACHMENT_PROMPT = "Attached media from tool result:"
|
||||
const TOOL_RESULT_ATTACHMENT_TEXT = "Media attached in following user message."
|
||||
const isToolResultMedia = (item: Content): item is Extract<Content, { type: "file" }> =>
|
||||
item.type === "file" && (item.mime.toLowerCase().startsWith("image/") || item.mime === "application/pdf")
|
||||
|
||||
export interface SDKEvent {
|
||||
readonly model: Info
|
||||
readonly package: string
|
||||
@@ -442,34 +435,21 @@ function prompt(request: LLMRequest): LanguageModelV3Prompt {
|
||||
.map((part) => part.text)
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
const messages: LanguageModelV3Message[] = []
|
||||
const media: UserFileContent[] = []
|
||||
const flushMedia = () => {
|
||||
if (media.length === 0) return
|
||||
messages.push({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: SYNTHETIC_ATTACHMENT_PROMPT }, ...media.splice(0)],
|
||||
})
|
||||
}
|
||||
for (const input of request.messages) {
|
||||
if (input.role !== "tool") flushMedia()
|
||||
messages.push(...message(input, media))
|
||||
}
|
||||
flushMedia()
|
||||
const messages = request.messages.flatMap(message)
|
||||
if (!system.length) return messages
|
||||
return [{ role: "system", content: system }, ...messages]
|
||||
}
|
||||
|
||||
function message(input: LLMRequest["messages"][number], media: UserFileContent[]): LanguageModelV3Message[] {
|
||||
function message(input: LLMRequest["messages"][number]): LanguageModelV3Message[] {
|
||||
switch (input.role) {
|
||||
case "system":
|
||||
return [{ role: "system", content: input.content.flatMap(text).join("\n\n") }]
|
||||
case "user":
|
||||
return [{ role: "user", content: input.content.flatMap(userPart) }]
|
||||
case "assistant":
|
||||
return [{ role: "assistant", content: input.content.flatMap((part) => assistantPart(part, media)) }]
|
||||
return [{ role: "assistant", content: input.content.flatMap(assistantPart) }]
|
||||
case "tool": {
|
||||
const content = input.content.flatMap((part) => toolResultPart(part, media))
|
||||
const content = input.content.flatMap(toolResultPart)
|
||||
return content.length ? [{ role: "tool", content }] : []
|
||||
}
|
||||
}
|
||||
@@ -486,7 +466,7 @@ function userPart(part: ContentPart): UserContent {
|
||||
return []
|
||||
}
|
||||
|
||||
function assistantPart(part: ContentPart, media: UserFileContent[]): AssistantContent {
|
||||
function assistantPart(part: ContentPart): AssistantContent {
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
return [{ type: "text", text: part.text }]
|
||||
@@ -506,34 +486,18 @@ function assistantPart(part: ContentPart, media: UserFileContent[]): AssistantCo
|
||||
},
|
||||
]
|
||||
case "tool-result":
|
||||
return toolResultPart(part, media)
|
||||
return toolResultPart(part)
|
||||
}
|
||||
}
|
||||
|
||||
function toolResultPart(part: ContentPart, media: UserFileContent[]): ToolResultContent[] {
|
||||
function toolResultPart(part: ContentPart): ToolResultContent[] {
|
||||
if (part.type !== "tool-result") return []
|
||||
const result = (() => {
|
||||
if (part.result.type !== "content") return part.result
|
||||
const extracted = part.result.value.filter(isToolResultMedia)
|
||||
if (extracted.length === 0) return part.result
|
||||
media.push(
|
||||
...extracted.map((item) => ({
|
||||
type: "file" as const,
|
||||
mediaType: item.mime,
|
||||
data: item.uri,
|
||||
filename: item.name,
|
||||
})),
|
||||
)
|
||||
const content = part.result.value.filter((item) => !isToolResultMedia(item))
|
||||
if (content.length === 0) return { type: "text" as const, value: TOOL_RESULT_ATTACHMENT_TEXT }
|
||||
return { type: "content" as const, value: content }
|
||||
})()
|
||||
return [
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: part.id,
|
||||
toolName: part.name,
|
||||
output: toolOutput(result),
|
||||
output: toolOutput(part.result),
|
||||
providerOptions: providerOptions(part.providerMetadata),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -204,13 +204,13 @@ export const layer = (options?: Options) =>
|
||||
const claude = [
|
||||
...new Set([
|
||||
...((yield* fs.isDir(globalClaudeDirectory)) ? [globalClaudeDirectory] : []),
|
||||
...discovered.filter((item) => path.basename(item) === ".claude").toReversed(),
|
||||
...discovered.filter((item) => path.basename(item) === ".claude"),
|
||||
]),
|
||||
].map((directory) => new ClaudeDirectory({ type: "claude", path: AbsolutePath.make(directory) }))
|
||||
const agents = [
|
||||
...new Set([
|
||||
...((yield* fs.isDir(globalAgentsDirectory)) ? [globalAgentsDirectory] : []),
|
||||
...discovered.filter((item) => path.basename(item) === ".agents").toReversed(),
|
||||
...discovered.filter((item) => path.basename(item) === ".agents"),
|
||||
]),
|
||||
].map((directory) => new AgentsDirectory({ type: "agents", path: AbsolutePath.make(directory) }))
|
||||
|
||||
|
||||
@@ -83,14 +83,8 @@ export function normalize(input: unknown): Result {
|
||||
if (legacySnapshots !== undefined) encoded.snapshots = legacySnapshots
|
||||
if (legacyShare !== undefined) encoded.share = legacyShare
|
||||
|
||||
const legacyReferences = decodeMap(input.reference, ConfigReference.Entry, ["reference"], diagnostics, decodeEncoded)
|
||||
const nativeReferences = decodeMap(
|
||||
input.references,
|
||||
ConfigReference.Entry,
|
||||
["references"],
|
||||
diagnostics,
|
||||
decodeEncoded,
|
||||
)
|
||||
const legacyReferences = decodeEncodedMap(input.reference, ConfigReference.Entry, ["reference"], diagnostics)
|
||||
const nativeReferences = decodeEncodedMap(input.references, ConfigReference.Entry, ["references"], diagnostics)
|
||||
mergeMap(
|
||||
encoded,
|
||||
"references",
|
||||
@@ -100,13 +94,13 @@ export function normalize(input: unknown): Result {
|
||||
diagnostics,
|
||||
)
|
||||
|
||||
const legacyCommands = decodeMap(input.command, ConfigCommandV1.Info, ["command"], diagnostics, decodeValue)
|
||||
const legacyCommands = decodeMap(input.command, ConfigCommandV1.Info, ["command"], diagnostics)
|
||||
diagnoseSelectionMap(input.command, ["command"], diagnostics)
|
||||
const migratedCommands = mapValues(legacyCommands, (value) => {
|
||||
const migrated = ConfigMigrateV1.commands({ value })?.value
|
||||
return migrated === undefined ? undefined : canonical(ConfigCommand.Info, migrated)
|
||||
})
|
||||
const nativeCommands = decodeMap(input.commands, ConfigCommand.Info, ["commands"], diagnostics, decodeEncoded)
|
||||
const nativeCommands = decodeEncodedMap(input.commands, ConfigCommand.Info, ["commands"], diagnostics)
|
||||
mergeMap(
|
||||
encoded,
|
||||
"commands",
|
||||
@@ -116,9 +110,8 @@ export function normalize(input: unknown): Result {
|
||||
diagnostics,
|
||||
)
|
||||
|
||||
const legacyAgents = mapValues(
|
||||
decodeMap(input.agent, ConfigAgentV1.Info, ["agent"], diagnostics, decodeValue),
|
||||
(value) => canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent(value)),
|
||||
const legacyAgents = mapValues(decodeMap(input.agent, ConfigAgentV1.Info, ["agent"], diagnostics), (value) =>
|
||||
canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent(value)),
|
||||
)
|
||||
const legacySmallModel = own(input, "small_model")
|
||||
? decodeValue(Schema.String, input.small_model, ["small_model"], diagnostics)
|
||||
@@ -137,11 +130,11 @@ export function normalize(input: unknown): Result {
|
||||
model: migratedSmallModel,
|
||||
...legacyAgents.title,
|
||||
}
|
||||
const modeAgents = mapValues(decodeMap(input.mode, ConfigAgentV1.Info, ["mode"], diagnostics, decodeValue), (value) =>
|
||||
const modeAgents = mapValues(decodeMap(input.mode, ConfigAgentV1.Info, ["mode"], diagnostics), (value) =>
|
||||
canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent({ ...value, mode: "primary" })),
|
||||
)
|
||||
const migratedAgents = mergeMaps(legacyAgents, modeAgents, ["agents"], diagnostics)
|
||||
const nativeAgents = decodeMap(input.agents, ConfigAgent.Info, ["agents"], diagnostics, decodeEncoded)
|
||||
const nativeAgents = decodeEncodedMap(input.agents, ConfigAgent.Info, ["agents"], diagnostics)
|
||||
diagnoseAgentUnsupported(input.agent, ["agent"], diagnostics)
|
||||
diagnoseAgentUnsupported(input.mode, ["mode"], diagnostics)
|
||||
mergeMap(
|
||||
@@ -154,7 +147,7 @@ export function normalize(input: unknown): Result {
|
||||
)
|
||||
|
||||
const legacyProviders = migrateProviders(input.provider, diagnostics)
|
||||
const nativeProviders = decodeMap(input.providers, ConfigProvider.Info, ["providers"], diagnostics, decodeEncoded)
|
||||
const nativeProviders = decodeEncodedMap(input.providers, ConfigProvider.Info, ["providers"], diagnostics)
|
||||
mergeMap(
|
||||
encoded,
|
||||
"providers",
|
||||
@@ -166,14 +159,14 @@ export function normalize(input: unknown): Result {
|
||||
|
||||
const toolRules = migrateTools(input.tools, diagnostics)
|
||||
const permissionRules = migratePermissions(input.permission, diagnostics)
|
||||
const nativePermissions = decodeList(input.permissions, Permission.Rule, ["permissions"], diagnostics, decodeEncoded)
|
||||
const nativePermissions = decodeEncodedList(input.permissions, Permission.Rule, ["permissions"], diagnostics)
|
||||
const permissions = [...toolRules, ...permissionRules, ...nativePermissions]
|
||||
if (permissions.length || Array.isArray(input.permissions)) encoded.permissions = permissions
|
||||
|
||||
const legacyPlugins = decodeList(input.plugin, ConfigPluginV1.Spec, ["plugin"], diagnostics, decodeValue).map(
|
||||
(plugin) => (typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] }),
|
||||
const legacyPlugins = decodeList(input.plugin, ConfigPluginV1.Spec, ["plugin"], diagnostics).map((plugin) =>
|
||||
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
|
||||
)
|
||||
const nativePlugins = decodeList(input.plugins, ConfigPlugin.Plugin, ["plugins"], diagnostics, decodeEncoded)
|
||||
const nativePlugins = decodeEncodedList(input.plugins, ConfigPlugin.Plugin, ["plugins"], diagnostics)
|
||||
if (legacyPlugins.length || nativePlugins.length || Array.isArray(input.plugin) || Array.isArray(input.plugins))
|
||||
encoded.plugins = [...legacyPlugins, ...nativePlugins]
|
||||
|
||||
@@ -207,7 +200,7 @@ export function normalize(input: unknown): Result {
|
||||
overlay(encoded, key, value, [key], diagnostics)
|
||||
})
|
||||
|
||||
const instructions = decodeList(input.instructions, Schema.String, ["instructions"], diagnostics, decodeEncoded)
|
||||
const instructions = decodeEncodedList(input.instructions, Schema.String, ["instructions"], diagnostics)
|
||||
if (instructions.length || Array.isArray(input.instructions)) encoded.instructions = instructions
|
||||
|
||||
return { type: "normalized", encoded, diagnostics }
|
||||
@@ -216,7 +209,7 @@ export function normalize(input: unknown): Result {
|
||||
function normalizeSkills(input: Record<string, unknown>, encoded: Record<string, unknown>, diagnostics: Diagnostic[]) {
|
||||
if (!own(input, "skills")) return
|
||||
if (Array.isArray(input.skills)) {
|
||||
encoded.skills = decodeList(input.skills, Schema.String, ["skills"], diagnostics, decodeEncoded)
|
||||
encoded.skills = decodeEncodedList(input.skills, Schema.String, ["skills"], diagnostics)
|
||||
return
|
||||
}
|
||||
if (!isRecord(input.skills)) {
|
||||
@@ -224,8 +217,8 @@ function normalizeSkills(input: Record<string, unknown>, encoded: Record<string,
|
||||
return
|
||||
}
|
||||
encoded.skills = [
|
||||
...decodeList(input.skills.paths, Schema.String, ["skills", "paths"], diagnostics, decodeEncoded),
|
||||
...decodeList(input.skills.urls, Schema.String, ["skills", "urls"], diagnostics, decodeEncoded),
|
||||
...decodeEncodedList(input.skills.paths, Schema.String, ["skills", "paths"], diagnostics),
|
||||
...decodeEncodedList(input.skills.urls, Schema.String, ["skills", "urls"], diagnostics),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -255,8 +248,8 @@ function normalizeMcp(input: Record<string, unknown>, encoded: Record<string, un
|
||||
return
|
||||
}
|
||||
if (name === "servers" && !isDirectLegacyMcp(value)) {
|
||||
Object.entries(decodeMap(value, ConfigMCP.Server, path, diagnostics, decodeEncoded)).forEach(
|
||||
([key, server]) => setOwn(nativeServers, key, server),
|
||||
Object.entries(decodeEncodedMap(value, ConfigMCP.Server, path, diagnostics)).forEach(([key, server]) =>
|
||||
setOwn(nativeServers, key, server),
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -411,13 +404,7 @@ function normalizeExperimental(
|
||||
if (value !== undefined) result.subagent_depth = value
|
||||
}
|
||||
native.push(
|
||||
...decodeList(
|
||||
experimental.policies,
|
||||
ConfigPolicy.Info,
|
||||
["experimental", "policies"],
|
||||
diagnostics,
|
||||
decodeEncoded,
|
||||
),
|
||||
...decodeEncodedList(experimental.policies, ConfigPolicy.Info, ["experimental", "policies"], diagnostics),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -433,7 +420,7 @@ function normalizeWatcher(input: Record<string, unknown>, encoded: Record<string
|
||||
invalid(["watcher"], diagnostics)
|
||||
return
|
||||
}
|
||||
const ignore = decodeList(input.watcher.ignore, Schema.String, ["watcher", "ignore"], diagnostics, decodeEncoded)
|
||||
const ignore = decodeEncodedList(input.watcher.ignore, Schema.String, ["watcher", "ignore"], diagnostics)
|
||||
encoded.watcher = ignore.length || Array.isArray(input.watcher.ignore) ? { ignore } : {}
|
||||
}
|
||||
|
||||
@@ -448,7 +435,7 @@ function normalizeFormatter(
|
||||
if (value !== undefined) encoded.formatter = value
|
||||
return
|
||||
}
|
||||
const entries = decodeMap(input.formatter, ConfigFormatter.Entry, ["formatter"], diagnostics, decodeEncoded)
|
||||
const entries = decodeEncodedMap(input.formatter, ConfigFormatter.Entry, ["formatter"], diagnostics)
|
||||
if (isRecord(input.formatter) && (!Object.keys(input.formatter).length || Object.keys(entries).length))
|
||||
encoded.formatter = entries
|
||||
}
|
||||
@@ -460,7 +447,7 @@ function normalizeLsp(input: Record<string, unknown>, encoded: Record<string, un
|
||||
if (value !== undefined) encoded.lsp = value
|
||||
return
|
||||
}
|
||||
const entries = decodeMap(input.lsp, ConfigLSP.Entry, ["lsp"], diagnostics, decodeEncoded)
|
||||
const entries = decodeEncodedMap(input.lsp, ConfigLSP.Entry, ["lsp"], diagnostics)
|
||||
if (isRecord(input.lsp) && (!Object.keys(input.lsp).length || Object.keys(entries).length)) encoded.lsp = entries
|
||||
}
|
||||
|
||||
@@ -610,44 +597,78 @@ function decodeProviderList(
|
||||
return {
|
||||
present: true,
|
||||
nonEmpty: input[key].length > 0,
|
||||
values: decodeList(input[key], Schema.String, [key], diagnostics, decodeValue),
|
||||
values: decodeList(input[key], Schema.String, [key], diagnostics),
|
||||
}
|
||||
}
|
||||
|
||||
function decodeMap<S extends Schema.Codec<unknown, unknown, never>, A>(
|
||||
function decodeEncodedMap<S extends Schema.Codec<unknown, unknown, never, never>>(
|
||||
value: unknown,
|
||||
schema: S,
|
||||
path: string[],
|
||||
diagnostics: Diagnostic[],
|
||||
decode: (schema: S, value: unknown, path: string[], diagnostics: Diagnostic[]) => A | undefined,
|
||||
): Record<string, A> {
|
||||
) {
|
||||
if (value === undefined) return {}
|
||||
if (!isRecord(value)) {
|
||||
invalid(path, diagnostics)
|
||||
return {}
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).flatMap(([name, raw]): [string, A][] => {
|
||||
const decoded = decode(schema, raw, [...path, name], diagnostics)
|
||||
Object.entries(value).flatMap(([name, raw]) => {
|
||||
const decoded = decodeEncoded(schema, raw, [...path, name], diagnostics)
|
||||
return decoded === undefined ? [] : [[name, decoded]]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function decodeList<S extends Schema.Codec<unknown, unknown, never>, A>(
|
||||
function decodeMap<S extends Schema.Codec<unknown, unknown, never, never>>(
|
||||
value: unknown,
|
||||
schema: S,
|
||||
path: string[],
|
||||
diagnostics: Diagnostic[],
|
||||
decode: (schema: S, value: unknown, path: string[], diagnostics: Diagnostic[]) => A | undefined,
|
||||
): A[] {
|
||||
if (value === undefined) return []
|
||||
) {
|
||||
if (value === undefined) return {} as Record<string, S["Type"]>
|
||||
if (!isRecord(value)) {
|
||||
invalid(path, diagnostics)
|
||||
return {} as Record<string, S["Type"]>
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).flatMap(([name, raw]) => {
|
||||
const decoded = decodeValue(schema, raw, [...path, name], diagnostics)
|
||||
return decoded === undefined ? [] : [[name, decoded]]
|
||||
}),
|
||||
) as Record<string, S["Type"]>
|
||||
}
|
||||
|
||||
function decodeEncodedList<S extends Schema.Codec<unknown, unknown, never, never>>(
|
||||
value: unknown,
|
||||
schema: S,
|
||||
path: string[],
|
||||
diagnostics: Diagnostic[],
|
||||
) {
|
||||
if (value === undefined) return [] as S["Encoded"][]
|
||||
if (!Array.isArray(value)) {
|
||||
invalid(path, diagnostics)
|
||||
return []
|
||||
return [] as S["Encoded"][]
|
||||
}
|
||||
return value.flatMap((item, index) => {
|
||||
const decoded = decode(schema, item, [...path, String(index)], diagnostics)
|
||||
const decoded = decodeEncoded(schema, item, [...path, String(index)], diagnostics)
|
||||
return decoded === undefined ? [] : [decoded]
|
||||
})
|
||||
}
|
||||
|
||||
function decodeList<S extends Schema.Codec<unknown, unknown, never, never>>(
|
||||
value: unknown,
|
||||
schema: S,
|
||||
path: string[],
|
||||
diagnostics: Diagnostic[],
|
||||
) {
|
||||
if (value === undefined) return [] as S["Type"][]
|
||||
if (!Array.isArray(value)) {
|
||||
invalid(path, diagnostics)
|
||||
return [] as S["Type"][]
|
||||
}
|
||||
return value.flatMap((item, index) => {
|
||||
const decoded = decodeValue(schema, item, [...path, String(index)], diagnostics)
|
||||
return decoded === undefined ? [] : [decoded]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -27,10 +27,8 @@ export const Plugin = define({
|
||||
const changes = yield* PubSub.sliding<string>(1)
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
const start = yield* fs.resolve(location.directory)
|
||||
const root = yield* fs.resolve(location.project.directory)
|
||||
const home = yield* fs.resolve(global.home)
|
||||
const project = discovery.project && FSUtil.contains(root, start)
|
||||
const stop = FSUtil.contains(home, start) ? home : root
|
||||
const stop = yield* fs.resolve(location.project.directory)
|
||||
const project = discovery.project && FSUtil.contains(stop, start)
|
||||
const globalFile = yield* fs.resolve(join(global.config, "AGENTS.md"))
|
||||
const loaded: { current: Loaded } = { current: { type: "available", files: [] } }
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Directory, Document, type Entry } from "@opencode-ai/schema/config"
|
||||
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Effect, Layer, Option, Predicate, PubSub, Schema, Scope, Stream } from "effect"
|
||||
import { Context, Effect, Layer, Option, PubSub, Scope, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { Config } from "../../config"
|
||||
@@ -154,67 +154,19 @@ const scan = Effect.fn("ConfigPluginSource.scan")(function* (
|
||||
})
|
||||
|
||||
const sourceDirectories = ["plugin", "plugins"] as const
|
||||
const Package = Schema.Struct({
|
||||
exports: Schema.optional(Schema.Unknown),
|
||||
module: Schema.optional(Schema.Unknown),
|
||||
main: Schema.optional(Schema.Unknown),
|
||||
})
|
||||
const decodePackage = Schema.decodeUnknownOption(Package)
|
||||
|
||||
function discoverDirectory(fs: FSUtil.Interface, directory: string) {
|
||||
return Effect.gen(function* () {
|
||||
const children = (yield* Effect.forEach(sourceDirectories, (source) =>
|
||||
fs.readDirectoryEntries(path.join(directory, source)).pipe(
|
||||
Effect.orElseSucceed(() => []),
|
||||
Effect.map((entries) =>
|
||||
entries.map((entry) => ({ ...entry, target: path.join(directory, source, entry.name) })),
|
||||
),
|
||||
),
|
||||
))
|
||||
.flat()
|
||||
.sort((a, b) => (a.target < b.target ? -1 : a.target > b.target ? 1 : 0))
|
||||
const targets = yield* Effect.forEach(children, (entry) => discoverChild(fs, entry))
|
||||
return targets.flatMap(Option.toArray).map((target): Operation => ({ type: "add", target, options: {} }))
|
||||
})
|
||||
}
|
||||
|
||||
function discoverChild(fs: FSUtil.Interface, entry: FSUtil.DirEntry & { target: string }) {
|
||||
return Effect.gen(function* () {
|
||||
const source = entry.target.endsWith(".ts") || entry.target.endsWith(".js")
|
||||
if (entry.type === "file" && source) return Option.some(entry.target)
|
||||
if (entry.type === "directory") return yield* discoverPackage(fs, entry.target)
|
||||
if (entry.type !== "symlink") return Option.none<string>()
|
||||
if (source && (yield* fs.isFile(entry.target))) return Option.some(entry.target)
|
||||
if (yield* fs.isDir(entry.target)) return yield* discoverPackage(fs, entry.target)
|
||||
return Option.none<string>()
|
||||
})
|
||||
}
|
||||
|
||||
function discoverPackage(fs: FSUtil.Interface, directory: string) {
|
||||
return Effect.gen(function* () {
|
||||
const root = yield* fs.resolve(directory)
|
||||
const manifest = yield* fs
|
||||
.readJson(path.join(directory, "package.json"))
|
||||
.pipe(Effect.map(decodePackage), Effect.orElseSucceed(Option.none))
|
||||
const configured = Option.isSome(manifest)
|
||||
? [manifest.value.exports, manifest.value.module, manifest.value.main].filter(Predicate.isString)
|
||||
: []
|
||||
return yield* Effect.findFirst(
|
||||
[...configured, "index.ts", "index.js"]
|
||||
.filter((entry) => !path.isAbsolute(entry))
|
||||
.map((entry) => path.resolve(directory, entry))
|
||||
.filter((entry) => FSUtil.contains(directory, entry)),
|
||||
(entry) =>
|
||||
fs
|
||||
.isFile(entry)
|
||||
.pipe(
|
||||
Effect.flatMap((exists) =>
|
||||
exists
|
||||
? fs.resolve(entry).pipe(Effect.map((resolved) => FSUtil.contains(root, resolved)))
|
||||
: Effect.succeed(false),
|
||||
),
|
||||
),
|
||||
)
|
||||
const files = yield* fs
|
||||
.scan(`{${sourceDirectories.join(",")}}/*.{ts,js}`, {
|
||||
cwd: directory,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
dot: true,
|
||||
symlink: true,
|
||||
})
|
||||
.pipe(Effect.orElseSucceed(() => []))
|
||||
return files.sort().map((target): Operation => ({ type: "add", target, options: {} }))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
export * as Database from "./database"
|
||||
|
||||
import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
|
||||
import { sqliteLayer, supportsForeignKeyToggle, supportsTuningPragmas } from "#sqlite"
|
||||
import { sqliteLayer } from "#sqlite"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import type { SqlClient } from "effect/unstable/sql"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { isAbsolute, join } from "path"
|
||||
import { DatabaseMigration } from "./migration"
|
||||
@@ -28,15 +27,12 @@ const databaseLayer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDatabase
|
||||
|
||||
if (supportsTuningPragmas) {
|
||||
yield* db.run("PRAGMA journal_mode = WAL")
|
||||
yield* db.run("PRAGMA synchronous = NORMAL")
|
||||
yield* db.run("PRAGMA busy_timeout = 5000")
|
||||
yield* db.run("PRAGMA cache_size = -64000")
|
||||
yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
|
||||
}
|
||||
// Durable Object SQLite always enforces foreign keys and rejects the pragma.
|
||||
if (supportsForeignKeyToggle) yield* db.run("PRAGMA foreign_keys = ON")
|
||||
yield* db.run("PRAGMA journal_mode = WAL")
|
||||
yield* db.run("PRAGMA synchronous = NORMAL")
|
||||
yield* db.run("PRAGMA busy_timeout = 5000")
|
||||
yield* db.run("PRAGMA cache_size = -64000")
|
||||
yield* db.run("PRAGMA foreign_keys = ON")
|
||||
yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
|
||||
yield* DatabaseMigration.apply(db)
|
||||
|
||||
return { db }
|
||||
@@ -46,7 +42,7 @@ const databaseLayer = Layer.effect(
|
||||
export function layer(options: Options = { path: ":memory:" }) {
|
||||
return Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const provide = (filename: string) => layerFromClient.pipe(Layer.provide(sqliteLayer({ filename })))
|
||||
const provide = (filename: string) => databaseLayer.pipe(Layer.provide(sqliteLayer({ filename })))
|
||||
const filename = options.path ?? ":memory:"
|
||||
if (filename === ":memory:" || isAbsolute(filename)) return provide(filename)
|
||||
const global = yield* Global.Service
|
||||
@@ -55,12 +51,6 @@ export function layer(options: Options = { path: ":memory:" }) {
|
||||
)
|
||||
}
|
||||
|
||||
// The database service over an injected SqlClient, for runtimes that receive
|
||||
// database storage instead of opening a filesystem path. Any client provided
|
||||
// here still goes through the pragma guards and migrations; Global is required
|
||||
// because migrations may read it (the v1 import).
|
||||
export const layerFromClient: Layer.Layer<Service, never, SqlClient.SqlClient | Global.Service> = databaseLayer
|
||||
|
||||
export function configured(options?: Options) {
|
||||
return makeGlobalNode({ service: Service, layer: layer(options), deps: [Global.node] })
|
||||
}
|
||||
|
||||
-2
@@ -40,7 +40,6 @@ import m37 from "./migration/20260622202450_simplify_session_input"
|
||||
import m38 from "./migration/20260804233008_loose_psylocke"
|
||||
import m39 from "./migration/20260805200742_import_legacy_credentials"
|
||||
import m40 from "./migration/20260808023530_workspace_domain"
|
||||
import m41 from "./migration/20260811161259_execution_claim_attempts"
|
||||
|
||||
export const migrations = [
|
||||
m00,
|
||||
@@ -84,5 +83,4 @@ export const migrations = [
|
||||
m38,
|
||||
m39,
|
||||
m40,
|
||||
m41,
|
||||
] satisfies DatabaseMigration.Migration[]
|
||||
|
||||
@@ -2,7 +2,6 @@ export * as DatabaseMigration from "./migration"
|
||||
|
||||
import { sql } from "drizzle-orm"
|
||||
import { Effect, Semaphore } from "effect"
|
||||
import { supportsForeignKeyToggle } from "#sqlite"
|
||||
import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
|
||||
import { migrations } from "./migration.gen"
|
||||
import schema from "./schema.gen"
|
||||
@@ -21,10 +20,8 @@ export type Migration = {
|
||||
export function apply(db: Database) {
|
||||
return lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
// OpenCode owns the unprefixed table namespace. Embedders sharing this
|
||||
// database may own underscore-prefixed tables, which bootstrap ignores.
|
||||
const tables = yield* db.all<{ name: string }>(
|
||||
sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND substr(name, 1, 1) <> '_'`,
|
||||
sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`,
|
||||
)
|
||||
if (tables.some((table) => table.name === "session" || table.name === "session_v2"))
|
||||
return yield* applyOnly(db, migrations)
|
||||
@@ -106,15 +103,9 @@ export function applyOnly(db: Database, input: Migration[]) {
|
||||
})
|
||||
continue
|
||||
}
|
||||
// Durable Object SQLite rejects the foreign_keys toggle; the closest
|
||||
// allowlisted relaxation is deferring enforcement to transaction commit.
|
||||
const relaxForeignKeys = supportsForeignKeyToggle
|
||||
? db.run(sql`PRAGMA foreign_keys = OFF`)
|
||||
: db.run(sql`PRAGMA defer_foreign_keys = ON`)
|
||||
const restoreForeignKeys = supportsForeignKeyToggle ? db.run(sql`PRAGMA foreign_keys = ON`) : Effect.void
|
||||
yield* relaxForeignKeys
|
||||
yield* db.run(sql`PRAGMA foreign_keys = OFF`)
|
||||
yield* apply.pipe(
|
||||
Effect.ensuring(restoreForeignKeys.pipe(Effect.orDie)),
|
||||
Effect.ensuring(db.run(sql`PRAGMA foreign_keys = ON`).pipe(Effect.orDie)),
|
||||
Effect.tapError((error) =>
|
||||
Effect.logError("database migration failed", {
|
||||
migration: migration.id,
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260811161259_execution_claim_attempts",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`ALTER TABLE \`session_v2\` ADD \`resume_attempts\` integer DEFAULT 0 NOT NULL;`)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export default migration
|
||||
@@ -200,7 +200,6 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
|
||||
\`time_compacting\` integer,
|
||||
\`time_archived\` integer,
|
||||
\`time_suspended\` integer,
|
||||
\`resume_attempts\` integer DEFAULT 0 NOT NULL,
|
||||
CONSTRAINT \`fk_session_v2_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
|
||||
@@ -1,37 +1,58 @@
|
||||
import { Database, type SQLQueryBindings } from "bun:sqlite"
|
||||
import { Database } from "bun:sqlite"
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Context, Effect, Fiber, Layer, Scope, Semaphore, Stream } from "effect"
|
||||
import { identity } from "effect/Function"
|
||||
import { Reactivity } from "effect/unstable/reactivity"
|
||||
import { SqlClient } from "effect/unstable/sql"
|
||||
import { SqlClient, Statement } from "effect/unstable/sql"
|
||||
import type { Connection } from "effect/unstable/sql/SqlConnection"
|
||||
import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError"
|
||||
import { Sqlite } from "./sqlite"
|
||||
|
||||
const ATTR_DB_SYSTEM_NAME = "db.system.name"
|
||||
|
||||
const TypeId = "~@opencode-ai/core/database/SqliteBun" as const
|
||||
type TypeId = typeof TypeId
|
||||
|
||||
export const supportsTuningPragmas = true
|
||||
interface SqliteClient extends SqlClient.SqlClient {
|
||||
readonly [TypeId]: TypeId
|
||||
readonly config: Config
|
||||
readonly export: Effect.Effect<Uint8Array, SqlError>
|
||||
readonly loadExtension: (path: string) => Effect.Effect<void, SqlError>
|
||||
readonly updateValues: never
|
||||
}
|
||||
|
||||
// Foreign keys default OFF and can be toggled per connection.
|
||||
export const supportsForeignKeyToggle = true
|
||||
|
||||
interface Config extends Sqlite.ClientConfig {
|
||||
interface Config {
|
||||
readonly filename: string
|
||||
readonly readonly?: boolean
|
||||
readonly create?: boolean
|
||||
readonly readwrite?: boolean
|
||||
readonly disableWAL?: boolean
|
||||
readonly spanAttributes?: Record<string, unknown>
|
||||
readonly transformResultNames?: (str: string) => string
|
||||
readonly transformQueryNames?: (str: string) => string
|
||||
}
|
||||
|
||||
interface SqliteConnection extends Connection {
|
||||
readonly export: Effect.Effect<Uint8Array, SqlError>
|
||||
readonly loadExtension: (path: string) => Effect.Effect<void, SqlError>
|
||||
}
|
||||
|
||||
const make = (options: Config) =>
|
||||
Effect.gen(function* () {
|
||||
const native = (yield* Sqlite.Native) as Database
|
||||
|
||||
const compiler = Statement.makeCompilerSqlite(options.transformQueryNames)
|
||||
const transformRows = options.transformResultNames
|
||||
? Statement.defaultTransforms(options.transformResultNames).array
|
||||
: undefined
|
||||
|
||||
const run = (query: string, params: ReadonlyArray<unknown> = []) =>
|
||||
Effect.withFiber<Array<Record<string, unknown>>, SqlError>((fiber) => {
|
||||
const statement = native.query<Record<string, unknown>, SQLQueryBindings[]>(query)
|
||||
const statement = native.query(query)
|
||||
// @ts-ignore bun-types missing safeIntegers method, fixed in https://github.com/oven-sh/bun/pull/26627
|
||||
statement.safeIntegers(Context.get(fiber.context, SqlClient.SafeIntegers))
|
||||
try {
|
||||
return Effect.succeed(statement.all(...(params as SQLQueryBindings[])) ?? [])
|
||||
return Effect.succeed((statement.all(...(params as any)) ?? []) as Array<Record<string, unknown>>)
|
||||
} catch (cause) {
|
||||
return Effect.fail(
|
||||
new SqlError({
|
||||
@@ -43,11 +64,11 @@ const make = (options: Config) =>
|
||||
|
||||
const runValues = (query: string, params: ReadonlyArray<unknown> = []) =>
|
||||
Effect.withFiber<Array<unknown[]>, SqlError>((fiber) => {
|
||||
const statement = native.query<unknown, SQLQueryBindings[]>(query)
|
||||
const statement = native.query(query)
|
||||
// @ts-ignore bun-types missing safeIntegers method, fixed in https://github.com/oven-sh/bun/pull/26627
|
||||
statement.safeIntegers(Context.get(fiber.context, SqlClient.SafeIntegers))
|
||||
try {
|
||||
return Effect.succeed(statement.values(...(params as SQLQueryBindings[])) ?? [])
|
||||
return Effect.succeed((statement.values(...(params as any)) ?? []) as Array<unknown[]>)
|
||||
} catch (cause) {
|
||||
return Effect.fail(
|
||||
new SqlError({
|
||||
@@ -57,7 +78,25 @@ const make = (options: Config) =>
|
||||
}
|
||||
})
|
||||
|
||||
const connection = Sqlite.makeConnection(run, runValues, {
|
||||
const connection = identity<SqliteConnection>({
|
||||
execute(query, params, transformRows) {
|
||||
return transformRows ? Effect.map(run(query, params), transformRows) : run(query, params)
|
||||
},
|
||||
executeRaw(query, params) {
|
||||
return run(query, params)
|
||||
},
|
||||
executeValues(query, params) {
|
||||
return runValues(query, params)
|
||||
},
|
||||
executeValuesUnprepared(query, params) {
|
||||
return runValues(query, params)
|
||||
},
|
||||
executeUnprepared(query, params, transformRows) {
|
||||
return this.execute(query, params, transformRows)
|
||||
},
|
||||
executeStream() {
|
||||
return Stream.die("executeStream not implemented")
|
||||
},
|
||||
export: Effect.try({
|
||||
try: () => native.serialize(),
|
||||
catch: (cause) =>
|
||||
@@ -65,7 +104,7 @@ const make = (options: Config) =>
|
||||
reason: classifySqliteError(cause, { message: "Failed to export database", operation: "export" }),
|
||||
}),
|
||||
}),
|
||||
loadExtension: (path: string) =>
|
||||
loadExtension: (path) =>
|
||||
Effect.try({
|
||||
try: () => native.loadExtension(path),
|
||||
catch: (cause) =>
|
||||
@@ -75,10 +114,37 @@ const make = (options: Config) =>
|
||||
}),
|
||||
})
|
||||
|
||||
return yield* Sqlite.makeClient(options, connection, TypeId, (acquirer) => ({
|
||||
export: Effect.flatMap(acquirer, (_) => _.export),
|
||||
loadExtension: (path: string) => Effect.flatMap(acquirer, (_) => _.loadExtension(path)),
|
||||
}))
|
||||
const semaphore = yield* Semaphore.make(1)
|
||||
const acquirer = semaphore.withPermits(1)(Effect.succeed(connection))
|
||||
const transactionAcquirer = Effect.uninterruptibleMask((restore) => {
|
||||
const fiber = Fiber.getCurrent()!
|
||||
const scope = Context.getUnsafe(fiber.context, Scope.Scope)
|
||||
return Effect.as(
|
||||
Effect.tap(restore(semaphore.take(1)), () => Scope.addFinalizer(scope, semaphore.release(1))),
|
||||
connection,
|
||||
)
|
||||
})
|
||||
|
||||
const client = Object.assign(
|
||||
(yield* SqlClient.make({
|
||||
acquirer,
|
||||
compiler,
|
||||
transactionAcquirer,
|
||||
spanAttributes: [
|
||||
...(options.spanAttributes ? Object.entries(options.spanAttributes) : []),
|
||||
[ATTR_DB_SYSTEM_NAME, "sqlite"],
|
||||
],
|
||||
transformRows,
|
||||
})) as SqliteClient,
|
||||
{
|
||||
[TypeId]: TypeId,
|
||||
config: options,
|
||||
export: Effect.flatMap(acquirer, (_) => _.export),
|
||||
loadExtension: (path: string) => Effect.flatMap(acquirer, (_) => _.loadExtension(path)),
|
||||
},
|
||||
)
|
||||
|
||||
return client
|
||||
})
|
||||
|
||||
const nativeLayer = (config: Config) =>
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
import { DatabaseSync, type SQLInputValue } from "node:sqlite"
|
||||
import { drizzle } from "drizzle-orm/node-sqlite"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Context, Effect, Fiber, Layer, Scope, Semaphore, Stream } from "effect"
|
||||
import { identity } from "effect/Function"
|
||||
import { Reactivity } from "effect/unstable/reactivity"
|
||||
import { SqlClient } from "effect/unstable/sql"
|
||||
import { SqlClient, Statement } from "effect/unstable/sql"
|
||||
import type { Connection } from "effect/unstable/sql/SqlConnection"
|
||||
import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError"
|
||||
import { Sqlite } from "./sqlite"
|
||||
|
||||
const ATTR_DB_SYSTEM_NAME = "db.system.name"
|
||||
|
||||
const TypeId = "~@opencode-ai/core/database/SqliteNode" as const
|
||||
type TypeId = typeof TypeId
|
||||
|
||||
export const supportsTuningPragmas = true
|
||||
interface SqliteClient extends SqlClient.SqlClient {
|
||||
readonly [TypeId]: TypeId
|
||||
readonly config: Config
|
||||
readonly loadExtension: (path: string) => Effect.Effect<void, SqlError>
|
||||
readonly updateValues: never
|
||||
}
|
||||
|
||||
// Foreign keys default OFF and can be toggled per connection.
|
||||
export const supportsForeignKeyToggle = true
|
||||
|
||||
interface Config extends Sqlite.ClientConfig {
|
||||
interface Config {
|
||||
readonly filename: string
|
||||
readonly readonly?: boolean
|
||||
readonly create?: boolean
|
||||
@@ -21,12 +28,24 @@ interface Config extends Sqlite.ClientConfig {
|
||||
readonly disableWAL?: boolean
|
||||
readonly timeout?: number
|
||||
readonly allowExtension?: boolean
|
||||
readonly spanAttributes?: Record<string, unknown>
|
||||
readonly transformResultNames?: (str: string) => string
|
||||
readonly transformQueryNames?: (str: string) => string
|
||||
}
|
||||
|
||||
interface SqliteConnection extends Connection {
|
||||
readonly loadExtension: (path: string) => Effect.Effect<void, SqlError>
|
||||
}
|
||||
|
||||
const make = (options: Config) =>
|
||||
Effect.gen(function* () {
|
||||
const native = (yield* Sqlite.Native) as DatabaseSync
|
||||
|
||||
const compiler = Statement.makeCompilerSqlite(options.transformQueryNames)
|
||||
const transformRows = options.transformResultNames
|
||||
? Statement.defaultTransforms(options.transformResultNames).array
|
||||
: undefined
|
||||
|
||||
const run = (query: string, params: ReadonlyArray<unknown> = []) =>
|
||||
Effect.withFiber<Array<Record<string, unknown>>, SqlError>((fiber) => {
|
||||
const statement = native.prepare(query)
|
||||
@@ -60,8 +79,26 @@ const make = (options: Config) =>
|
||||
}
|
||||
})
|
||||
|
||||
const connection = Sqlite.makeConnection(run, runValues, {
|
||||
loadExtension: (path: string) =>
|
||||
const connection = identity<SqliteConnection>({
|
||||
execute(query, params, transformRows) {
|
||||
return transformRows ? Effect.map(run(query, params), transformRows) : run(query, params)
|
||||
},
|
||||
executeRaw(query, params) {
|
||||
return run(query, params)
|
||||
},
|
||||
executeValues(query, params) {
|
||||
return runValues(query, params)
|
||||
},
|
||||
executeValuesUnprepared(query, params) {
|
||||
return runValues(query, params)
|
||||
},
|
||||
executeUnprepared(query, params, transformRows) {
|
||||
return this.execute(query, params, transformRows)
|
||||
},
|
||||
executeStream() {
|
||||
return Stream.die("executeStream not implemented")
|
||||
},
|
||||
loadExtension: (path) =>
|
||||
Effect.try({
|
||||
try: () => native.loadExtension(path),
|
||||
catch: (cause) =>
|
||||
@@ -71,9 +108,36 @@ const make = (options: Config) =>
|
||||
}),
|
||||
})
|
||||
|
||||
return yield* Sqlite.makeClient(options, connection, TypeId, (acquirer) => ({
|
||||
loadExtension: (path: string) => Effect.flatMap(acquirer, (_) => _.loadExtension(path)),
|
||||
}))
|
||||
const semaphore = yield* Semaphore.make(1)
|
||||
const acquirer = semaphore.withPermits(1)(Effect.succeed(connection))
|
||||
const transactionAcquirer = Effect.uninterruptibleMask((restore) => {
|
||||
const fiber = Fiber.getCurrent()!
|
||||
const scope = Context.getUnsafe(fiber.context, Scope.Scope)
|
||||
return Effect.as(
|
||||
Effect.tap(restore(semaphore.take(1)), () => Scope.addFinalizer(scope, semaphore.release(1))),
|
||||
connection,
|
||||
)
|
||||
})
|
||||
|
||||
const client = Object.assign(
|
||||
(yield* SqlClient.make({
|
||||
acquirer,
|
||||
compiler,
|
||||
transactionAcquirer,
|
||||
spanAttributes: [
|
||||
...(options.spanAttributes ? Object.entries(options.spanAttributes) : []),
|
||||
[ATTR_DB_SYSTEM_NAME, "sqlite"],
|
||||
],
|
||||
transformRows,
|
||||
})) as SqliteClient,
|
||||
{
|
||||
[TypeId]: TypeId,
|
||||
config: options,
|
||||
loadExtension: (path: string) => Effect.flatMap(acquirer, (_) => _.loadExtension(path)),
|
||||
},
|
||||
)
|
||||
|
||||
return client
|
||||
})
|
||||
|
||||
const nativeLayer = (config: Config) =>
|
||||
|
||||
@@ -1,100 +1,8 @@
|
||||
export * as Sqlite from "./sqlite"
|
||||
|
||||
import { Context, Effect, Fiber, Scope, Semaphore, Stream } from "effect"
|
||||
import { identity } from "effect/Function"
|
||||
import { SqlClient, Statement } from "effect/unstable/sql"
|
||||
import type { Connection } from "effect/unstable/sql/SqlConnection"
|
||||
import type { SqlError } from "effect/unstable/sql/SqlError"
|
||||
import { Context } from "effect"
|
||||
import type { drizzle } from "drizzle-orm/bun-sqlite"
|
||||
|
||||
export type DrizzleClient = ReturnType<typeof drizzle>
|
||||
export class Native extends Context.Service<Native, unknown>()("@opencode-ai/core/database/SqliteNative") {}
|
||||
export class Drizzle extends Context.Service<Drizzle, DrizzleClient>()("@opencode-ai/core/database/SqliteDrizzle") {}
|
||||
|
||||
export interface ClientConfig {
|
||||
readonly spanAttributes?: Record<string, unknown>
|
||||
readonly transformResultNames?: (str: string) => string
|
||||
readonly transformQueryNames?: (str: string) => string
|
||||
}
|
||||
|
||||
type Run = (
|
||||
query: string,
|
||||
params?: ReadonlyArray<unknown>,
|
||||
) => Effect.Effect<ReadonlyArray<Record<string, unknown>>, SqlError>
|
||||
|
||||
type RunValues = (
|
||||
query: string,
|
||||
params?: ReadonlyArray<unknown>,
|
||||
) => Effect.Effect<ReadonlyArray<ReadonlyArray<unknown>>, SqlError>
|
||||
|
||||
export const makeConnection = <Extensions extends object>(run: Run, runValues: RunValues, extensions: Extensions) =>
|
||||
identity<Connection & Extensions>({
|
||||
execute(query, params, transformRows) {
|
||||
return transformRows ? Effect.map(run(query, params), transformRows) : run(query, params)
|
||||
},
|
||||
executeRaw(query, params) {
|
||||
return run(query, params)
|
||||
},
|
||||
executeValues(query, params) {
|
||||
return runValues(query, params)
|
||||
},
|
||||
executeValuesUnprepared(query, params) {
|
||||
return runValues(query, params)
|
||||
},
|
||||
executeUnprepared(query, params, transformRows) {
|
||||
return this.execute(query, params, transformRows)
|
||||
},
|
||||
executeStream() {
|
||||
return Stream.die("executeStream not implemented")
|
||||
},
|
||||
...extensions,
|
||||
})
|
||||
|
||||
export const makeClient = <
|
||||
Config extends ClientConfig,
|
||||
SqliteConnection extends Connection,
|
||||
const TypeId extends string,
|
||||
Extensions extends object,
|
||||
>(
|
||||
options: Config,
|
||||
connection: SqliteConnection,
|
||||
typeId: TypeId,
|
||||
extensions: (acquirer: Effect.Effect<SqliteConnection, SqlError, Scope.Scope>) => Extensions,
|
||||
) =>
|
||||
Effect.gen(function* () {
|
||||
const semaphore = yield* Semaphore.make(1)
|
||||
const acquirer = semaphore.withPermits(1)(Effect.succeed(connection))
|
||||
const transactionAcquirer = Effect.uninterruptibleMask((restore) => {
|
||||
const fiber = Fiber.getCurrent()!
|
||||
const scope = Context.getUnsafe(fiber.context, Scope.Scope)
|
||||
return Effect.as(
|
||||
Effect.tap(restore(semaphore.take(1)), () => Scope.addFinalizer(scope, semaphore.release(1))),
|
||||
connection,
|
||||
)
|
||||
})
|
||||
const transformRows = options.transformResultNames
|
||||
? Statement.defaultTransforms(options.transformResultNames).array
|
||||
: undefined
|
||||
|
||||
return Object.assign(
|
||||
yield* SqlClient.make({
|
||||
acquirer,
|
||||
compiler: Statement.makeCompilerSqlite(options.transformQueryNames),
|
||||
transactionAcquirer,
|
||||
spanAttributes: [
|
||||
...(options.spanAttributes ? Object.entries(options.spanAttributes) : []),
|
||||
["db.system.name", "sqlite"],
|
||||
],
|
||||
transformRows,
|
||||
}),
|
||||
{
|
||||
[typeId]: typeId,
|
||||
config: options,
|
||||
...extensions(acquirer),
|
||||
},
|
||||
) as SqlClient.SqlClient &
|
||||
Record<TypeId, TypeId> & {
|
||||
readonly config: Config
|
||||
readonly updateValues: never
|
||||
} & Extensions
|
||||
})
|
||||
|
||||
@@ -1,254 +0,0 @@
|
||||
import { drizzle } from "drizzle-orm/durable-sqlite"
|
||||
import { Context, Effect, Exit, Fiber, Layer, Scope, Semaphore, Stream } from "effect"
|
||||
import { identity } from "effect/Function"
|
||||
import { Reactivity } from "effect/unstable/reactivity"
|
||||
import { SqlClient, Statement } from "effect/unstable/sql"
|
||||
import type { Connection } from "effect/unstable/sql/SqlConnection"
|
||||
import { classifySqliteError, SqlError, UnknownError } from "effect/unstable/sql/SqlError"
|
||||
import { Sqlite } from "./sqlite"
|
||||
|
||||
const ATTR_DB_SYSTEM_NAME = "db.system.name"
|
||||
|
||||
const TypeId = "~@opencode-ai/core/database/SqliteWorkerd" as const
|
||||
type TypeId = typeof TypeId
|
||||
|
||||
// Durable Object SQLite only allowlists introspection pragmas; journal_mode,
|
||||
// synchronous, busy_timeout, cache_size, and wal_checkpoint all throw, and
|
||||
// foreign keys are already enforced by default (SQLITE_DEFAULT_FOREIGN_KEYS=1).
|
||||
export const supportsTuningPragmas = false
|
||||
|
||||
// Durable Object SQLite rejects `PRAGMA foreign_keys`: enforcement is always
|
||||
// on (SQLITE_DEFAULT_FOREIGN_KEYS=1) and only `defer_foreign_keys` is
|
||||
// allowlisted for migrations that must relax checking inside a transaction.
|
||||
export const supportsForeignKeyToggle = false
|
||||
|
||||
// Minimal structural types for the Durable Object storage API so this adapter
|
||||
// does not depend on @cloudflare/workers-types (whose ambient globals conflict
|
||||
// with @types/bun). Shapes match the SqlStorage and DurableObjectStorage docs.
|
||||
type SqlStorageValue = ArrayBuffer | string | number | null
|
||||
|
||||
interface SqlStorageCursor {
|
||||
readonly columnNames: Array<string>
|
||||
raw(): IterableIterator<Array<SqlStorageValue>>
|
||||
toArray(): Array<Record<string, SqlStorageValue>>
|
||||
}
|
||||
|
||||
export interface SqlStorage {
|
||||
exec(query: string, ...bindings: Array<unknown>): SqlStorageCursor
|
||||
}
|
||||
|
||||
export interface DurableObjectStorage {
|
||||
readonly sql: SqlStorage
|
||||
transaction<T>(closure: (txn: { rollback(): void }) => Promise<T>): Promise<T>
|
||||
transactionSync<T>(closure: () => T): T
|
||||
}
|
||||
|
||||
interface SqliteClient extends SqlClient.SqlClient {
|
||||
readonly [TypeId]: TypeId
|
||||
readonly config: Config
|
||||
readonly updateValues: never
|
||||
}
|
||||
|
||||
interface Config {
|
||||
readonly storage: DurableObjectStorage
|
||||
readonly spanAttributes?: Record<string, unknown>
|
||||
readonly transformResultNames?: (str: string) => string
|
||||
readonly transformQueryNames?: (str: string) => string
|
||||
}
|
||||
|
||||
// sql.exec() rejects BEGIN/COMMIT/SAVEPOINT, so SqlClient.make's default
|
||||
// transaction SQL can never run. withTransaction is replaced below with a
|
||||
// DurableObjectStorage.transaction-backed implementation; this service only
|
||||
// tracks the active transaction connection for statements and nesting checks.
|
||||
const WorkerdTransaction = Context.Service<SqlClient.TransactionConnection, SqlClient.TransactionConnection.Service>(
|
||||
"@opencode-ai/core/database/SqliteWorkerdTransaction",
|
||||
)
|
||||
|
||||
const transactionError = (message: string) =>
|
||||
new SqlError({
|
||||
reason: new UnknownError({ cause: new Error(message), message, operation: "transaction" }),
|
||||
})
|
||||
|
||||
const makeWithTransaction =
|
||||
(
|
||||
storage: DurableObjectStorage,
|
||||
connection: Connection,
|
||||
semaphore: Semaphore.Semaphore,
|
||||
): SqlClient.SqlClient["withTransaction"] =>
|
||||
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E | SqlError, R> =>
|
||||
Effect.withFiber((fiber) => {
|
||||
const services = fiber.context
|
||||
if (Context.getOption(services, WorkerdTransaction)._tag === "Some")
|
||||
return Effect.fail(
|
||||
transactionError("Nested transactions are not supported by Cloudflare Durable Object SQLite storage"),
|
||||
)
|
||||
const effectWithTxn = Effect.provideContext(
|
||||
effect,
|
||||
Context.add(services, WorkerdTransaction, [connection, 0] as const),
|
||||
)
|
||||
return semaphore.withPermits(1)(
|
||||
Effect.callback((resume) => {
|
||||
let interrupted = false
|
||||
const promise = storage
|
||||
.transaction(
|
||||
(txn) =>
|
||||
new Promise<void>((resolve) => {
|
||||
if (interrupted) return resolve()
|
||||
resume(
|
||||
Effect.onExit(effectWithTxn, (exit) => {
|
||||
if (Exit.isFailure(exit)) txn.rollback()
|
||||
resolve()
|
||||
// wait for the transaction to complete
|
||||
return Effect.promise(() => promise)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.catch((cause) =>
|
||||
resume(
|
||||
Effect.fail(
|
||||
new SqlError({
|
||||
reason: classifySqliteError(cause, { message: "Failed transaction", operation: "transaction" }),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
return Effect.suspend(() => {
|
||||
interrupted = true
|
||||
return Effect.promise(() => promise)
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const make = (options: Config) =>
|
||||
Effect.gen(function* () {
|
||||
const native = (yield* Sqlite.Native) as DurableObjectStorage
|
||||
|
||||
const compiler = Statement.makeCompilerSqlite(options.transformQueryNames)
|
||||
const transformRows = options.transformResultNames
|
||||
? Statement.defaultTransforms(options.transformResultNames).array
|
||||
: undefined
|
||||
|
||||
// SqlClient.SafeIntegers is ignored: Durable Object SQLite has no bigint
|
||||
// mode and always returns integers as numbers. Blobs come back as
|
||||
// ArrayBuffer and are normalized to Uint8Array to match the other adapters.
|
||||
function* runIterator(query: string, params: ReadonlyArray<unknown> = []) {
|
||||
const cursor = native.sql.exec(query, ...params)
|
||||
const columns = cursor.columnNames
|
||||
for (const row of cursor.raw()) {
|
||||
const record: Record<string, unknown> = {}
|
||||
for (let i = 0; i < columns.length; i++) {
|
||||
const value = row[i]
|
||||
record[columns[i]] = value instanceof ArrayBuffer ? new Uint8Array(value) : value
|
||||
}
|
||||
yield record
|
||||
}
|
||||
}
|
||||
|
||||
const run = (query: string, params: ReadonlyArray<unknown> = []) =>
|
||||
Effect.try({
|
||||
try: () => Array.from(runIterator(query, params)),
|
||||
catch: (cause) =>
|
||||
new SqlError({
|
||||
reason: classifySqliteError(cause, { message: "Failed to execute statement", operation: "execute" }),
|
||||
}),
|
||||
})
|
||||
|
||||
const runValues = (query: string, params: ReadonlyArray<unknown> = []) =>
|
||||
Effect.try({
|
||||
try: () =>
|
||||
Array.from(native.sql.exec(query, ...params).raw(), (row) =>
|
||||
row.map((value) => (value instanceof ArrayBuffer ? new Uint8Array(value) : value)),
|
||||
),
|
||||
catch: (cause) =>
|
||||
new SqlError({
|
||||
reason: classifySqliteError(cause, { message: "Failed to execute statement", operation: "execute" }),
|
||||
}),
|
||||
})
|
||||
|
||||
const connection = identity<Connection>({
|
||||
execute(query, params, transformRows) {
|
||||
return transformRows ? Effect.map(run(query, params), transformRows) : run(query, params)
|
||||
},
|
||||
executeRaw(query, params) {
|
||||
return run(query, params)
|
||||
},
|
||||
executeValues(query, params) {
|
||||
return runValues(query, params)
|
||||
},
|
||||
executeValuesUnprepared(query, params) {
|
||||
return runValues(query, params)
|
||||
},
|
||||
executeUnprepared(query, params, transformRows) {
|
||||
return this.execute(query, params, transformRows)
|
||||
},
|
||||
executeStream() {
|
||||
return Stream.die("executeStream not implemented")
|
||||
},
|
||||
})
|
||||
|
||||
const semaphore = yield* Semaphore.make(1)
|
||||
const acquirer = semaphore.withPermits(1)(Effect.succeed(connection))
|
||||
const transactionAcquirer = Effect.uninterruptibleMask((restore) => {
|
||||
const fiber = Fiber.getCurrent()!
|
||||
const scope = Context.getUnsafe(fiber.context, Scope.Scope)
|
||||
return Effect.as(
|
||||
Effect.tap(restore(semaphore.take(1)), () => Scope.addFinalizer(scope, semaphore.release(1))),
|
||||
connection,
|
||||
)
|
||||
})
|
||||
|
||||
const client = Object.assign(
|
||||
(yield* SqlClient.make({
|
||||
acquirer,
|
||||
compiler,
|
||||
transactionAcquirer,
|
||||
transactionService: WorkerdTransaction,
|
||||
spanAttributes: [
|
||||
...(options.spanAttributes ? Object.entries(options.spanAttributes) : []),
|
||||
[ATTR_DB_SYSTEM_NAME, "sqlite"],
|
||||
],
|
||||
transformRows,
|
||||
})) as SqliteClient,
|
||||
{
|
||||
[TypeId]: TypeId,
|
||||
config: options,
|
||||
withTransaction: makeWithTransaction(native, connection, semaphore),
|
||||
// Durable Object SQLite rejects BEGIN/COMMIT/SAVEPOINT; consumers such
|
||||
// as the drizzle session must route through withTransaction instead.
|
||||
transactionStatements: false,
|
||||
},
|
||||
)
|
||||
|
||||
return client
|
||||
})
|
||||
|
||||
// Defends against the shared path-based Database.layer, which passes a
|
||||
// filename instead of storage when resolved under the workerd condition.
|
||||
const nativeLayer = (config: Config) =>
|
||||
config.storage
|
||||
? Layer.succeed(Sqlite.Native, config.storage)
|
||||
: Layer.effect(
|
||||
Sqlite.Native,
|
||||
Effect.die(
|
||||
"workerd sqlite cannot open a database from a path; use Database.layerWith(sqliteLayer({ storage }))",
|
||||
),
|
||||
)
|
||||
|
||||
const clientLayer = (config: Config) => Layer.effect(SqlClient.SqlClient, make(config))
|
||||
|
||||
const drizzleLayer = Layer.effect(
|
||||
Sqlite.Drizzle,
|
||||
Effect.gen(function* () {
|
||||
const native = (yield* Sqlite.Native) as DurableObjectStorage
|
||||
return drizzle(native) as unknown as Sqlite.DrizzleClient
|
||||
}),
|
||||
)
|
||||
|
||||
export const sqliteLayer = (config: Config) => {
|
||||
const native = nativeLayer(config)
|
||||
return Layer.merge(native, Layer.merge(clientLayer(config), drizzleLayer).pipe(Layer.provide(native))).pipe(
|
||||
Layer.provide(Reactivity.layer),
|
||||
)
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { SessionV1 } from "@opencode-ai/schema/session-v1"
|
||||
import { SessionMessage } from "../session/message"
|
||||
import { SessionSchema } from "../session/schema"
|
||||
import { KVTable } from "../kv/sql"
|
||||
import { EventSequenceTable } from "../event/sql"
|
||||
import { EventSequenceTable, EventTable } from "../event/sql"
|
||||
import { eq, sql } from "drizzle-orm"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { existsSync } from "node:fs"
|
||||
@@ -161,7 +161,6 @@ type NextMessage = {
|
||||
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
const MIGRATION_STATE_KEY = "migration.v1-v2"
|
||||
const EVENT_DELETE_BATCH_SIZE = 1_000
|
||||
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
|
||||
const decodeMessage = Schema.decodeUnknownOption(SessionV1.Info)
|
||||
const decodePart = Schema.decodeUnknownOption(SessionV1.Part)
|
||||
@@ -486,15 +485,7 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
|
||||
yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
while (true) {
|
||||
yield* tx.run(sql`
|
||||
DELETE FROM event
|
||||
WHERE rowid IN (SELECT rowid FROM event LIMIT ${EVENT_DELETE_BATCH_SIZE})
|
||||
`)
|
||||
const deleted = (yield* tx.get<{ value: number }>(sql`SELECT changes() AS value`))?.value ?? 0
|
||||
if (deleted < EVENT_DELETE_BATCH_SIZE) break
|
||||
yield* Effect.yieldNow
|
||||
}
|
||||
yield* tx.delete(EventTable).run()
|
||||
yield* tx
|
||||
.insert(KVTable)
|
||||
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions" } })
|
||||
|
||||
@@ -1,15 +1,102 @@
|
||||
import { FileFinder } from "@ff-labs/fff-bun"
|
||||
import { bind } from "./fff"
|
||||
|
||||
export type { Directory, DirSearch, File, Init, Mixed, MixedSearch, Picker, Result, Search } from "./fff"
|
||||
import {
|
||||
FileFinder,
|
||||
type DirItem,
|
||||
type DirSearchResult,
|
||||
type FileItem,
|
||||
type InitOptions,
|
||||
type MixedItem,
|
||||
type MixedSearchResult,
|
||||
type SearchResult,
|
||||
} from "@ff-labs/fff-bun"
|
||||
|
||||
declare global {
|
||||
const FFF_LIBC: "gnu" | "musl"
|
||||
}
|
||||
|
||||
const adapter = bind(FileFinder)
|
||||
export type Result<T> = { ok: true; value: T } | { ok: false; error: string }
|
||||
|
||||
export const available = adapter.available
|
||||
export const create = adapter.create
|
||||
export type Init = InitOptions
|
||||
|
||||
export interface Search {
|
||||
items: FileItem[]
|
||||
scores: SearchResult["scores"]
|
||||
totalMatched: number
|
||||
totalFiles: number
|
||||
}
|
||||
|
||||
export interface DirSearch {
|
||||
items: DirItem[]
|
||||
scores: DirSearchResult["scores"]
|
||||
totalMatched: number
|
||||
totalDirs: number
|
||||
}
|
||||
|
||||
export interface MixedSearch {
|
||||
items: MixedItem[]
|
||||
scores: MixedSearchResult["scores"]
|
||||
totalMatched: number
|
||||
totalFiles: number
|
||||
totalDirs: number
|
||||
}
|
||||
|
||||
export type File = FileItem
|
||||
export type Directory = DirItem
|
||||
export type Mixed = MixedItem
|
||||
export interface Picker {
|
||||
destroy(): void
|
||||
isScanning(): boolean
|
||||
waitForScan(timeoutMs?: number): Promise<Result<boolean>>
|
||||
refreshGitStatus(): Result<number>
|
||||
fileSearch(
|
||||
query: string,
|
||||
opts?: {
|
||||
currentFile?: string
|
||||
pageIndex?: number
|
||||
pageSize?: number
|
||||
},
|
||||
): Result<Search>
|
||||
directorySearch(
|
||||
query: string,
|
||||
opts?: {
|
||||
currentFile?: string
|
||||
pageIndex?: number
|
||||
pageSize?: number
|
||||
},
|
||||
): Result<DirSearch>
|
||||
mixedSearch(
|
||||
query: string,
|
||||
opts?: {
|
||||
currentFile?: string
|
||||
pageIndex?: number
|
||||
pageSize?: number
|
||||
},
|
||||
): Result<MixedSearch>
|
||||
trackQuery(query: string, file: string): Result<boolean>
|
||||
getHistoricalQuery(offset: number): Result<string | null>
|
||||
}
|
||||
|
||||
export function available() {
|
||||
return FileFinder.isAvailable()
|
||||
}
|
||||
|
||||
export function create(opts: Init): Result<Picker> {
|
||||
const made = FileFinder.create(opts)
|
||||
if (!made.ok) return made
|
||||
const pick = made.value
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
destroy: () => pick.destroy(),
|
||||
isScanning: () => pick.isScanning(),
|
||||
waitForScan: (timeoutMs) => pick.waitForScan(timeoutMs),
|
||||
refreshGitStatus: () => pick.refreshGitStatus(),
|
||||
fileSearch: (query, next) => pick.fileSearch(query, next),
|
||||
directorySearch: (query, next) => pick.directorySearch(query, next),
|
||||
mixedSearch: (query, next) => pick.mixedSearch(query, next),
|
||||
trackQuery: (query, file) => pick.trackQuery(query, file),
|
||||
getHistoricalQuery: (offset) => pick.getHistoricalQuery(offset),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export * as Fff from "./fff.bun"
|
||||
|
||||
@@ -1,12 +1,100 @@
|
||||
import { bind } from "./fff"
|
||||
|
||||
export type { Directory, DirSearch, File, Init, Mixed, MixedSearch, Picker, Result, Search } from "./fff"
|
||||
import type {
|
||||
DirItem,
|
||||
DirSearchResult,
|
||||
FileItem,
|
||||
InitOptions,
|
||||
MixedItem,
|
||||
MixedSearchResult,
|
||||
SearchResult,
|
||||
} from "@ff-labs/fff-node"
|
||||
|
||||
const { FileFinder } = await import("@ff-labs/fff-node").catch(() => ({ FileFinder: undefined }))
|
||||
|
||||
const adapter = bind(FileFinder, "fff unavailable on node runtime")
|
||||
export type Result<T> = { ok: true; value: T } | { ok: false; error: string }
|
||||
|
||||
export const available = adapter.available
|
||||
export const create = adapter.create
|
||||
export type Init = InitOptions
|
||||
|
||||
export interface Search {
|
||||
items: FileItem[]
|
||||
scores: SearchResult["scores"]
|
||||
totalMatched: number
|
||||
totalFiles: number
|
||||
}
|
||||
|
||||
export interface DirSearch {
|
||||
items: DirItem[]
|
||||
scores: DirSearchResult["scores"]
|
||||
totalMatched: number
|
||||
totalDirs: number
|
||||
}
|
||||
|
||||
export interface MixedSearch {
|
||||
items: MixedItem[]
|
||||
scores: MixedSearchResult["scores"]
|
||||
totalMatched: number
|
||||
totalFiles: number
|
||||
totalDirs: number
|
||||
}
|
||||
|
||||
export type File = FileItem
|
||||
export type Directory = DirItem
|
||||
export type Mixed = MixedItem
|
||||
export interface Picker {
|
||||
destroy(): void
|
||||
isScanning(): boolean
|
||||
waitForScan(timeoutMs?: number): Promise<Result<boolean>>
|
||||
refreshGitStatus(): Result<number>
|
||||
fileSearch(
|
||||
query: string,
|
||||
opts?: {
|
||||
currentFile?: string
|
||||
pageIndex?: number
|
||||
pageSize?: number
|
||||
},
|
||||
): Result<Search>
|
||||
directorySearch(
|
||||
query: string,
|
||||
opts?: {
|
||||
currentFile?: string
|
||||
pageIndex?: number
|
||||
pageSize?: number
|
||||
},
|
||||
): Result<DirSearch>
|
||||
mixedSearch(
|
||||
query: string,
|
||||
opts?: {
|
||||
currentFile?: string
|
||||
pageIndex?: number
|
||||
pageSize?: number
|
||||
},
|
||||
): Result<MixedSearch>
|
||||
trackQuery(query: string, file: string): Result<boolean>
|
||||
getHistoricalQuery(offset: number): Result<string | null>
|
||||
}
|
||||
|
||||
export function available() {
|
||||
return FileFinder?.isAvailable() ?? false
|
||||
}
|
||||
|
||||
export function create(opts: Init): Result<Picker> {
|
||||
if (!FileFinder) return { ok: false, error: "fff unavailable on node runtime" }
|
||||
const made = FileFinder.create(opts)
|
||||
if (!made.ok) return made
|
||||
const pick = made.value
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
destroy: () => pick.destroy(),
|
||||
isScanning: () => pick.isScanning(),
|
||||
waitForScan: (timeoutMs) => pick.waitForScan(timeoutMs),
|
||||
refreshGitStatus: () => pick.refreshGitStatus(),
|
||||
fileSearch: (query, next) => pick.fileSearch(query, next),
|
||||
directorySearch: (query, next) => pick.directorySearch(query, next),
|
||||
mixedSearch: (query, next) => pick.mixedSearch(query, next),
|
||||
trackQuery: (query, file) => pick.trackQuery(query, file),
|
||||
getHistoricalQuery: (offset) => pick.getHistoricalQuery(offset),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export * as Fff from "./fff.node"
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
export type Result<T> = { ok: true; value: T } | { ok: false; error: string }
|
||||
|
||||
export interface Init {
|
||||
basePath: string
|
||||
aiMode?: boolean
|
||||
disableMmapCache?: boolean
|
||||
disableContentIndexing?: boolean
|
||||
}
|
||||
|
||||
export interface SearchOptions {
|
||||
currentFile?: string
|
||||
pageIndex?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export interface File {
|
||||
relativePath: string
|
||||
}
|
||||
|
||||
export interface Directory {
|
||||
relativePath: string
|
||||
}
|
||||
|
||||
export type Mixed = { type: "file"; item: File } | { type: "directory"; item: Directory }
|
||||
|
||||
export interface Score {
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface Search {
|
||||
items: File[]
|
||||
scores: Score[]
|
||||
}
|
||||
|
||||
export interface DirSearch {
|
||||
items: Directory[]
|
||||
scores: Score[]
|
||||
}
|
||||
|
||||
export interface MixedSearch {
|
||||
items: Mixed[]
|
||||
scores: Score[]
|
||||
}
|
||||
|
||||
export interface Picker {
|
||||
destroy(): void
|
||||
fileSearch(query: string, options?: SearchOptions): Result<Search>
|
||||
directorySearch(query: string, options?: SearchOptions): Result<DirSearch>
|
||||
mixedSearch(query: string, options?: SearchOptions): Result<MixedSearch>
|
||||
}
|
||||
|
||||
export interface Backend {
|
||||
isAvailable(): boolean
|
||||
create(options: Init): Result<Picker>
|
||||
}
|
||||
|
||||
export function bind(backend: Backend | undefined, unavailable = "fff unavailable") {
|
||||
return {
|
||||
available: () => backend?.isAvailable() ?? false,
|
||||
create: (options: Init): Result<Picker> =>
|
||||
backend?.create(options) ?? {
|
||||
ok: false,
|
||||
error: unavailable,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -158,42 +158,45 @@ export const fromCatalogModel = (
|
||||
model: Info,
|
||||
credential?: Credential.Value,
|
||||
dependencies?: Dependencies,
|
||||
): Effect.Effect<LanguageModel, UnsupportedPackageError | UnresolvedProviderVariablesError> =>
|
||||
resolveCatalogModel(model, credential, dependencies).pipe(
|
||||
Effect.flatMap((resolved) => validateProviderVariables(model, resolved)),
|
||||
)
|
||||
|
||||
const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(function* (
|
||||
model: Info,
|
||||
credential?: Credential.Value,
|
||||
dependencies?: Dependencies,
|
||||
) {
|
||||
const resolved = prepareRuntimeModel(model, credential)
|
||||
): Effect.Effect<LanguageModel, UnsupportedPackageError | UnresolvedProviderVariablesError> => {
|
||||
const prepared = prepareRuntimeModel(model, credential)
|
||||
if (prepared.unresolved.length > 0)
|
||||
return Effect.fail(
|
||||
new UnresolvedProviderVariablesError({
|
||||
providerID: model.providerID,
|
||||
modelID: model.id,
|
||||
variables: prepared.unresolved,
|
||||
}),
|
||||
)
|
||||
const resolved = prepared.model
|
||||
const packageName = Provider.packageName(resolved.package)
|
||||
const key = apiKey(resolved, credential)
|
||||
const configuration = credential?.type === "key" ? credential.configuration : undefined
|
||||
|
||||
if (Provider.isAISDK(resolved.package) && packageName === "@ai-sdk/openai") {
|
||||
const runtime = yield* prepareProviderModel(resolved)
|
||||
return withDefaults(runtime, OpenAIResponses.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
||||
.model({ id: runtime.modelID ?? runtime.id, compatibility: runtime.compatibility })
|
||||
return Effect.succeed(
|
||||
withDefaults(resolved, OpenAIResponses.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
||||
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
|
||||
)
|
||||
}
|
||||
if (Provider.isAISDK(resolved.package) && packageName === "@ai-sdk/anthropic") {
|
||||
const runtime = yield* prepareProviderModel(resolved)
|
||||
return withDefaults(runtime, AnthropicMessages.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) })
|
||||
.model({ id: runtime.modelID ?? runtime.id, compatibility: runtime.compatibility })
|
||||
return Effect.succeed(
|
||||
withDefaults(resolved, AnthropicMessages.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) })
|
||||
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
|
||||
)
|
||||
}
|
||||
if (
|
||||
Provider.isAISDK(resolved.package) &&
|
||||
packageName === "@ai-sdk/openai-compatible" &&
|
||||
typeof resolved.settings?.baseURL === "string"
|
||||
) {
|
||||
const runtime = yield* prepareProviderModel(resolved)
|
||||
return withDefaults(runtime, OpenAICompatibleChat.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
||||
.model({ id: runtime.modelID ?? runtime.id, compatibility: runtime.compatibility })
|
||||
return Effect.succeed(
|
||||
withDefaults(resolved, OpenAICompatibleChat.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
||||
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
|
||||
)
|
||||
}
|
||||
const configured = { ...resolved.settings, ...credential?.metadata, ...configuration }
|
||||
const mapping = Provider.isAISDK(resolved.package)
|
||||
@@ -205,107 +208,64 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
|
||||
: undefined
|
||||
const native = mapping?.package ?? resolved.package
|
||||
if (Provider.isAISDK(resolved.package) && !mapping) {
|
||||
const loadAISDK = dependencies?.loadAISDK
|
||||
if (!loadAISDK) return yield* unsupported(resolved)
|
||||
const settings = yield* prepareProviderSettings(
|
||||
resolved,
|
||||
Provider.mergeOverlay(resolved.settings, {
|
||||
if (!dependencies?.loadAISDK) return Effect.fail(unsupported(resolved))
|
||||
const runtime = produce(resolved, (draft) => {
|
||||
draft.settings = Provider.mergeOverlay(draft.settings, {
|
||||
...nativeCredentialSettings(resolved.package ?? "", credential),
|
||||
...credential?.metadata,
|
||||
...configuration,
|
||||
}) ?? {},
|
||||
)
|
||||
const runtime = produce(resolved, (draft) => {
|
||||
draft.settings = settings
|
||||
})
|
||||
})
|
||||
return yield* loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved)))
|
||||
return dependencies.loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved)))
|
||||
}
|
||||
if (!native) return yield* unsupported(resolved)
|
||||
if (!native) return Effect.fail(unsupported(resolved))
|
||||
|
||||
const specifier = native
|
||||
const mapped = yield* prepareProviderSettings(resolved, mapping?.settings ?? configured)
|
||||
const module = yield* (dependencies?.loadPackage ?? Provider.loadPackage)(specifier).pipe(
|
||||
Effect.mapError(() => unsupported(resolved)),
|
||||
)
|
||||
const settings = {
|
||||
...(credential ? withoutNativeAuthSettings(mapped) : mapped),
|
||||
...nativeCredentialSettings(specifier, credential),
|
||||
headers: Provider.mergeHeaders(mapping?.headers, resolved.headers),
|
||||
body: Provider.mergeOverlay(mapping?.body, resolved.body),
|
||||
limits: { context: resolved.limit.context, input: resolved.limit.input, output: resolved.limit.output },
|
||||
}
|
||||
return yield* Effect.try({
|
||||
try: () => {
|
||||
const runtime = module.model(resolved.modelID ?? resolved.id, settings)
|
||||
return LanguageModel.update(runtime, {
|
||||
provider: resolved.providerID,
|
||||
compatibility: resolved.compatibility
|
||||
? Object.assign({}, runtime.compatibility, resolved.compatibility)
|
||||
: runtime.compatibility,
|
||||
})
|
||||
},
|
||||
catch: () => unsupported(resolved),
|
||||
return Effect.gen(function* () {
|
||||
const module = yield* (dependencies?.loadPackage ?? Provider.loadPackage)(specifier).pipe(
|
||||
Effect.mapError(() => unsupported(resolved)),
|
||||
)
|
||||
const mapped = mapping?.settings ?? configured
|
||||
const settings = {
|
||||
...(credential ? withoutNativeAuthSettings(mapped) : mapped),
|
||||
...nativeCredentialSettings(specifier, credential),
|
||||
headers: Provider.mergeHeaders(mapping?.headers, resolved.headers),
|
||||
body: Provider.mergeOverlay(mapping?.body, resolved.body),
|
||||
limits: { context: resolved.limit.context, input: resolved.limit.input, output: resolved.limit.output },
|
||||
}
|
||||
return yield* Effect.try({
|
||||
try: () => {
|
||||
const runtime = module.model(resolved.modelID ?? resolved.id, settings)
|
||||
return LanguageModel.update(runtime, {
|
||||
provider: resolved.providerID,
|
||||
compatibility: resolved.compatibility
|
||||
? Object.assign({}, runtime.compatibility, resolved.compatibility)
|
||||
: runtime.compatibility,
|
||||
})
|
||||
},
|
||||
catch: () => unsupported(resolved),
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function prepareRuntimeModel(model: Info, credential: Credential.Value | undefined) {
|
||||
if (model.settings?.apiKey !== "" && (credential?.type !== "key" || credential.metadata === undefined)) return model
|
||||
return produce(model, (draft) => {
|
||||
const prepared = produce(model, (draft) => {
|
||||
if (draft.settings?.apiKey === "") delete draft.settings.apiKey
|
||||
if (credential?.type === "key" && credential.metadata !== undefined)
|
||||
draft.body = Provider.mergeOverlay(draft.body, credential.metadata)
|
||||
if (typeof draft.settings?.baseURL !== "string") return
|
||||
draft.settings.baseURL = draft.settings.baseURL.replace(/\$\{([^}]+)\}/g, (placeholder, name: string) => {
|
||||
return process.env[name] ?? placeholder
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function validateProviderVariables(
|
||||
model: Info,
|
||||
resolved: LanguageModel,
|
||||
): Effect.Effect<LanguageModel, UnresolvedProviderVariablesError> {
|
||||
const baseURL = resolved.route.endpoint.baseURL
|
||||
if (typeof baseURL !== "string") return Effect.succeed(resolved)
|
||||
const failure = unresolvedProviderVariables(model, baseURL)
|
||||
return failure ? Effect.fail(failure) : Effect.succeed(resolved)
|
||||
}
|
||||
|
||||
function prepareProviderModel(model: Info): Effect.Effect<Info, UnresolvedProviderVariablesError> {
|
||||
if (!model.settings) return Effect.succeed(model)
|
||||
return prepareProviderSettings(model, model.settings).pipe(
|
||||
Effect.map((settings) =>
|
||||
settings === model.settings
|
||||
? model
|
||||
: produce(model, (draft) => {
|
||||
draft.settings = settings
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function prepareProviderSettings(
|
||||
model: Info,
|
||||
settings: Readonly<Record<string, unknown>>,
|
||||
): Effect.Effect<Readonly<Record<string, unknown>>, UnresolvedProviderVariablesError> {
|
||||
const baseURL = settings.baseURL
|
||||
if (typeof baseURL !== "string") return Effect.succeed(settings)
|
||||
return prepareProviderURL(model, baseURL).pipe(
|
||||
Effect.map((prepared) => (prepared === baseURL ? settings : { ...settings, baseURL: prepared })),
|
||||
)
|
||||
}
|
||||
|
||||
function prepareProviderURL(model: Info, baseURL: string): Effect.Effect<string, UnresolvedProviderVariablesError> {
|
||||
if (!baseURL.includes("${")) return Effect.succeed(baseURL)
|
||||
const prepared = baseURL.replace(/\$\{([^}]+)\}/g, (placeholder, name: string) => process.env[name] ?? placeholder)
|
||||
const failure = unresolvedProviderVariables(model, prepared)
|
||||
return failure ? Effect.fail(failure) : Effect.succeed(prepared)
|
||||
}
|
||||
|
||||
function unresolvedProviderVariables(model: Info, baseURL: string) {
|
||||
const variables = new Set(Array.from(baseURL.matchAll(/\$\{([^}]+)\}/g), (match) => match[1]))
|
||||
if (variables.size === 0) return
|
||||
return new UnresolvedProviderVariablesError({
|
||||
providerID: model.providerID,
|
||||
modelID: model.id,
|
||||
variables: Array.from(variables),
|
||||
})
|
||||
const baseURL = prepared.settings?.baseURL
|
||||
const unresolved =
|
||||
typeof baseURL === "string"
|
||||
? Array.from(baseURL.matchAll(/\$\{([^}]+)\}/g), (match) => match[1]).filter(
|
||||
(name, index, names) => names.indexOf(name) === index,
|
||||
)
|
||||
: []
|
||||
return { model: prepared, unresolved }
|
||||
}
|
||||
|
||||
const nativeCredentialSettings = (specifier: string, credential: Credential.Value | undefined) => {
|
||||
|
||||
@@ -4,7 +4,7 @@ export { Event, ID, Info } from "@opencode-ai/schema/plugin"
|
||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { App } from "./app"
|
||||
import { Context, Effect, Exit, Layer, Logger, References, Scope, Semaphore } from "effect"
|
||||
import { Context, Effect, Exit, Layer, Scope, Semaphore } from "effect"
|
||||
import { Agent } from "./agent"
|
||||
import { AISDK } from "./aisdk"
|
||||
import { Catalog } from "./catalog"
|
||||
@@ -44,12 +44,7 @@ const layer = Layer.effect(
|
||||
const inherit = yield* State.inherit()
|
||||
const loaded = yield* Effect.suspend(() => plugin.effect(host)).pipe(
|
||||
inherit,
|
||||
Effect.updateContext((context: Context.Context<never>) =>
|
||||
Context.make(Scope.Scope, child).pipe(
|
||||
Context.add(Logger.CurrentLoggers, Context.get(context, Logger.CurrentLoggers)),
|
||||
Context.add(References.MinimumLogLevel, Context.get(context, References.MinimumLogLevel)),
|
||||
),
|
||||
),
|
||||
Effect.updateContext((_context: Context.Context<never>) => Context.make(Scope.Scope, child)),
|
||||
Effect.withSpan("Plugin.load", { attributes: { "plugin.id": plugin.id } }),
|
||||
Effect.andThen(bus.publish(Plugin.Event.Added, { id: Plugin.ID.make(plugin.id) })),
|
||||
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
|
||||
|
||||
@@ -101,6 +101,16 @@ export const Plugin = define({
|
||||
item.permissions.push({ action: "question", resource: "*", effect: "allow" })
|
||||
})
|
||||
|
||||
draft.update(Agent.ID.make("plan"), (item) => {
|
||||
item.name = Agent.Name.make("Plan")
|
||||
item.description = "Plan mode. Disallows all edit tools."
|
||||
item.mode = "primary"
|
||||
item.permissions.push(
|
||||
{ action: "question", resource: "*", effect: "allow" },
|
||||
{ action: "edit", resource: "*", effect: "deny" },
|
||||
)
|
||||
})
|
||||
|
||||
draft.update(Agent.ID.make("general"), (item) => {
|
||||
item.name = Agent.Name.make("General")
|
||||
item.description =
|
||||
|
||||
@@ -17,7 +17,6 @@ import { ConfigProviderPlugin } from "../config/plugin/provider"
|
||||
import { ConfigPolicyPlugin } from "../config/plugin/policy"
|
||||
import { ConfigReferencePlugin } from "../config/plugin/reference"
|
||||
import { ConfigSkillPlugin } from "../config/plugin/skill"
|
||||
import { ConfigPluginSource } from "../config/plugin/source"
|
||||
import { ConfigWebSearchPlugin } from "../config/plugin/websearch"
|
||||
import { Bus } from "../bus"
|
||||
import { Environment } from "../environment"
|
||||
@@ -61,7 +60,6 @@ import { WellKnown } from "../wellknown"
|
||||
import { WriteTool } from "../tool/plugin/write"
|
||||
import { AgentPlugin } from "./agent"
|
||||
import { CommandPlugin } from "./command"
|
||||
import { PlanPlugin } from "./plan"
|
||||
import { ModelsDevPlugin } from "./models-dev"
|
||||
import { ProviderPlugins } from "./provider"
|
||||
import { WebSearchPlugins } from "./websearch"
|
||||
@@ -78,7 +76,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
const command = yield* Command.Service
|
||||
const config = yield* Config.Service
|
||||
const credential = yield* Credential.Service
|
||||
const pluginSources = yield* ConfigPluginSource.Service
|
||||
const bus = yield* Bus.Service
|
||||
const environment = yield* Environment.Service
|
||||
const mutation = yield* FileMutation.Service
|
||||
@@ -115,7 +112,6 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
Context.make(Command.Service, command),
|
||||
Context.make(Config.Service, config),
|
||||
Context.make(Credential.Service, credential),
|
||||
Context.make(ConfigPluginSource.Service, pluginSources),
|
||||
Context.make(Bus.Service, bus),
|
||||
Context.make(Environment.Service, environment),
|
||||
Context.make(FileMutation.Service, mutation),
|
||||
@@ -159,7 +155,6 @@ export const requirements = LayerNode.group([
|
||||
Command.node,
|
||||
Config.node,
|
||||
Credential.node,
|
||||
ConfigPluginSource.node,
|
||||
Bus.node,
|
||||
Environment.node,
|
||||
FileMutation.node,
|
||||
@@ -197,7 +192,6 @@ export type InternalPlugin = Plugin<Requirements | Scope.Scope>
|
||||
const pre = [
|
||||
WellKnownPlugin.Plugin,
|
||||
AgentPlugin.Plugin,
|
||||
PlanPlugin.Plugin,
|
||||
CommandPlugin.Plugin,
|
||||
SkillPlugin.Plugin,
|
||||
...SystemPromptPlugin.Plugins,
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
export * as PlanPlugin from "./plan"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Agent } from "../agent"
|
||||
import { SessionEvent } from "../session/event"
|
||||
|
||||
const plan = Agent.ID.make("plan")
|
||||
|
||||
const enter = `<system-reminder>
|
||||
You are in Plan mode. You are not allowed to edit or create files, and you may not ask a subagent to do that either.
|
||||
|
||||
You are in Plan mode until the user switches agents. Plan mode is not changed by user intent, tone, or imperative language. If the user asks you to change files, do not edit. Tell them they need to switch agents.
|
||||
</system-reminder>`
|
||||
|
||||
const leave = `<system-reminder>
|
||||
You are NO LONGER in Plan mode. The previous Plan restrictions no longer apply. Any Plan mode instructions from earlier in this conversation are no longer active.
|
||||
</system-reminder>`
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.plan",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.agent.transform((draft) => {
|
||||
draft.update(plan, (item) => {
|
||||
item.name = Agent.Name.make("Plan")
|
||||
item.description = "Read-only agent for exploring the codebase and planning work before implementation."
|
||||
item.mode = "primary"
|
||||
item.permissions.push({ action: "question", resource: "*", effect: "allow" })
|
||||
})
|
||||
})
|
||||
|
||||
yield* ctx.tool.hook("execute.before", (event) => {
|
||||
if (event.agent !== plan) return Effect.void
|
||||
if (event.tool !== "edit" && event.tool !== "write" && event.tool !== "patch") return Effect.void
|
||||
return new ToolFailure({
|
||||
message: `Cannot use ${event.tool} in Plan mode. You are in a read-only mode and must not modify files.`,
|
||||
})
|
||||
})
|
||||
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter(
|
||||
(event): event is SessionEvent.Created | SessionEvent.AgentSelected =>
|
||||
event.type === "session.created" || event.type === "session.agent.selected",
|
||||
),
|
||||
Stream.runForEach((event) => {
|
||||
const text = reminder(event)
|
||||
if (!text) return Effect.void
|
||||
return ctx.session
|
||||
.synthetic({
|
||||
sessionID: event.data.sessionID,
|
||||
text,
|
||||
resume: false,
|
||||
})
|
||||
.pipe(Effect.catch(() => Effect.void))
|
||||
}),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
function reminder(event: SessionEvent.Created | SessionEvent.AgentSelected) {
|
||||
if (event.type === "session.created") {
|
||||
if (event.data.agent !== plan) return
|
||||
return enter
|
||||
}
|
||||
if (event.data.agent === event.data.previous) return
|
||||
if (event.data.agent === plan) return enter
|
||||
if (event.data.previous === plan) return leave
|
||||
}
|
||||
@@ -1,10 +1,16 @@
|
||||
import { createProviderPlugin } from "./factory"
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
|
||||
export const AlibabaPlugin = createProviderPlugin({
|
||||
export const AlibabaPlugin = define({
|
||||
id: "opencode.provider.alibaba",
|
||||
package: "@ai-sdk/alibaba",
|
||||
load: async (options) => {
|
||||
const { createAlibaba } = await import("@ai-sdk/alibaba")
|
||||
return createAlibaba(options)
|
||||
},
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/alibaba") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/alibaba"))
|
||||
evt.sdk = mod.createAlibaba(evt.options)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { createProviderPlugin } from "./factory"
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
|
||||
export const CoherePlugin = createProviderPlugin({
|
||||
export const CoherePlugin = define({
|
||||
id: "opencode.provider.cohere",
|
||||
package: "@ai-sdk/cohere",
|
||||
load: async (options) => {
|
||||
const { createCohere } = await import("@ai-sdk/cohere")
|
||||
return createCohere(options)
|
||||
},
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/cohere") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/cohere"))
|
||||
evt.sdk = mod.createCohere(evt.options)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { createProviderPlugin } from "./factory"
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
|
||||
export const DeepInfraPlugin = createProviderPlugin({
|
||||
export const DeepInfraPlugin = define({
|
||||
id: "opencode.provider.deepinfra",
|
||||
package: "@ai-sdk/deepinfra",
|
||||
load: async (options) => {
|
||||
const { createDeepInfra } = await import("@ai-sdk/deepinfra")
|
||||
return createDeepInfra(options)
|
||||
},
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/deepinfra") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/deepinfra"))
|
||||
evt.sdk = mod.createDeepInfra(evt.options)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { AISDKHooks } from "@opencode-ai/plugin/effect/aisdk"
|
||||
import { Effect } from "effect"
|
||||
|
||||
export function createProviderPlugin(input: {
|
||||
readonly id: string
|
||||
readonly package: string
|
||||
readonly load: (options: AISDKHooks["sdk"]["options"]) => Promise<unknown>
|
||||
}) {
|
||||
return define({
|
||||
id: input.id,
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== input.package) return
|
||||
evt.sdk = yield* Effect.promise(() => input.load(evt.options))
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -1,10 +1,16 @@
|
||||
import { createProviderPlugin } from "./factory"
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
|
||||
export const GatewayPlugin = createProviderPlugin({
|
||||
export const GatewayPlugin = define({
|
||||
id: "opencode.provider.gateway",
|
||||
package: "@ai-sdk/gateway",
|
||||
load: async (options) => {
|
||||
const { createGateway } = await import("@ai-sdk/gateway")
|
||||
return createGateway(options)
|
||||
},
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/gateway") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/gateway"))
|
||||
evt.sdk = mod.createGateway(evt.options)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { createProviderPlugin } from "./factory"
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
|
||||
export const GroqPlugin = createProviderPlugin({
|
||||
export const GroqPlugin = define({
|
||||
id: "opencode.provider.groq",
|
||||
package: "@ai-sdk/groq",
|
||||
load: async (options) => {
|
||||
const { createGroq } = await import("@ai-sdk/groq")
|
||||
return createGroq(options)
|
||||
},
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/groq") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/groq"))
|
||||
evt.sdk = mod.createGroq(evt.options)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { createProviderPlugin } from "./factory"
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
|
||||
export const MistralPlugin = createProviderPlugin({
|
||||
export const MistralPlugin = define({
|
||||
id: "opencode.provider.mistral",
|
||||
package: "@ai-sdk/mistral",
|
||||
load: async (options) => {
|
||||
const { createMistral } = await import("@ai-sdk/mistral")
|
||||
return createMistral(options)
|
||||
},
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/mistral") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/mistral"))
|
||||
evt.sdk = mod.createMistral(evt.options)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { createProviderPlugin } from "./factory"
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
|
||||
export const PerplexityPlugin = createProviderPlugin({
|
||||
export const PerplexityPlugin = define({
|
||||
id: "opencode.provider.perplexity",
|
||||
package: "@ai-sdk/perplexity",
|
||||
load: async (options) => {
|
||||
const { createPerplexity } = await import("@ai-sdk/perplexity")
|
||||
return createPerplexity(options)
|
||||
},
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/perplexity") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/perplexity"))
|
||||
evt.sdk = mod.createPerplexity(evt.options)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { createProviderPlugin } from "./factory"
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
|
||||
export const TogetherAIPlugin = createProviderPlugin({
|
||||
export const TogetherAIPlugin = define({
|
||||
id: "opencode.provider.togetherai",
|
||||
package: "@ai-sdk/togetherai",
|
||||
load: async (options) => {
|
||||
const { createTogetherAI } = await import("@ai-sdk/togetherai")
|
||||
return createTogetherAI(options)
|
||||
},
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/togetherai") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/togetherai"))
|
||||
evt.sdk = mod.createTogetherAI(evt.options)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { createProviderPlugin } from "./factory"
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
|
||||
export const VenicePlugin = createProviderPlugin({
|
||||
export const VenicePlugin = define({
|
||||
id: "opencode.provider.venice",
|
||||
package: "venice-ai-sdk-provider",
|
||||
load: async (options) => {
|
||||
const { createVenice } = await import("venice-ai-sdk-provider")
|
||||
return createVenice(options)
|
||||
},
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "venice-ai-sdk-provider") return
|
||||
const mod = yield* Effect.promise(() => import("venice-ai-sdk-provider"))
|
||||
evt.sdk = mod.createVenice(evt.options)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -6,8 +6,12 @@ import { define, type Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect } from "effect"
|
||||
import { AbsolutePath } from "../schema"
|
||||
import { Skill } from "../skill"
|
||||
import { ConfigPluginSource } from "../config/plugin/source"
|
||||
import { Config } from "../config"
|
||||
import { Location } from "../location"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import opencodeContent from "./skill/opencode.md" with { type: "text" }
|
||||
import reportContent from "./skill/report.md" with { type: "text" }
|
||||
|
||||
@@ -68,10 +72,32 @@ const reportContentWithDiagnostics = Effect.fn("SkillPlugin.reportContentWithDia
|
||||
})
|
||||
|
||||
const configuredPlugins = Effect.fn("SkillPlugin.configuredPlugins")(function* () {
|
||||
const sources = yield* ConfigPluginSource.Service
|
||||
return (yield* sources.operations())
|
||||
.map((operation) => (operation.type === "remove" ? `-${operation.target}` : operation.target))
|
||||
.toSorted()
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
return yield* Effect.forEach(yield* config.entries(), (entry) => {
|
||||
if (entry.type === "document") {
|
||||
const directory = entry.path ? path.dirname(entry.path) : location.directory
|
||||
return Effect.succeed(
|
||||
(entry.info.plugins ?? []).map((item) => {
|
||||
const ref = typeof item === "string" ? { package: item } : item
|
||||
if (ref.package.startsWith("file://")) return fileURLToPath(ref.package)
|
||||
if (ref.package.startsWith("./") || ref.package.startsWith("../")) return path.resolve(directory, ref.package)
|
||||
return ref.package
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (entry.type !== "directory") return Effect.succeed([])
|
||||
return fs
|
||||
.scan("{plugin,plugins}/*.{ts,js}", {
|
||||
cwd: entry.path,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
dot: true,
|
||||
symlink: true,
|
||||
})
|
||||
.pipe(Effect.orElseSucceed(() => []))
|
||||
}).pipe(Effect.map((items) => items.flat().toSorted()))
|
||||
})
|
||||
|
||||
function terminal() {
|
||||
|
||||
@@ -30,7 +30,7 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
|
||||
draft.update("exa", (integration) => (integration.name = "Exa"))
|
||||
draft.method.update({
|
||||
integrationID: "exa",
|
||||
method: { type: "key" },
|
||||
method: { type: "key", label: "API key (optional)" },
|
||||
})
|
||||
draft.method.update({
|
||||
integrationID: "exa",
|
||||
|
||||
@@ -41,7 +41,7 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
|
||||
draft.update("firecrawl", (integration) => (integration.name = "Firecrawl"))
|
||||
draft.method.update({
|
||||
integrationID: "firecrawl",
|
||||
method: { type: "key" },
|
||||
method: { type: "key", label: "API key (optional)" },
|
||||
})
|
||||
draft.method.update({
|
||||
integrationID: "firecrawl",
|
||||
|
||||
@@ -56,7 +56,7 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
|
||||
draft.update("parallel", (integration) => (integration.name = "Parallel"))
|
||||
draft.method.update({
|
||||
integrationID: "parallel",
|
||||
method: { type: "key" },
|
||||
method: { type: "key", label: "API key (optional)" },
|
||||
})
|
||||
draft.method.update({
|
||||
integrationID: "parallel",
|
||||
|
||||
@@ -2,7 +2,7 @@ export * as Project from "./project"
|
||||
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { asc, desc } from "drizzle-orm"
|
||||
import { asc, desc, isNotNull, isNull, ne, or } from "drizzle-orm"
|
||||
import path from "path"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { Database } from "./database/database"
|
||||
@@ -13,7 +13,7 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
import { ProjectDirectories } from "./project/directories"
|
||||
import { ProjectSchema } from "./project/schema"
|
||||
import { ProjectTable, upsertProject } from "./project/sql"
|
||||
import { ProjectTable } from "./project/sql"
|
||||
|
||||
export const ID = ProjectSchema.ID
|
||||
export type ID = ProjectSchema.ID
|
||||
@@ -98,7 +98,19 @@ const layer = Layer.effect(
|
||||
yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* upsertProject(tx, project)
|
||||
const vcs = project.vcs?.type
|
||||
yield* tx
|
||||
.insert(ProjectTable)
|
||||
.values({ id: project.id, worktree: project.canonical, vcs, sandboxes: [] })
|
||||
.onConflictDoUpdate({
|
||||
target: ProjectTable.id,
|
||||
set: { worktree: project.canonical, vcs: vcs ?? null },
|
||||
setWhere: or(
|
||||
ne(ProjectTable.worktree, project.canonical),
|
||||
vcs ? or(isNull(ProjectTable.vcs), ne(ProjectTable.vcs, vcs)) : isNotNull(ProjectTable.vcs),
|
||||
),
|
||||
})
|
||||
.run()
|
||||
if (!project.vcs) return
|
||||
yield* projectDirectories.create({ projectID: project.id, directory: project.canonical }, tx)
|
||||
if (project.directory === project.canonical) return
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
|
||||
import { isNotNull, isNull, ne, or } from "drizzle-orm"
|
||||
import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core"
|
||||
import { absoluteArrayColumn, absoluteColumn } from "../database/path"
|
||||
import { Timestamps } from "../database/schema.sql"
|
||||
import type { AbsolutePath } from "../schema"
|
||||
import { ProjectSchema } from "./schema"
|
||||
|
||||
type DatabaseClient = EffectDrizzleSqlite.EffectSQLiteDatabase
|
||||
type Transaction = Parameters<Parameters<DatabaseClient["transaction"]>[0]>[0]
|
||||
|
||||
export const ProjectTable = sqliteTable("project", {
|
||||
id: text().$type<ProjectSchema.ID>().primaryKey(),
|
||||
worktree: absoluteColumn().notNull(),
|
||||
@@ -39,22 +33,3 @@ export const ProjectDirectoryTable = sqliteTable(
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.project_id, table.directory] })],
|
||||
)
|
||||
|
||||
export function upsertProject(
|
||||
db: DatabaseClient | Transaction,
|
||||
project: { readonly id: ProjectSchema.ID; readonly canonical: AbsolutePath; readonly vcs?: ProjectSchema.Vcs },
|
||||
) {
|
||||
const vcs = project.vcs?.type
|
||||
return db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: project.id, worktree: project.canonical, vcs, sandboxes: [] })
|
||||
.onConflictDoUpdate({
|
||||
target: ProjectTable.id,
|
||||
set: { worktree: project.canonical, vcs: vcs ?? null },
|
||||
setWhere: or(
|
||||
ne(ProjectTable.worktree, project.canonical),
|
||||
vcs ? or(isNull(ProjectTable.vcs), ne(ProjectTable.vcs, vcs)) : isNotNull(ProjectTable.vcs),
|
||||
),
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ export * from "./session/schema"
|
||||
|
||||
import { Effect, Layer, Schema, Context, Stream, Scope } from "effect"
|
||||
import { ListAnchor } from "@opencode-ai/schema/session"
|
||||
import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm"
|
||||
import { and, asc, desc, eq, gt, isNotNull, isNull, like, lt, ne, or, type SQL } from "drizzle-orm"
|
||||
import { Project } from "./project"
|
||||
import { Workspace } from "./workspace"
|
||||
import { Model } from "./model"
|
||||
@@ -21,7 +21,7 @@ import { Agent } from "./agent"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { App } from "./app"
|
||||
import { Slug } from "./util/slug"
|
||||
import { upsertProject } from "./project/sql"
|
||||
import { ProjectTable } from "./project/sql"
|
||||
import path from "path"
|
||||
import { fromRow } from "./session/info"
|
||||
import { SessionRunner } from "./session/runner/index"
|
||||
@@ -309,7 +309,22 @@ const layer = Layer.effect(
|
||||
const shellLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
|
||||
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
|
||||
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
||||
const persistProject = (project: Project.Resolved) => upsertProject(db, project).pipe(Effect.orDie)
|
||||
const persistProject = (project: Project.Resolved) => {
|
||||
const vcs = project.vcs?.type
|
||||
return db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: project.id, worktree: project.canonical, vcs, sandboxes: [] })
|
||||
.onConflictDoUpdate({
|
||||
target: ProjectTable.id,
|
||||
set: { worktree: project.canonical, vcs: vcs ?? null },
|
||||
setWhere: or(
|
||||
ne(ProjectTable.worktree, project.canonical),
|
||||
vcs ? or(isNull(ProjectTable.vcs), ne(ProjectTable.vcs, vcs)) : isNotNull(ProjectTable.vcs),
|
||||
),
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}
|
||||
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
|
||||
Effect.mapError(
|
||||
@@ -701,11 +716,10 @@ const layer = Layer.effect(
|
||||
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
|
||||
}),
|
||||
switchAgent: Effect.fn("Session.switchAgent")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
yield* result.get(input.sessionID)
|
||||
yield* bus.publish(SessionEvent.AgentSelected, {
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
previous: session.agent,
|
||||
})
|
||||
}),
|
||||
switchModel: Effect.fn("Session.switchModel")(function* (input) {
|
||||
@@ -719,7 +733,6 @@ const layer = Layer.effect(
|
||||
yield* bus.publish(SessionEvent.ModelSelected, {
|
||||
sessionID: input.sessionID,
|
||||
model: input.model,
|
||||
previous: session.model,
|
||||
})
|
||||
}),
|
||||
rename: Effect.fn("Session.rename")(function* (input) {
|
||||
|
||||
@@ -56,22 +56,16 @@ export const layer = Layer.effect(
|
||||
),
|
||||
Effect.asVoid,
|
||||
)
|
||||
// Write-ahead claim: starting records the durable intent that a turn is in flight, in the same
|
||||
// transaction as the started event. Terminals release it — except shutdown interruption, which
|
||||
// preserves the claim so the next server start resumes the turn. A claim that survives with no
|
||||
// terminal is the signature of a process that died without teardown (crash, SIGKILL, eviction);
|
||||
// recovery is a property of the database, never of a shutdown hook that may not run.
|
||||
const claimOnCommit = (sessionID: SessionSchema.ID) => ({
|
||||
commit: () => store.claim(sessionID),
|
||||
})
|
||||
const releaseOnCommit = (sessionID: SessionSchema.ID) => ({
|
||||
commit: () => store.release(sessionID),
|
||||
// Starting or finishing on its own clears stale suspension; interruption preserves it because
|
||||
// managed-server teardown suspends active Sessions immediately before interrupting their drains.
|
||||
const clearSuspensionOnCommit = (sessionID: SessionSchema.ID) => ({
|
||||
commit: () => Effect.asVoid(store.consumeSuspended(sessionID)),
|
||||
})
|
||||
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError, InterruptReason>({
|
||||
started: (sessionID) =>
|
||||
reportLifecycle(
|
||||
sessionID,
|
||||
bus.publish(SessionEvent.Execution.Started, { sessionID }, claimOnCommit(sessionID)),
|
||||
bus.publish(SessionEvent.Execution.Started, { sessionID }, clearSuspensionOnCommit(sessionID)),
|
||||
),
|
||||
drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) {
|
||||
const session = yield* store.get(sessionID)
|
||||
@@ -92,17 +86,11 @@ export const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const outcome = terminal(exit, reason)
|
||||
if (outcome.type === "succeeded") {
|
||||
yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID }, releaseOnCommit(sessionID))
|
||||
yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID }, clearSuspensionOnCommit(sessionID))
|
||||
return
|
||||
}
|
||||
if (outcome.type === "interrupted") {
|
||||
// A user cancel (or a superseding execution) releases the claim: the turn must not
|
||||
// resurrect at the next boot. Shutdown interruption keeps it for restart continuity.
|
||||
yield* bus.publish(
|
||||
SessionEvent.Execution.Interrupted,
|
||||
{ sessionID, reason: outcome.reason },
|
||||
outcome.reason === "shutdown" ? undefined : releaseOnCommit(sessionID),
|
||||
)
|
||||
yield* bus.publish(SessionEvent.Execution.Interrupted, { sessionID, reason: outcome.reason })
|
||||
return
|
||||
}
|
||||
yield* bus.publish(
|
||||
@@ -111,7 +99,7 @@ export const layer = Layer.effect(
|
||||
sessionID,
|
||||
error: outcome.error,
|
||||
},
|
||||
releaseOnCommit(sessionID),
|
||||
clearSuspensionOnCommit(sessionID),
|
||||
)
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -5,111 +5,61 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Bus } from "../../bus"
|
||||
import { SessionEvent } from "../event"
|
||||
import { SessionExecution } from "../execution"
|
||||
import { SessionSchema } from "../schema"
|
||||
import { SessionStore } from "../store"
|
||||
|
||||
const CONTINUE_AFTER_SERVER_RESTART =
|
||||
"The server restarted while you were working. Continue from where you left off without repeating completed work."
|
||||
|
||||
const RESUME_EXHAUSTED = {
|
||||
type: "aborted",
|
||||
message: "Execution was interrupted repeatedly and will not be resumed automatically.",
|
||||
} as const
|
||||
|
||||
export interface Options {
|
||||
/**
|
||||
* Times a single turn may be resumed before it is terminalized instead.
|
||||
* The counter is durable and only a terminal event resets it, so a turn
|
||||
* that keeps dying cannot crash-loop across restarts. Turns that complete
|
||||
* never accumulate: the budget is per-turn, not per-session.
|
||||
*/
|
||||
readonly maxAttempts?: number
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_ATTEMPTS = 10
|
||||
|
||||
export interface Interface {
|
||||
/**
|
||||
* Resumes Sessions whose execution claim was never released — turns orphaned
|
||||
* by a process that died without teardown, or interrupted by a graceful
|
||||
* shutdown (which preserves the claim on purpose). The claim is never
|
||||
* cleared here: only a terminal event releases it, so a death anywhere in
|
||||
* the resume path leaves the same orphaned claim for the next boot.
|
||||
* Marks every execution active in this process for resumption by the next server start.
|
||||
* Call once new work has stopped arriving and before teardown interrupts the drains.
|
||||
*/
|
||||
readonly suspendActiveSessions: Effect.Effect<void>
|
||||
/** Resumes suspended Sessions. Each suspension is consumed atomically, so a Session resumes at most once. */
|
||||
readonly resumeSuspendedSessions: Effect.Effect<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Recovery for orphaned executions. Claims are written at turn start by
|
||||
* SessionExecution, so this sweep needs no cooperation from the previous
|
||||
* process: crash, SIGKILL, isolate eviction, and graceful restart all leave
|
||||
* the same durable signature.
|
||||
*
|
||||
* The sweep assumes every orphaned claim's owner is dead. The managed-server
|
||||
* protocol guarantees this: a successor is only spawned after the previous
|
||||
* process is confirmed dead (client service `kill`/`evict` poll the PID), the
|
||||
* registration lock admits one managed server at a time, and unregistered
|
||||
* servers sharing the database never sweep. The service is inert until called
|
||||
* — the managed server invokes it at boot; embedders may call it from their
|
||||
* own start-up.
|
||||
* Restart continuity actions for the managed server. The service is inert until called: only the
|
||||
* managed server invokes it, so default, embedded, and stdio servers never suspend or auto-resume.
|
||||
*/
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionRestart") {}
|
||||
|
||||
export const layer = (options?: Options) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const store = yield* SessionStore.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const bus = yield* Bus.Service
|
||||
const scope = yield* Effect.scope
|
||||
const maxAttempts = options?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS
|
||||
|
||||
const resumeOne = Effect.fnUntraced(function* (sessionID: SessionSchema.ID) {
|
||||
// Durable before the resume runs, so a crash inside the resumed turn is
|
||||
// counted by the next sweep and the budget cannot be dodged.
|
||||
const attempts = yield* store.countResume(sessionID)
|
||||
if (attempts === undefined) return // the Session was deleted since listing
|
||||
if (attempts > maxAttempts) {
|
||||
// Terminalize instead: the release hook clears the claim and resets the
|
||||
// counter atomically with the terminal event.
|
||||
yield* bus.publish(
|
||||
SessionEvent.Execution.Failed,
|
||||
{ sessionID, error: RESUME_EXHAUSTED },
|
||||
{ commit: () => store.release(sessionID) },
|
||||
)
|
||||
return
|
||||
}
|
||||
yield* bus.publish(SessionEvent.Synthetic, {
|
||||
sessionID,
|
||||
text: CONTINUE_AFTER_SERVER_RESTART,
|
||||
description: "Continuing after restart",
|
||||
})
|
||||
// Forked into the service scope so boot never waits on resumed turns;
|
||||
// resuming an already-live Session joins its execution. Drain failures
|
||||
// are logged and durably recorded by the execution layer.
|
||||
yield* execution.resume(sessionID).pipe(Effect.ignore, Effect.forkIn(scope))
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
resumeSuspendedSessions: Effect.gen(function* () {
|
||||
// Child claims never drive recovery (children are not resumed), so a
|
||||
// dead child's claim is noise no terminal will ever release. Clearing
|
||||
// is safe even against a live child: claims are recovery markers, not
|
||||
// locks, and children are excluded from that recovery.
|
||||
yield* store.releaseChildClaims
|
||||
const active = yield* execution.active
|
||||
// Sessions already draining in this process keep their claim; resuming
|
||||
// them would only inject a stray continuation into a live turn.
|
||||
const orphaned = (yield* store.listSuspended()).filter((sessionID) => !active.has(sessionID))
|
||||
yield* Effect.forEach(orphaned, resumeOne, { concurrency: "unbounded", discard: true })
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const store = yield* SessionStore.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const bus = yield* Bus.Service
|
||||
return Service.of({
|
||||
suspendActiveSessions: Effect.gen(function* () {
|
||||
yield* store.suspend(yield* execution.active)
|
||||
}),
|
||||
resumeSuspendedSessions: Effect.gen(function* () {
|
||||
const sessions = yield* store.listSuspended()
|
||||
yield* Effect.forEach(
|
||||
sessions,
|
||||
(sessionID) =>
|
||||
Effect.gen(function* () {
|
||||
if (!(yield* store.consumeSuspended(sessionID))) return
|
||||
yield* bus.publish(SessionEvent.Synthetic, {
|
||||
sessionID,
|
||||
text: CONTINUE_AFTER_SERVER_RESTART,
|
||||
description: "Continuing after restart",
|
||||
})
|
||||
// Drain failures are already logged and durably recorded by the execution layer.
|
||||
yield* Effect.ignore(execution.resume(sessionID))
|
||||
}),
|
||||
{ concurrency: "unbounded", discard: true },
|
||||
)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer(),
|
||||
layer,
|
||||
deps: [SessionStore.node, SessionExecution.node, Bus.node],
|
||||
})
|
||||
|
||||
@@ -61,7 +61,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
"session.usage.recorded": () => Effect.void,
|
||||
"session.agent.selected": (event) => {
|
||||
return Effect.gen(function* () {
|
||||
const previous = event.data.previous ?? (yield* adapter.getAgent())
|
||||
const previous = yield* adapter.getAgent()
|
||||
yield* adapter.appendMessage(
|
||||
SessionMessage.AgentSelected.make({
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
@@ -76,7 +76,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
},
|
||||
"session.model.selected": (event) => {
|
||||
return Effect.gen(function* () {
|
||||
const previous = event.data.previous ?? (yield* adapter.getModel())
|
||||
const previous = yield* adapter.getModel()
|
||||
yield* adapter.appendMessage(
|
||||
SessionMessage.ModelSelected.make({
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
|
||||
@@ -58,9 +58,7 @@ export const SessionTable = sqliteTable(
|
||||
...Timestamps,
|
||||
time_compacting: integer(),
|
||||
time_archived: integer(),
|
||||
/** The execution claim timestamp (historical column name; see SessionStore.claim). */
|
||||
time_suspended: integer(),
|
||||
resume_attempts: integer().notNull().default(0),
|
||||
},
|
||||
(table) => [
|
||||
index("session_v2_project_idx").on(table.project_id),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SessionStore from "./store"
|
||||
|
||||
import { and, eq, isNotNull, isNull, sql } from "drizzle-orm"
|
||||
import { and, eq, inArray, isNotNull, isNull } from "drizzle-orm"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Database } from "../database/database"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
@@ -17,32 +17,10 @@ export interface Interface {
|
||||
readonly message: (
|
||||
messageID: SessionMessage.ID,
|
||||
) => Effect.Effect<{ readonly sessionID: Session.ID; readonly message: SessionMessage.Info } | undefined>
|
||||
/**
|
||||
* Top-level Sessions holding an execution claim. Child (subagent) Sessions
|
||||
* are excluded: a resumed parent re-runs its tool call and spawns fresh
|
||||
* children, so resuming orphaned children would duplicate their work.
|
||||
*/
|
||||
readonly listSuspended: () => Effect.Effect<ReadonlyArray<Session.ID>>
|
||||
/**
|
||||
* Records the execution claim: the durable write-ahead intent that a turn is
|
||||
* (or was) in flight. Set when execution starts; a claim that survives to the
|
||||
* next boot marks a turn that never completed — its process crashed or shut
|
||||
* down mid-turn.
|
||||
*/
|
||||
readonly claim: (sessionID: Session.ID) => Effect.Effect<void>
|
||||
/** Releases the claim and resets resume accounting. Terminal events call this on commit. */
|
||||
readonly release: (sessionID: Session.ID) => Effect.Effect<void>
|
||||
/**
|
||||
* Clears orphaned child (subagent) claims. Children are never resumed
|
||||
* independently, so a dead child's claim is noise no terminal will ever
|
||||
* release.
|
||||
*/
|
||||
readonly releaseChildClaims: Effect.Effect<void>
|
||||
/**
|
||||
* Durably counts one more resume of an orphaned claim, returning the new
|
||||
* total — or undefined when the Session no longer exists.
|
||||
*/
|
||||
readonly countResume: (sessionID: Session.ID) => Effect.Effect<number | undefined>
|
||||
/** Clears suspension, reporting whether this caller consumed it. At most one concurrent caller receives true. */
|
||||
readonly consumeSuspended: (sessionID: Session.ID) => Effect.Effect<boolean>
|
||||
readonly suspend: (sessionIDs: Iterable<Session.ID>) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionStore") {}
|
||||
@@ -79,52 +57,35 @@ const layer = Layer.effect(
|
||||
return yield* db
|
||||
.select({ sessionID: SessionTable.id })
|
||||
.from(SessionTable)
|
||||
.where(and(isNotNull(SessionTable.time_suspended), isNull(SessionTable.parent_id)))
|
||||
.where(isNotNull(SessionTable.time_suspended))
|
||||
.all()
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((rows) => rows.map((row) => row.sessionID)),
|
||||
)
|
||||
}),
|
||||
claim: Effect.fn("SessionStore.claim")(function* (sessionID) {
|
||||
// The null guard makes re-claiming a still-claimed Session a zero-row
|
||||
// no-op (a resumed turn re-claims through the same started hook).
|
||||
// Claim bookkeeping never counts as user activity: time_updated is
|
||||
// pinned so session ordering only moves on real changes.
|
||||
consumeSuspended: Effect.fn("SessionStore.consumeSuspended")(function* (sessionID) {
|
||||
return (
|
||||
(yield* db
|
||||
.update(SessionTable)
|
||||
.set({ time_suspended: null })
|
||||
.where(and(eq(SessionTable.id, sessionID), isNotNull(SessionTable.time_suspended)))
|
||||
.returning({ sessionID: SessionTable.id })
|
||||
.get()
|
||||
.pipe(Effect.orDie)) !== undefined
|
||||
)
|
||||
}),
|
||||
suspend: Effect.fn("SessionStore.suspend")(function* (sessionIDs) {
|
||||
const ids = Array.from(sessionIDs)
|
||||
if (ids.length === 0) return
|
||||
// The null guard preserves the original suspension time if a Session is somehow suspended twice.
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({ time_suspended: Date.now(), time_updated: sql`${SessionTable.time_updated}` })
|
||||
.where(and(eq(SessionTable.id, sessionID), isNull(SessionTable.time_suspended)))
|
||||
.set({ time_suspended: Date.now() })
|
||||
.where(and(inArray(SessionTable.id, ids), isNull(SessionTable.time_suspended)))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
release: Effect.fn("SessionStore.release")(function* (sessionID) {
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({ time_suspended: null, resume_attempts: 0, time_updated: sql`${SessionTable.time_updated}` })
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
releaseChildClaims: db
|
||||
.update(SessionTable)
|
||||
.set({ time_suspended: null, resume_attempts: 0, time_updated: sql`${SessionTable.time_updated}` })
|
||||
.where(and(isNotNull(SessionTable.time_suspended), isNotNull(SessionTable.parent_id)))
|
||||
.run()
|
||||
.pipe(Effect.orDie, Effect.asVoid, Effect.withSpan("SessionStore.releaseChildClaims")),
|
||||
countResume: Effect.fn("SessionStore.countResume")(function* (sessionID) {
|
||||
const row = yield* db
|
||||
.update(SessionTable)
|
||||
.set({
|
||||
resume_attempts: sql`${SessionTable.resume_attempts} + 1`,
|
||||
time_updated: sql`${SessionTable.time_updated}`,
|
||||
})
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.returning({ attempts: SessionTable.resume_attempts })
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
return row?.attempts
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ export * as SessionTransfer from "./transfer"
|
||||
import { SessionTransfer } from "@opencode-ai/schema/session-transfer"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { eq, isNotNull, isNull, ne, or } from "drizzle-orm"
|
||||
import { Context, DateTime, Effect, Layer, Schema } from "effect"
|
||||
import path from "path"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
@@ -12,7 +12,7 @@ import { Bus } from "../bus"
|
||||
import { Database } from "../database/database"
|
||||
import { Location } from "../location"
|
||||
import { Project } from "../project"
|
||||
import { upsertProject } from "../project/sql"
|
||||
import { ProjectTable } from "../project/sql"
|
||||
import { AbsolutePath, RelativePath } from "../schema"
|
||||
import { Session } from "../session"
|
||||
import { Slug } from "../util/slug"
|
||||
@@ -49,7 +49,22 @@ const layer = Layer.effect(
|
||||
const sessions = yield* Session.Service
|
||||
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
|
||||
|
||||
const persistProject = (project: Project.Resolved) => upsertProject(db, project).pipe(Effect.orDie)
|
||||
const persistProject = (project: Project.Resolved) => {
|
||||
const vcs = project.vcs?.type
|
||||
return db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: project.id, worktree: project.canonical, vcs, sandboxes: [] })
|
||||
.onConflictDoUpdate({
|
||||
target: ProjectTable.id,
|
||||
set: { worktree: project.canonical, vcs: vcs ?? null },
|
||||
setWhere: or(
|
||||
ne(ProjectTable.worktree, project.canonical),
|
||||
vcs ? or(isNull(ProjectTable.vcs), ne(ProjectTable.vcs, vcs)) : isNotNull(ProjectTable.vcs),
|
||||
),
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
return Service.of({
|
||||
export: Effect.fn("SessionTransfer.export")(function* (input) {
|
||||
|
||||
@@ -21,7 +21,7 @@ Usage notes:
|
||||
- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label`
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
questions: Schema.Array(Question.Prompt).check(Schema.isNonEmpty()).annotate({ description: "Questions to ask" }),
|
||||
questions: Schema.NonEmptyArray(Question.Prompt).annotate({ description: "Questions to ask" }),
|
||||
})
|
||||
|
||||
export const Output = Schema.Struct({
|
||||
|
||||
@@ -165,6 +165,7 @@ describe("Agent", () => {
|
||||
"compaction",
|
||||
"explore",
|
||||
"general",
|
||||
"plan",
|
||||
"summary",
|
||||
"title",
|
||||
])
|
||||
|
||||
@@ -123,16 +123,6 @@ describe("AISDKNative", () => {
|
||||
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",
|
||||
{
|
||||
region: "us-west-2",
|
||||
baseURL: "https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1",
|
||||
},
|
||||
"openai.gpt-5.5",
|
||||
),
|
||||
).toMatchObject({ settings: { baseURL: "https://bedrock-mantle.us-west-2.api.aws/openai/v1" } })
|
||||
})
|
||||
|
||||
test("maps static Bedrock Mantle credentials without leaking connection options", () => {
|
||||
@@ -144,9 +134,8 @@ describe("AISDKNative", () => {
|
||||
accessKeyId: "key",
|
||||
secretAccessKey: "secret",
|
||||
sessionToken: "session",
|
||||
region: "eu-west-1",
|
||||
},
|
||||
baseURL: "https://bedrock-mantle.${AWS_REGION}.api.aws/v1",
|
||||
region: "eu-west-1",
|
||||
profile: "ignored",
|
||||
credentialProvider: "ignored",
|
||||
fetch: "ignored",
|
||||
@@ -163,7 +152,7 @@ describe("AISDKNative", () => {
|
||||
sessionToken: "session",
|
||||
region: "eu-west-1",
|
||||
},
|
||||
baseURL: "https://bedrock-mantle.eu-west-1.api.aws/v1",
|
||||
region: "eu-west-1",
|
||||
providerOptions: { openai: { store: false } },
|
||||
},
|
||||
})
|
||||
@@ -273,35 +262,6 @@ describe("AISDKNative", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("maps Vertex Anthropic settings to native Messages", () => {
|
||||
expect(
|
||||
map("@ai-sdk/google-vertex/anthropic", {
|
||||
accessToken: "vertex-token",
|
||||
baseURL: "https://vertex.example/v1",
|
||||
headers: { "x-test": "value" },
|
||||
location: "eu",
|
||||
project: "vertex-project",
|
||||
thinking: { type: "adaptive", display: "summarized" },
|
||||
effort: "high",
|
||||
}),
|
||||
).toEqual({
|
||||
package: "@opencode-ai/ai/providers/google-vertex/messages",
|
||||
settings: {
|
||||
accessToken: "vertex-token",
|
||||
baseURL: "https://vertex.example/v1",
|
||||
location: "eu",
|
||||
project: "vertex-project",
|
||||
providerOptions: {
|
||||
anthropic: {
|
||||
thinking: { type: "adaptive", display: "summarized" },
|
||||
effort: "high",
|
||||
},
|
||||
},
|
||||
},
|
||||
headers: { "x-test": "value" },
|
||||
})
|
||||
})
|
||||
|
||||
test("maps supported xAI settings", () => {
|
||||
expect(
|
||||
map("@ai-sdk/xai", {
|
||||
|
||||
@@ -275,7 +275,7 @@ it.effect("projects replay metadata onto AI SDK prompt parts", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("moves tool result images and PDFs into an AI SDK user message", () =>
|
||||
it.effect("preserves tool result content in AI SDK prompts", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* aisdk.hook.sdk((event) => {
|
||||
@@ -323,96 +323,21 @@ it.effect("moves tool result images and PDFs into an AI SDK user message", () =>
|
||||
type: "content",
|
||||
value: [
|
||||
{ type: "text", text: "attachments" },
|
||||
{ type: "image-data", data: "AAAA", mediaType: "image/png" },
|
||||
{
|
||||
type: "file-data",
|
||||
data: "JVBERg==",
|
||||
mediaType: "application/pdf",
|
||||
filename: "document.pdf",
|
||||
},
|
||||
{ type: "file-data", data: "SUQz", mediaType: "audio/mpeg", filename: "clip.mp3" },
|
||||
{ type: "image-url", url: "https://example.com/pixel.png" },
|
||||
{ type: "file-url", url: "https://example.com/document.pdf" },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Attached media from tool result:" },
|
||||
{ type: "file", mediaType: "image/png", data: "data:image/png;base64,AAAA", filename: "pixel.png" },
|
||||
{
|
||||
type: "file",
|
||||
mediaType: "application/pdf",
|
||||
data: "data:application/pdf;charset=utf-8;base64,JVBERg==",
|
||||
filename: "document.pdf",
|
||||
},
|
||||
{ type: "file", mediaType: "image/png", data: "https://example.com/pixel.png" },
|
||||
{ type: "file", mediaType: "application/pdf", data: "https://example.com/document.pdf" },
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("groups consecutive AI SDK tool media and keeps file-only results non-empty", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* aisdk.hook.sdk((event) => {
|
||||
event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
|
||||
})
|
||||
|
||||
const resolved = yield* aisdk.model(model("test-ai-sdk"))
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: resolved,
|
||||
messages: [
|
||||
Message.tool({
|
||||
id: "call_1",
|
||||
name: "read",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "one.png" }],
|
||||
},
|
||||
}),
|
||||
Message.tool({
|
||||
id: "call_2",
|
||||
name: "read",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [{ type: "file", uri: "data:image/png;base64,BBBB", mime: "image/png", name: "two.png" }],
|
||||
},
|
||||
}),
|
||||
Message.assistant("Images received"),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.prompt).toEqual([
|
||||
{
|
||||
role: "tool",
|
||||
content: [
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: "call_1",
|
||||
toolName: "read",
|
||||
output: { type: "text", value: "Media attached in following user message." },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "tool",
|
||||
content: [
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: "call_2",
|
||||
toolName: "read",
|
||||
output: { type: "text", value: "Media attached in following user message." },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Attached media from tool result:" },
|
||||
{ type: "file", mediaType: "image/png", data: "data:image/png;base64,AAAA", filename: "one.png" },
|
||||
{ type: "file", mediaType: "image/png", data: "data:image/png;base64,BBBB", filename: "two.png" },
|
||||
],
|
||||
},
|
||||
{ role: "assistant", content: [{ type: "text", text: "Images received" }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1464,13 +1464,13 @@ describe("Config", () => {
|
||||
])
|
||||
expect(entries.filter((entry) => entry.type === "agents").map((entry) => entry.path)).toEqual([
|
||||
AbsolutePath.make(globalAgents),
|
||||
AbsolutePath.make(path.join(root, ".agents")),
|
||||
AbsolutePath.make(path.join(directory, ".agents")),
|
||||
AbsolutePath.make(path.join(root, ".agents")),
|
||||
])
|
||||
expect(entries.filter((entry) => entry.type === "claude").map((entry) => entry.path)).toEqual([
|
||||
AbsolutePath.make(globalClaude),
|
||||
AbsolutePath.make(path.join(root, ".claude")),
|
||||
AbsolutePath.make(path.join(directory, ".claude")),
|
||||
AbsolutePath.make(path.join(root, ".claude")),
|
||||
])
|
||||
expect(documents.map((document) => document.info.$schema)).toEqual([
|
||||
"global",
|
||||
@@ -1483,11 +1483,11 @@ describe("Config", () => {
|
||||
])
|
||||
expect(entries.map((entry) => (entry.type === "document" ? entry.info.$schema : entry.path))).toEqual([
|
||||
AbsolutePath.make(globalClaude),
|
||||
AbsolutePath.make(path.join(root, ".claude")),
|
||||
AbsolutePath.make(path.join(directory, ".claude")),
|
||||
AbsolutePath.make(path.join(root, ".claude")),
|
||||
AbsolutePath.make(globalAgents),
|
||||
AbsolutePath.make(path.join(root, ".agents")),
|
||||
AbsolutePath.make(path.join(directory, ".agents")),
|
||||
AbsolutePath.make(path.join(root, ".agents")),
|
||||
"global",
|
||||
AbsolutePath.make(global),
|
||||
"outside",
|
||||
|
||||
@@ -168,75 +168,6 @@ describe("PluginSupervisor config", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("loads auto-discovered plugin package entrypoints in order", () =>
|
||||
withLocation(
|
||||
undefined,
|
||||
Effect.gen(function* () {
|
||||
yield* ready()
|
||||
const plugins = yield* Plugin.Service
|
||||
const ids = (yield* plugins.list()).map((plugin) => String(plugin.id))
|
||||
expect(ids).toContain("package-exports")
|
||||
expect(ids).toContain("package-module")
|
||||
expect(ids).toContain("package-main")
|
||||
expect(ids).toContain("package-index")
|
||||
}),
|
||||
false,
|
||||
async (directory) => {
|
||||
await Promise.all([
|
||||
writeDiscoveredPackage(directory, "exports", { exports: "./entry.ts" }, { "entry.ts": "package-exports" }),
|
||||
writeDiscoveredPackage(
|
||||
directory,
|
||||
"module",
|
||||
{ exports: "./missing.js", module: "./entry.js" },
|
||||
{ "entry.js": "package-module" },
|
||||
),
|
||||
writeDiscoveredPackage(
|
||||
directory,
|
||||
"main",
|
||||
{ exports: { import: "./missing.js" }, module: "./missing.js", main: "./entry.js" },
|
||||
{ "entry.js": "package-main" },
|
||||
),
|
||||
writeDiscoveredPackage(directory, "index", undefined, { "index.js": "package-index" }),
|
||||
])
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
it.live("keeps auto-discovered package entrypoints inside the package directory", () =>
|
||||
withLocation(
|
||||
undefined,
|
||||
Effect.gen(function* () {
|
||||
yield* ready()
|
||||
const plugins = yield* Plugin.Service
|
||||
const ids = (yield* plugins.list()).map((plugin) => String(plugin.id))
|
||||
expect(ids).toContain("contained-fallback")
|
||||
expect(ids).toContain("symlink-fallback")
|
||||
expect(ids).not.toContain("escaped-entrypoint")
|
||||
}),
|
||||
false,
|
||||
async (directory) => {
|
||||
await fs.mkdir(path.join(directory, ".opencode"), { recursive: true })
|
||||
await fs.writeFile(path.join(directory, ".opencode", "escape.js"), discoveredPlugin("escaped-entrypoint"))
|
||||
await writeDiscoveredPackage(
|
||||
directory,
|
||||
"contained",
|
||||
{ exports: "../../escape.js" },
|
||||
{ "index.js": "contained-fallback" },
|
||||
)
|
||||
await writeDiscoveredPackage(
|
||||
directory,
|
||||
"symlink",
|
||||
{ exports: "./entry.js" },
|
||||
{ "index.js": "symlink-fallback" },
|
||||
)
|
||||
await fs.symlink(
|
||||
path.join(directory, ".opencode", "escape.js"),
|
||||
path.join(directory, ".opencode", "plugins", "symlink", "entry.js"),
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
staticIt.live("uses only internal and SDK plugins when the static source is wired", () =>
|
||||
Effect.gen(function* () {
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
@@ -458,21 +389,3 @@ export default Plugin.define({
|
||||
})
|
||||
`
|
||||
}
|
||||
|
||||
function discoveredPlugin(id: string) {
|
||||
return `export default { id: ${JSON.stringify(id)}, setup() {} }`
|
||||
}
|
||||
|
||||
async function writeDiscoveredPackage(
|
||||
directory: string,
|
||||
name: string,
|
||||
manifest: Record<string, unknown> | undefined,
|
||||
files: Record<string, string>,
|
||||
) {
|
||||
const plugin = path.join(directory, ".opencode", "plugins", name)
|
||||
await fs.mkdir(plugin, { recursive: true })
|
||||
await Promise.all([
|
||||
...(manifest ? [fs.writeFile(path.join(plugin, "package.json"), JSON.stringify(manifest))] : []),
|
||||
...Object.entries(files).map(([file, id]) => fs.writeFile(path.join(plugin, file), discoveredPlugin(id))),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import { SkillFile } from "@opencode-ai/core/config/plugin/skill-file"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
@@ -24,8 +23,6 @@ import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Skill } from "@opencode-ai/core/skill"
|
||||
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
|
||||
import { WellKnown } from "@opencode-ai/core/wellknown"
|
||||
import { emptyCredentialNode, emptyWellknownNode } from "../fixture/config-nodes"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { location } from "../fixture/location"
|
||||
import { testEffect } from "../lib/effect"
|
||||
@@ -94,25 +91,6 @@ const start = (skills: string[], directory: string) =>
|
||||
directory,
|
||||
)
|
||||
|
||||
const discover = (directory: string, global: string) =>
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
return yield* config.entries()
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node]), [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
||||
],
|
||||
[Global.node, Global.layerWith({ config: global, home: path.join(global, "home") })],
|
||||
[Credential.node, emptyCredentialNode],
|
||||
[WellKnown.node, emptyWellknownNode],
|
||||
[Watcher.node, Watcher.testLayer],
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
function emitAndWait(update: Watcher.Update) {
|
||||
return Effect.gen(function* () {
|
||||
const watcher = yield* Watcher.Test
|
||||
@@ -240,37 +218,6 @@ describe("ConfigSkillPlugin.Plugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("prefers a worktree skill over the parent checkout copy", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const checkout = path.join(tmp.path, "repo")
|
||||
const worktree = path.join(checkout, ".worktrees", "feature")
|
||||
const parentSkills = path.join(checkout, ".agents", "skills")
|
||||
const worktreeSkills = path.join(worktree, ".agents", "skills")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(checkout, ".git"), { recursive: true })
|
||||
await fs.mkdir(path.join(parentSkills, "review"), { recursive: true })
|
||||
await fs.mkdir(path.join(worktreeSkills, "review"), { recursive: true })
|
||||
await fs.writeFile(path.join(worktree, ".git"), "gitdir: ../../../.git/worktrees/feature\n")
|
||||
await write(parentSkills, "review", "Parent checkout")
|
||||
await write(worktreeSkills, "review", "Worktree")
|
||||
})
|
||||
|
||||
const entries = yield* discover(worktree, path.join(tmp.path, "global"))
|
||||
const skill = yield* startEntries(entries, worktree)
|
||||
const review = (yield* skill.list()).find((item) => item.id === "review")
|
||||
|
||||
expect(review?.description).toBe("Worktree")
|
||||
expect(review?.location).toBe(AbsolutePath.make(path.join(worktreeSkills, "review", "SKILL.md")))
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("keeps directory skills when a URL source fails", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
||||
@@ -84,19 +84,6 @@ describe("DatabaseMigration", () => {
|
||||
).rejects.toThrow("Database is not empty and has no session table")
|
||||
})
|
||||
|
||||
test("bootstraps alongside underscore-prefixed embedder tables", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* db.run(sql`CREATE TABLE _embedder_state (id text PRIMARY KEY)`)
|
||||
yield* DatabaseMigration.apply(db)
|
||||
expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_v2'`)).toEqual(
|
||||
{ name: "session_v2" },
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("applies generic migrations once and records their order", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -24,7 +24,6 @@ const it = testEffect(Layer.empty)
|
||||
|
||||
const instructionLayer = (input: {
|
||||
config?: string
|
||||
home?: string
|
||||
locationServiceLayer: Layer.Layer<Location.Service>
|
||||
filesystemLayer?: Layer.Layer<FSUtil.Service>
|
||||
project?: boolean
|
||||
@@ -35,15 +34,7 @@ const instructionLayer = (input: {
|
||||
LayerNode.group([InstructionDiscovery.node, Bus.node, FSUtil.node, Global.node, Location.node, Watcher.node]),
|
||||
[
|
||||
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: input.project })],
|
||||
[
|
||||
Global.node,
|
||||
input.config || input.home
|
||||
? Global.layerWith({
|
||||
...(input.config ? { config: input.config } : {}),
|
||||
...(input.home ? { home: input.home } : {}),
|
||||
})
|
||||
: tempGlobalLayer,
|
||||
],
|
||||
[Global.node, input.config ? Global.layerWith({ config: input.config }) : tempGlobalLayer],
|
||||
[Location.node, input.locationServiceLayer],
|
||||
[Watcher.node, watcher],
|
||||
...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []),
|
||||
@@ -121,13 +112,10 @@ describe("ConfigInstructionPlugin.Plugin", () => {
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
const global = path.join(tmp.path, "global")
|
||||
const home = path.join(tmp.path, "home")
|
||||
const shared = path.join(home, "code")
|
||||
const project = path.join(shared, "repo")
|
||||
const project = path.join(tmp.path, "project")
|
||||
const directory = path.join(project, "packages", "core")
|
||||
const outside = path.join(tmp.path, "AGENTS.md")
|
||||
const globalFile = path.join(global, "AGENTS.md")
|
||||
const sharedFile = path.join(shared, "AGENTS.md")
|
||||
const projectFile = path.join(project, "AGENTS.md")
|
||||
const packageFile = path.join(directory, "AGENTS.md")
|
||||
return Effect.gen(function* () {
|
||||
@@ -136,7 +124,6 @@ describe("ConfigInstructionPlugin.Plugin", () => {
|
||||
await fs.mkdir(directory, { recursive: true })
|
||||
await fs.writeFile(outside, "outside")
|
||||
await fs.writeFile(globalFile, "global")
|
||||
await fs.writeFile(sharedFile, "shared")
|
||||
await fs.writeFile(projectFile, "project")
|
||||
await fs.writeFile(packageFile, "package")
|
||||
})
|
||||
@@ -148,20 +135,13 @@ describe("ConfigInstructionPlugin.Plugin", () => {
|
||||
{ path: packageFile, type: "file" },
|
||||
{ path: path.join(project, "packages", "AGENTS.md"), type: "file" },
|
||||
{ path: projectFile, type: "file" },
|
||||
{ path: sharedFile, type: "file" },
|
||||
{ path: path.join(home, "AGENTS.md"), type: "file" },
|
||||
])
|
||||
expect(yield* watcher.subscriptions()).not.toContainEqual({
|
||||
path: path.join(tmp.path, "AGENTS.md"),
|
||||
type: "file",
|
||||
})
|
||||
const initialized = yield* readInitial(yield* discovery.load())
|
||||
expect(initialized.text).toBe(
|
||||
[
|
||||
`Instructions from: ${globalFile}\nglobal`,
|
||||
`Instructions from: ${packageFile}\npackage`,
|
||||
`Instructions from: ${projectFile}\nproject`,
|
||||
`Instructions from: ${sharedFile}\nshared`,
|
||||
].join("\n\n"),
|
||||
)
|
||||
expect(initialized.text).not.toContain("outside")
|
||||
@@ -179,7 +159,6 @@ describe("ConfigInstructionPlugin.Plugin", () => {
|
||||
"These instructions replace all previously loaded ambient instructions.",
|
||||
`Instructions from: ${globalFile}\nglobal`,
|
||||
`Instructions from: ${projectFile}\nproject`,
|
||||
`Instructions from: ${sharedFile}\nshared`,
|
||||
].join("\n\n"),
|
||||
)
|
||||
|
||||
@@ -187,8 +166,6 @@ describe("ConfigInstructionPlugin.Plugin", () => {
|
||||
yield* emitAndWait({ type: "delete", path: globalFile })
|
||||
yield* Effect.promise(() => fs.rm(projectFile))
|
||||
yield* emitAndWait({ type: "delete", path: projectFile })
|
||||
yield* Effect.promise(() => fs.rm(sharedFile))
|
||||
yield* emitAndWait({ type: "delete", path: sharedFile })
|
||||
expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toBe(
|
||||
"Previously loaded instructions no longer apply.",
|
||||
)
|
||||
@@ -196,7 +173,6 @@ describe("ConfigInstructionPlugin.Plugin", () => {
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
config: global,
|
||||
home,
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
@@ -239,17 +215,15 @@ describe("ConfigInstructionPlugin.Plugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("discovers a newly created instruction file above the project root", () =>
|
||||
it.live("discovers a newly created instruction file in an intermediate directory", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
const home = path.join(tmp.path, "home")
|
||||
const shared = path.join(home, "code")
|
||||
const project = path.join(shared, "repo")
|
||||
const intermediate = path.join(shared, "AGENTS.md")
|
||||
const directory = path.join(project, "core")
|
||||
const project = path.join(tmp.path, "project")
|
||||
const intermediate = path.join(project, "packages", "AGENTS.md")
|
||||
const directory = path.join(project, "packages", "core")
|
||||
const projectFile = path.join(project, "AGENTS.md")
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
|
||||
@@ -261,7 +235,7 @@ describe("ConfigInstructionPlugin.Plugin", () => {
|
||||
yield* emitAndWait({ type: "create", path: intermediate })
|
||||
|
||||
expect((yield* readInitial(yield* discovery.load())).text).toBe(
|
||||
[`Instructions from: ${projectFile}\nproject`, `Instructions from: ${intermediate}\nintermediate`].join(
|
||||
[`Instructions from: ${intermediate}\nintermediate`, `Instructions from: ${projectFile}\nproject`].join(
|
||||
"\n\n",
|
||||
),
|
||||
)
|
||||
@@ -269,48 +243,6 @@ describe("ConfigInstructionPlugin.Plugin", () => {
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
config: path.join(tmp.path, "global"),
|
||||
home,
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory: AbsolutePath.make(directory) },
|
||||
{ projectDirectory: AbsolutePath.make(project) },
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("stops instruction candidates at the project root outside home", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
const global = path.join(tmp.path, "global")
|
||||
const home = path.join(tmp.path, "home")
|
||||
const project = path.join(tmp.path, "scratch", "repo")
|
||||
const directory = path.join(project, "packages", "core")
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
|
||||
yield* start()
|
||||
const watcher = yield* Watcher.Test
|
||||
expect(yield* watcher.subscriptions()).toEqual([
|
||||
{ path: path.join(global, "AGENTS.md"), type: "file" },
|
||||
{ path: path.join(directory, "AGENTS.md"), type: "file" },
|
||||
{ path: path.join(project, "packages", "AGENTS.md"), type: "file" },
|
||||
{ path: path.join(project, "AGENTS.md"), type: "file" },
|
||||
])
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
config: global,
|
||||
home,
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
|
||||
@@ -123,24 +123,6 @@ describe("ModelResolver", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("resolves environment templates before native providers inspect endpoints", () =>
|
||||
withEnv({ AZURE_HOST: "resource.openai.azure.com" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/azure"), {
|
||||
providerID: Provider.ID.azure,
|
||||
settings: { baseURL: "https://${AZURE_HOST}/openai" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(resolved.route.endpoint).toMatchObject({
|
||||
baseURL: "https://resource.openai.azure.com/openai/v1",
|
||||
query: { "api-version": "v1" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("maps Bedrock Mantle models to native Responses and safeguards to Chat", () =>
|
||||
Effect.gen(function* () {
|
||||
const credential = Credential.Key.make({ type: "key", key: "secret" })
|
||||
@@ -170,46 +152,6 @@ describe("ModelResolver", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("resolves Bedrock Mantle catalog endpoints from the configured region", () =>
|
||||
withEnv({ AWS_REGION: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = model(Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"), {
|
||||
providerID: Provider.ID.amazonBedrock,
|
||||
modelID: "openai.gpt-5.5",
|
||||
settings: {
|
||||
region: "us-west-2",
|
||||
baseURL: "https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1",
|
||||
},
|
||||
})
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(catalog)
|
||||
|
||||
expect(resolved.route).toMatchObject({
|
||||
id: "bedrock-mantle-responses",
|
||||
endpoint: { baseURL: "https://bedrock-mantle.us-west-2.api.aws/openai/v1" },
|
||||
})
|
||||
expect(catalog.settings?.baseURL).toBe("https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("prefers the configured Mantle region over the environment", () =>
|
||||
withEnv({ AWS_REGION: "us-east-1" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"), {
|
||||
modelID: "openai.gpt-5.5",
|
||||
settings: {
|
||||
region: "us-west-2",
|
||||
baseURL: "https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1",
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(resolved.route.endpoint.baseURL).toBe("https://bedrock-mantle.us-west-2.api.aws/openai/v1")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("uses the API modelID instead of the catalog ID for native OpenAI routes", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = model(Provider.aisdk("@ai-sdk/openai"), {
|
||||
@@ -323,7 +265,7 @@ describe("ModelResolver", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("rejects unresolved variables in constructed provider routes", () =>
|
||||
it.effect("rejects unresolved provider URL variables before route construction", () =>
|
||||
withEnv({ REQUIRED_HOST: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const failure = yield* ModelResolver.fromCatalogModel(
|
||||
@@ -763,57 +705,6 @@ describe("ModelResolver", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("routes Vertex Anthropic catalog models through native Messages", () =>
|
||||
Effect.gen(function* () {
|
||||
const native = yield* ModelResolver.fromCatalogModel(model(Provider.aisdk("@ai-sdk/openai")))
|
||||
const credential = Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: Integration.MethodID.make("device"),
|
||||
access: "vertex-token",
|
||||
refresh: "refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
})
|
||||
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/google-vertex/anthropic"), {
|
||||
modelID: "claude-sonnet-4-6",
|
||||
settings: {
|
||||
location: "eu",
|
||||
project: "vertex-project",
|
||||
thinking: { type: "adaptive", display: "summarized" },
|
||||
effort: "high",
|
||||
},
|
||||
}),
|
||||
credential,
|
||||
{
|
||||
loadPackage: (specifier) => {
|
||||
expect(specifier).toBe("@opencode-ai/ai/providers/google-vertex/messages")
|
||||
return Effect.succeed({
|
||||
model: (modelID, settings) => {
|
||||
expect(modelID).toBe("claude-sonnet-4-6")
|
||||
expect(settings).toMatchObject({
|
||||
accessToken: "vertex-token",
|
||||
location: "eu",
|
||||
project: "vertex-project",
|
||||
providerOptions: {
|
||||
anthropic: {
|
||||
thinking: { type: "adaptive", display: "summarized" },
|
||||
effort: "high",
|
||||
},
|
||||
},
|
||||
})
|
||||
return LanguageModel.make({ id: modelID, provider: "native-provider", route: native.route })
|
||||
},
|
||||
})
|
||||
},
|
||||
loadAISDK: () => Effect.die("AI SDK loader should not be called"),
|
||||
},
|
||||
)
|
||||
|
||||
expect(resolved).toMatchObject({ id: "claude-sonnet-4-6", provider: "test-provider" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("merges mapped OpenRouter headers and body with catalog overlays", () =>
|
||||
ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@openrouter/ai-sdk-provider"), {
|
||||
@@ -932,63 +823,6 @@ describe("ModelResolver", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects unresolved variables before loading opaque AISDK packages", () =>
|
||||
withEnv({ REQUIRED_HOST: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const failure = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/mistral"), {
|
||||
settings: { baseURL: "https://${REQUIRED_HOST}/v1" },
|
||||
}),
|
||||
undefined,
|
||||
{ loadAISDK: () => Effect.die("AI SDK loader should not be called") },
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(failure).toMatchObject({
|
||||
_tag: "SessionRunnerModel.UnresolvedProviderVariablesError",
|
||||
variables: ["REQUIRED_HOST"],
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("rejects placeholders introduced by environment expansion before loading providers", () =>
|
||||
withEnv({ PROVIDER_HOST: "${MISSING_HOST}", MISSING_HOST: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const failure = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/mistral"), {
|
||||
settings: { baseURL: "https://${PROVIDER_HOST}/v1" },
|
||||
}),
|
||||
undefined,
|
||||
{ loadAISDK: () => Effect.die("AI SDK loader should not be called") },
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(failure).toMatchObject({
|
||||
_tag: "SessionRunnerModel.UnresolvedProviderVariablesError",
|
||||
variables: ["MISSING_HOST"],
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("rejects unresolved variables before loading native provider packages", () =>
|
||||
withEnv({ REQUIRED_HOST: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const failure = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/google"), {
|
||||
settings: { baseURL: "https://${REQUIRED_HOST}/v1" },
|
||||
}),
|
||||
undefined,
|
||||
{ loadPackage: () => Effect.die("Native package loader should not be called") },
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(failure).toMatchObject({
|
||||
_tag: "SessionRunnerModel.UnresolvedProviderVariablesError",
|
||||
variables: ["REQUIRED_HOST"],
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("rejects AISDK packages without an available loader", () =>
|
||||
Effect.gen(function* () {
|
||||
const failure = yield* ModelResolver.fromCatalogModel(
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { ConfigPluginSource } from "@opencode-ai/core/config/plugin/source"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { SkillPlugin } from "@opencode-ai/core/plugin/skill"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Skill } from "@opencode-ai/core/skill"
|
||||
import { location } from "../fixture/location"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { host } from "./host"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(Skill.node))
|
||||
const sources = (operations: readonly ConfigPluginSource.Operation[] = []) =>
|
||||
Layer.succeed(
|
||||
ConfigPluginSource.Service,
|
||||
ConfigPluginSource.Service.of({ operations: () => Effect.succeed(operations), changes: () => Stream.never }),
|
||||
)
|
||||
|
||||
describe("SkillPlugin.Plugin", () => {
|
||||
it.effect("registers built-in skills", () =>
|
||||
@@ -27,7 +27,15 @@ describe("SkillPlugin.Plugin", () => {
|
||||
reload: skill.reload,
|
||||
},
|
||||
}),
|
||||
).pipe(Effect.provide(sources()))
|
||||
).pipe(
|
||||
Effect.provide(Config.testLayer()),
|
||||
Effect.provideService(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(import.meta.dir) })),
|
||||
),
|
||||
Effect.provide(AppNodeBuilder.build(FSUtil.node)),
|
||||
Effect.provide(NodeFileSystem.layer),
|
||||
)
|
||||
const skills = yield* skill.list()
|
||||
const report = skills.find((item) => item.id === "report")
|
||||
|
||||
@@ -50,30 +58,4 @@ describe("SkillPlugin.Plugin", () => {
|
||||
expect(report?.content).toContain("- install/channel: beta")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reports canonical configured plugin sources with existing labels and ordering", () =>
|
||||
Effect.gen(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
yield* SkillPlugin.Plugin.effect(
|
||||
host({
|
||||
skill: {
|
||||
list: () => Effect.die("unused skill.list"),
|
||||
transform: skill.transform,
|
||||
reload: skill.reload,
|
||||
},
|
||||
}),
|
||||
)
|
||||
const report = (yield* skill.list()).find((item) => item.id === "report")
|
||||
expect(report?.content).toContain("- Active plugins: -disabled, local.ts, package-plugin, package-plugin")
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
sources([
|
||||
{ type: "add", target: "package-plugin", options: {} },
|
||||
{ type: "remove", target: "disabled" },
|
||||
{ type: "add", target: "local.ts", options: {}, mtime: 1 },
|
||||
{ type: "add", target: "package-plugin", options: { enabled: true } },
|
||||
]),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Effect } from "effect"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
import { WebSearchExa } from "@opencode-ai/core/plugin/websearch/exa"
|
||||
import { WebSearchFirecrawl } from "@opencode-ai/core/plugin/websearch/firecrawl"
|
||||
import { WebSearchParallel } from "@opencode-ai/core/plugin/websearch/parallel"
|
||||
import { host, integrationHost, webSearchHost } from "./host"
|
||||
import { requests, resetWebSearchFixture, webSearchIntegrationTest } from "./websearch-fixture"
|
||||
@@ -54,22 +53,6 @@ describe("built-in web search providers", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("registers Firecrawl with the standard key method", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* WebSearchFirecrawl.Plugin.effect(
|
||||
host({ integration: integrationHost(integrations), websearch: webSearchHost(websearch) }),
|
||||
)
|
||||
|
||||
expect(yield* integrations.get(Integration.ID.make("firecrawl"))).toMatchObject({
|
||||
id: "firecrawl",
|
||||
name: "Firecrawl",
|
||||
methods: [{ type: "key" }, { type: "env", names: ["FIRECRAWL_API_KEY"] }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("registers Exa with its MCP schema", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
@@ -146,9 +129,6 @@ describe("built-in web search providers", () => {
|
||||
yield* WebSearchParallel.Plugin.effect(
|
||||
host({ integration: integrationHost(integrations), websearch: webSearchHost(websearch) }),
|
||||
)
|
||||
expect(yield* integrations.get(Integration.ID.make("parallel"))).toMatchObject({
|
||||
methods: [{ type: "key" }, { type: "env", names: ["PARALLEL_API_KEY"] }],
|
||||
})
|
||||
yield* integrations.connection.key({
|
||||
integrationID: Integration.ID.make("parallel"),
|
||||
key: "parallel-secret",
|
||||
|
||||
@@ -654,7 +654,7 @@ describe("Session.create", () => {
|
||||
expect(yield* session.get(created.id)).toMatchObject({ agent: "plan" })
|
||||
expect(
|
||||
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.drop(1), Stream.take(1), Stream.runCollect)),
|
||||
).toMatchObject([{ type: "session.agent.selected", data: { agent: "plan", previous: "build" } }])
|
||||
).toMatchObject([{ type: "session.agent.selected", data: { agent: "plan" } }])
|
||||
expect(yield* session.messages({ sessionID: created.id, order: "asc" })).toMatchObject([
|
||||
{ type: "agent-switched", agent: "plan", previous: "build" },
|
||||
])
|
||||
@@ -678,12 +678,7 @@ describe("Session.create", () => {
|
||||
it.effect("switches the selected model through the durable Session event", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const previous = Model.Ref.make({
|
||||
id: Model.ID.make("haiku"),
|
||||
providerID: Provider.ID.anthropic,
|
||||
variant: Model.VariantID.make("default"),
|
||||
})
|
||||
const created = yield* session.create({ location, model: previous })
|
||||
const created = yield* session.create({ location })
|
||||
const model = Model.Ref.make({
|
||||
id: Model.ID.make("sonnet"),
|
||||
providerID: Provider.ID.anthropic,
|
||||
@@ -697,10 +692,7 @@ describe("Session.create", () => {
|
||||
yield* logEvents(session, created.id, true).pipe(Stream.drop(1), Stream.take(1), Stream.runCollect),
|
||||
)
|
||||
expect(bus).toMatchObject([{ type: "session.model.selected" }])
|
||||
expect(bus[0]?.data).toEqual({ sessionID: created.id, model, previous })
|
||||
expect(yield* session.messages({ sessionID: created.id, order: "asc" })).toMatchObject([
|
||||
{ type: "model-switched", model, previous },
|
||||
])
|
||||
expect(bus[0]?.data).toEqual({ sessionID: created.id, model })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import { SessionRunner } from "@opencode-ai/core/session/runner"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { Context, Deferred, Effect, Exit, Fiber, Layer, LayerMap, Scope } from "effect"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionStore.node])))
|
||||
@@ -50,90 +49,58 @@ describe("SessionExecution lifecycle", () => {
|
||||
})
|
||||
})
|
||||
|
||||
it.effect("the sweep only lists claimed top-level Sessions", () =>
|
||||
it.effect("atomically consumes each suspension at most once", () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const parent = Session.ID.make("ses_recover_parent")
|
||||
const child = Session.ID.make("ses_recover_child")
|
||||
const idle = Session.ID.make("ses_recover_idle")
|
||||
yield* seedSessions(database, [parent], { time_suspended: Date.now() })
|
||||
yield* seedSessions(database, [idle])
|
||||
// An orphaned child is never resumed: the resumed parent re-runs its
|
||||
// tool call and spawns a fresh child instead.
|
||||
yield* seedSessions(database, [child], { time_suspended: Date.now(), parent_id: parent })
|
||||
const first = Session.ID.make("ses_recover_first")
|
||||
const second = Session.ID.make("ses_recover_second")
|
||||
yield* seedSessions(database, [first, second], { time_suspended: Date.now() })
|
||||
|
||||
expect(yield* store.listSuspended()).toEqual([parent])
|
||||
|
||||
// The sweep clears orphaned child claims outright; parents keep theirs.
|
||||
yield* store.releaseChildClaims
|
||||
expect(yield* claims(database)).toEqual({ [parent]: true, [child]: false, [idle]: false })
|
||||
expect(yield* store.consumeSuspended(first)).toBe(true)
|
||||
expect(yield* store.consumeSuspended(first)).toBe(false)
|
||||
expect(yield* store.consumeSuspended(second)).toBe(true)
|
||||
expect(yield* suspensions(database)).toEqual({ [first]: false, [second]: false })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("claims at execution start, releases on completion, and preserves through teardown", () =>
|
||||
it.effect("suspension survives teardown interruption and clears when a drain finishes on its own", () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const interrupted = Session.ID.make("ses_claim_interrupted")
|
||||
const completed = Session.ID.make("ses_claim_completed")
|
||||
const interrupted = Session.ID.make("ses_suspend_interrupted")
|
||||
const completed = Session.ID.make("ses_suspend_completed")
|
||||
yield* seedSessions(database, [interrupted, completed])
|
||||
|
||||
// Each drain signals once it runs; the claim commits before the drain starts.
|
||||
const interruptedRunning = yield* Deferred.make<void>()
|
||||
const completedRunning = yield* Deferred.make<void>()
|
||||
const draining = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const scope = yield* Scope.make()
|
||||
const context = yield* buildExecution(scope, ({ sessionID }) =>
|
||||
sessionID === completed
|
||||
? Deferred.succeed(completedRunning, undefined).pipe(Effect.andThen(Deferred.await(release)))
|
||||
: Deferred.succeed(interruptedRunning, undefined).pipe(Effect.andThen(Effect.never)),
|
||||
? Deferred.await(release)
|
||||
: Deferred.succeed(draining, undefined).pipe(Effect.andThen(Effect.never)),
|
||||
)
|
||||
const execution = Context.get(context, SessionExecution.Service)
|
||||
const restart = Context.get(context, SessionRestart.Service)
|
||||
yield* execution.resume(interrupted).pipe(Effect.forkScoped)
|
||||
const completing = yield* execution.resume(completed).pipe(Effect.forkIn(scope))
|
||||
yield* Deferred.await(interruptedRunning)
|
||||
yield* Deferred.await(completedRunning)
|
||||
yield* Deferred.await(draining)
|
||||
|
||||
// The write-ahead claim exists WHILE the turns run — no shutdown hook involved.
|
||||
expect(yield* claims(database)).toEqual({ [interrupted]: true, [completed]: true })
|
||||
yield* restart.suspendActiveSessions
|
||||
expect(yield* suspensions(database)).toEqual({ [interrupted]: true, [completed]: true })
|
||||
|
||||
// A drain that finishes on its own releases its claim.
|
||||
// A drain that finishes on its own after suspension clears its stale suspension.
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(completing)
|
||||
yield* execution.awaitIdle(completed)
|
||||
expect((yield* claims(database))[completed]).toBe(false)
|
||||
expect((yield* suspensions(database))[completed]).toBe(false)
|
||||
|
||||
// Teardown interruption (graceful twin of an unclean death) preserves the claim
|
||||
// for the next server start.
|
||||
// Teardown interruption preserves suspension for the next server start.
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect((yield* claims(database))[interrupted]).toBe(true)
|
||||
expect((yield* suspensions(database))[interrupted]).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("a user interrupt releases the claim so the turn never resurrects", () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const sessionID = Session.ID.make("ses_claim_user_cancel")
|
||||
yield* seedSessions(database, [sessionID])
|
||||
|
||||
const draining = yield* Deferred.make<void>()
|
||||
const scope = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
|
||||
const context = yield* buildExecution(scope, () =>
|
||||
Deferred.succeed(draining, undefined).pipe(Effect.andThen(Effect.never)),
|
||||
)
|
||||
const execution = Context.get(context, SessionExecution.Service)
|
||||
yield* execution.resume(sessionID).pipe(Effect.forkScoped)
|
||||
yield* Deferred.await(draining)
|
||||
expect((yield* claims(database))[sessionID]).toBe(true)
|
||||
|
||||
yield* execution.interrupt(sessionID)
|
||||
yield* execution.awaitIdle(sessionID)
|
||||
expect((yield* claims(database))[sessionID]).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("starts every claimed execution without waiting for earlier drains to finish", () =>
|
||||
it.effect("starts every suspended execution without waiting for earlier drains to finish", () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const sessionIDs = Array.from({ length: 5 }, (_, index) => Session.ID.make(`ses_resume_concurrent_${index}`))
|
||||
@@ -158,7 +125,7 @@ describe("SessionExecution lifecycle", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("resumes each claimed Session at most once", () =>
|
||||
it.effect("resumes each suspended Session at most once", () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
@@ -167,22 +134,14 @@ describe("SessionExecution lifecycle", () => {
|
||||
yield* seedSessions(database, [first, second], { time_suspended: Date.now() })
|
||||
|
||||
const drained: string[] = []
|
||||
const bothDraining = yield* Deferred.make<void>()
|
||||
const continued: SessionEvent.Synthetic[] = []
|
||||
const scope = yield* Scope.make()
|
||||
const context = yield* buildExecution(scope, ({ sessionID }) =>
|
||||
Effect.sync(() => {
|
||||
drained.push(sessionID)
|
||||
if (drained.length === 2) Deferred.doneUnsafe(bothDraining, Effect.void)
|
||||
}),
|
||||
)
|
||||
const context = yield* buildExecution(scope, ({ sessionID }) => Effect.sync(() => void drained.push(sessionID)))
|
||||
const execution = Context.get(context, SessionExecution.Service)
|
||||
const restart = Context.get(context, SessionRestart.Service)
|
||||
yield* bus.project(SessionEvent.Synthetic, (event) => Effect.sync(() => void continued.push(event)))
|
||||
|
||||
// The sweep forks resumed drains, so completion is observed through the executions.
|
||||
yield* restart.resumeSuspendedSessions
|
||||
yield* Deferred.await(bothDraining)
|
||||
yield* Effect.forEach([first, second], execution.awaitIdle, { discard: true })
|
||||
expect(drained.toSorted()).toEqual([first, second])
|
||||
expect(continued.map((event) => event.data).toSorted((a, b) => a.sessionID.localeCompare(b.sessionID))).toEqual(
|
||||
@@ -192,9 +151,7 @@ describe("SessionExecution lifecycle", () => {
|
||||
description: "Continuing after restart",
|
||||
})),
|
||||
)
|
||||
// Drains completed naturally, so claims are released and counters reset.
|
||||
expect(yield* claims(database)).toEqual({ [first]: false, [second]: false })
|
||||
expect(yield* attempts(database, first)).toBe(0)
|
||||
expect(yield* suspensions(database)).toEqual({ [first]: false, [second]: false })
|
||||
|
||||
yield* restart.resumeSuspendedSessions
|
||||
expect(drained.length).toBe(2)
|
||||
@@ -202,104 +159,17 @@ describe("SessionExecution lifecycle", () => {
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("terminalizes a turn that exhausts its resume budget instead of crash-looping", () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
const sessionID = Session.ID.make("ses_resume_exhausted")
|
||||
// A claim from a dead process, already resumed twice without completing.
|
||||
yield* seedSessions(database, [sessionID], { time_suspended: Date.now(), resume_attempts: 2 })
|
||||
|
||||
const drained: string[] = []
|
||||
const failures: SessionEvent.Execution.Failed[] = []
|
||||
const scope = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
|
||||
const context = yield* buildExecution(scope, ({ sessionID: id }) => Effect.sync(() => void drained.push(id)), {
|
||||
maxAttempts: 2,
|
||||
})
|
||||
const restart = Context.get(context, SessionRestart.Service)
|
||||
yield* bus.project(SessionEvent.Execution.Failed, (event) => Effect.sync(() => void failures.push(event)))
|
||||
|
||||
yield* restart.resumeSuspendedSessions
|
||||
expect(drained).toEqual([])
|
||||
expect(failures.map((event) => event.data.error.type)).toEqual(["aborted"])
|
||||
// The terminal released the claim and reset the counter atomically.
|
||||
expect(yield* claims(database)).toEqual({ [sessionID]: false })
|
||||
expect(yield* attempts(database, sessionID)).toBe(0)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("counts every resume durably and never consumes the claim it recovers", () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const sessionID = Session.ID.make("ses_resume_counted")
|
||||
yield* seedSessions(database, [sessionID], { time_suspended: Date.now() })
|
||||
|
||||
const draining = yield* Deferred.make<void>()
|
||||
const scope = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
|
||||
// The drain never terminalizes (mirrors a process that will die mid-turn).
|
||||
const context = yield* buildExecution(scope, () =>
|
||||
Deferred.succeed(draining, undefined).pipe(Effect.andThen(Effect.never)),
|
||||
)
|
||||
const restart = Context.get(context, SessionRestart.Service)
|
||||
yield* restart.resumeSuspendedSessions.pipe(Effect.forkIn(scope))
|
||||
yield* Deferred.await(draining)
|
||||
|
||||
// The attempt is durable before the drain runs, and the claim is held
|
||||
// throughout: a crash anywhere in the resume path leaves both intact.
|
||||
expect(yield* attempts(database, sessionID)).toBe(1)
|
||||
expect((yield* claims(database))[sessionID]).toBe(true)
|
||||
|
||||
// Teardown (a graceful shutdown's interrupt) preserves both, so the next
|
||||
// boot counts attempt 2 against the same turn.
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect((yield* claims(database))[sessionID]).toBe(true)
|
||||
expect(yield* attempts(database, sessionID)).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("the sweep leaves Sessions already draining in this process untouched", () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
const sessionID = Session.ID.make("ses_resume_local_active")
|
||||
yield* seedSessions(database, [sessionID])
|
||||
|
||||
const draining = yield* Deferred.make<void>()
|
||||
const continued: SessionEvent.Synthetic[] = []
|
||||
const scope = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
|
||||
const context = yield* buildExecution(scope, () =>
|
||||
Deferred.succeed(draining, undefined).pipe(Effect.andThen(Effect.never)),
|
||||
)
|
||||
const execution = Context.get(context, SessionExecution.Service)
|
||||
const restart = Context.get(context, SessionRestart.Service)
|
||||
yield* bus.project(SessionEvent.Synthetic, (event) => Effect.sync(() => void continued.push(event)))
|
||||
|
||||
// A live local turn holds a claim; the sweep must not count, continue, or terminalize it.
|
||||
yield* execution.resume(sessionID).pipe(Effect.forkScoped)
|
||||
yield* Deferred.await(draining)
|
||||
yield* restart.resumeSuspendedSessions
|
||||
|
||||
expect(continued).toEqual([])
|
||||
expect(yield* attempts(database, sessionID)).toBe(0)
|
||||
expect((yield* claims(database))[sessionID]).toBe(true)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
function seedSessions(
|
||||
database: Database.Service["Service"],
|
||||
sessionIDs: ReadonlyArray<Session.ID>,
|
||||
values: Partial<Pick<typeof SessionTable.$inferInsert, "time_suspended" | "resume_attempts" | "parent_id">> = {},
|
||||
values: { time_suspended?: number } = {},
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
yield* database.db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* database.db
|
||||
@@ -320,35 +190,19 @@ function seedSessions(
|
||||
})
|
||||
}
|
||||
|
||||
function claims(database: Database.Service["Service"]) {
|
||||
function suspensions(database: Database.Service["Service"]) {
|
||||
return database.db
|
||||
.select({ id: SessionTable.id, claimed: SessionTable.time_suspended })
|
||||
.select({ id: SessionTable.id, suspended: SessionTable.time_suspended })
|
||||
.from(SessionTable)
|
||||
.all()
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((rows) => Object.fromEntries(rows.map((row) => [row.id, row.claimed !== null]))),
|
||||
)
|
||||
}
|
||||
|
||||
function attempts(database: Database.Service["Service"], sessionID: Session.ID) {
|
||||
return database.db
|
||||
.select({ attempts: SessionTable.resume_attempts })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.get()
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((row) => row?.attempts),
|
||||
Effect.map((rows) => Object.fromEntries(rows.map((row) => [row.id, row.suspended !== null]))),
|
||||
)
|
||||
}
|
||||
|
||||
/** Builds the local execution layer plus the restart actions against the test harness services. */
|
||||
function buildExecution(
|
||||
scope: Scope.Closeable,
|
||||
drain: SessionRunner.Interface["drain"],
|
||||
options?: SessionRestart.Options,
|
||||
) {
|
||||
function buildExecution(scope: Scope.Closeable, drain: SessionRunner.Interface["drain"]) {
|
||||
return Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
@@ -364,7 +218,7 @@ function buildExecution(
|
||||
),
|
||||
)
|
||||
return yield* Layer.buildWithScope(
|
||||
SessionRestart.layer(options).pipe(
|
||||
SessionRestart.layer.pipe(
|
||||
Layer.provideMerge(SessionExecution.layer),
|
||||
Layer.provide(Layer.succeed(Database.Service, database)),
|
||||
Layer.provide(Layer.succeed(Bus.Service, bus)),
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Database } from "bun:sqlite"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { SqlClient } from "effect/unstable/sql"
|
||||
import { SqlError } from "effect/unstable/sql/SqlError"
|
||||
import { sqliteLayer } from "@opencode-ai/core/database/sqlite.workerd"
|
||||
import type { DurableObjectStorage } from "@opencode-ai/core/database/sqlite.workerd"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
|
||||
// Emulates the Durable Object storage API over bun:sqlite so the adapter can
|
||||
// be verified without workerd or Cloudflare runtime dependencies.
|
||||
const makeFakeStorage = () => {
|
||||
const native = new Database(":memory:")
|
||||
const toSqlStorageValue = (value: unknown) => {
|
||||
if (!(value instanceof Uint8Array)) return value as ArrayBuffer | string | number | null
|
||||
const buffer = new ArrayBuffer(value.byteLength)
|
||||
new Uint8Array(buffer).set(value)
|
||||
return buffer
|
||||
}
|
||||
const storage: DurableObjectStorage = {
|
||||
sql: {
|
||||
exec(query: string, ...bindings: Array<unknown>) {
|
||||
const statement = native.query(query)
|
||||
const rows = (statement.values(...(bindings as never[])) ?? []).map((row) => row.map(toSqlStorageValue))
|
||||
const columnNames = statement.columnNames
|
||||
return {
|
||||
columnNames,
|
||||
raw: () => rows[Symbol.iterator](),
|
||||
toArray: () => rows.map((row) => Object.fromEntries(columnNames.map((name, i) => [name, row[i]]))),
|
||||
}
|
||||
},
|
||||
},
|
||||
transaction<T>(closure: (txn: { rollback(): void }) => Promise<T>): Promise<T> {
|
||||
native.run("BEGIN")
|
||||
let rolledBack = false
|
||||
return closure({ rollback: () => (rolledBack = true) }).then(
|
||||
(result) => {
|
||||
native.run(rolledBack ? "ROLLBACK" : "COMMIT")
|
||||
return result
|
||||
},
|
||||
(error) => {
|
||||
native.run("ROLLBACK")
|
||||
throw error
|
||||
},
|
||||
)
|
||||
},
|
||||
transactionSync<T>(closure: () => T): T {
|
||||
return native.transaction(closure)()
|
||||
},
|
||||
}
|
||||
return storage
|
||||
}
|
||||
|
||||
const run = <A, E>(storage: DurableObjectStorage, effect: Effect.Effect<A, E, SqlClient.SqlClient>) =>
|
||||
Effect.runPromise(effect.pipe(Effect.provide(sqliteLayer({ storage })), Effect.scoped))
|
||||
|
||||
describe("sqlite.workerd", () => {
|
||||
test("executes statements with bindings and maps rows to records", async () => {
|
||||
const rows = await run(
|
||||
makeFakeStorage(),
|
||||
Effect.gen(function* () {
|
||||
const sql = yield* SqlClient.SqlClient
|
||||
yield* sql`CREATE TABLE item (id INTEGER PRIMARY KEY, name TEXT NOT NULL)`
|
||||
yield* sql`INSERT INTO item (id, name) VALUES (${1}, ${"one"}), (${2}, ${"two"})`
|
||||
return yield* sql<{ id: number; name: string }>`SELECT id, name FROM item ORDER BY id`
|
||||
}),
|
||||
)
|
||||
expect(rows).toEqual([
|
||||
{ id: 1, name: "one" },
|
||||
{ id: 2, name: "two" },
|
||||
])
|
||||
})
|
||||
|
||||
test("normalizes ArrayBuffer blob values to Uint8Array", async () => {
|
||||
const rows = await run(
|
||||
makeFakeStorage(),
|
||||
Effect.gen(function* () {
|
||||
const sql = yield* SqlClient.SqlClient
|
||||
yield* sql`CREATE TABLE blob (data BLOB NOT NULL)`
|
||||
yield* sql`INSERT INTO blob (data) VALUES (${new Uint8Array([1, 2, 3])})`
|
||||
return yield* sql<{ data: Uint8Array }>`SELECT data FROM blob`
|
||||
}),
|
||||
)
|
||||
expect(rows[0].data).toBeInstanceOf(Uint8Array)
|
||||
expect(Array.from(rows[0].data)).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
test("withTransaction commits on success and rolls back on failure", async () => {
|
||||
const storage = makeFakeStorage()
|
||||
const count = await run(
|
||||
storage,
|
||||
Effect.gen(function* () {
|
||||
const sql = yield* SqlClient.SqlClient
|
||||
yield* sql`CREATE TABLE t (value TEXT NOT NULL)`
|
||||
yield* sql.withTransaction(sql`INSERT INTO t (value) VALUES (${"kept"})`)
|
||||
yield* sql
|
||||
.withTransaction(
|
||||
Effect.gen(function* () {
|
||||
yield* sql`INSERT INTO t (value) VALUES (${"discarded"})`
|
||||
return yield* Effect.fail("rollback")
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.ignore)
|
||||
return yield* sql<{ count: number }>`SELECT count(*) AS count FROM t`
|
||||
}),
|
||||
)
|
||||
expect(count[0].count).toBe(1)
|
||||
})
|
||||
|
||||
test("nested withTransaction fails with SqlError", async () => {
|
||||
const error = await run(
|
||||
makeFakeStorage(),
|
||||
Effect.gen(function* () {
|
||||
const sql = yield* SqlClient.SqlClient
|
||||
yield* sql`CREATE TABLE t (value TEXT NOT NULL)`
|
||||
return yield* sql
|
||||
.withTransaction(sql.withTransaction(sql`INSERT INTO t (value) VALUES (${"nested"})`))
|
||||
.pipe(Effect.flip)
|
||||
}),
|
||||
)
|
||||
expect(error).toBeInstanceOf(SqlError)
|
||||
})
|
||||
|
||||
test("boots the full database layer with migrations over injected storage", async () => {
|
||||
const storage = makeFakeStorage()
|
||||
const core = await import("@opencode-ai/core/database/database")
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Layer.build(
|
||||
core.Database.layerFromClient.pipe(Layer.provide(sqliteLayer({ storage })), Layer.provide(tempGlobalLayer)),
|
||||
),
|
||||
),
|
||||
)
|
||||
const names = storage.sql
|
||||
.exec("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name")
|
||||
.toArray()
|
||||
.map((row) => row.name)
|
||||
expect(names).toContain("migration")
|
||||
expect(names).toContain("session_v2")
|
||||
})
|
||||
})
|
||||
@@ -89,30 +89,6 @@ const it = testEffect(
|
||||
)
|
||||
|
||||
describe("QuestionTool", () => {
|
||||
it.effect("emits one item schema for the nonempty questions array", () =>
|
||||
Effect.gen(function* () {
|
||||
captured = undefined
|
||||
const registry = yield* Tool.Service
|
||||
const definition = (yield* toolDefinitions(registry)).find((tool) => tool.name === QuestionTool.name)
|
||||
|
||||
expect(definition?.inputSchema).toHaveProperty("properties.questions.type", "array")
|
||||
expect(definition?.inputSchema).toHaveProperty("properties.questions.minItems", 1)
|
||||
expect(definition?.inputSchema).toHaveProperty("properties.questions.items")
|
||||
expect(definition?.inputSchema).not.toHaveProperty("properties.questions.prefixItems")
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-question-empty", name: QuestionTool.name, input: { questions: [] } },
|
||||
}),
|
||||
).toMatchObject({
|
||||
status: "error",
|
||||
error: { type: "tool.execution", message: expect.stringContaining("Invalid tool input") },
|
||||
})
|
||||
expect(capturedInput()).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits a catalog-denied question and enforces its leaf permission", () =>
|
||||
Effect.gen(function* () {
|
||||
captured = undefined
|
||||
|
||||
@@ -3,20 +3,17 @@ import { realpathSync } from "node:fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Duration, Effect, Fiber, Layer, Scope, Stream } from "effect"
|
||||
import { DateTime, Deferred, Duration, Effect, Fiber, Layer, Scope, Stream } from "effect"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { filesystem } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Environment } from "@opencode-ai/core/environment"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
@@ -30,7 +27,6 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { Shell } from "@opencode-ai/core/shell"
|
||||
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
|
||||
import { ShellTool } from "@opencode-ai/core/tool/plugin/shell"
|
||||
@@ -39,7 +35,7 @@ import { Tool } from "@opencode-ai/core/tool"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
|
||||
import { toolIdentity, executeTool, toolDefinitions, waitForTool } from "./lib/tool"
|
||||
|
||||
const sessionID = Session.ID.make("ses_shell_tool_test")
|
||||
const sessionModel = Model.Ref.make({ id: Model.ID.make("test"), providerID: Provider.ID.make("test") })
|
||||
@@ -127,42 +123,27 @@ const executionNode = makeGlobalNode({
|
||||
deps: [Bus.node, SessionStore.node],
|
||||
})
|
||||
|
||||
const shellPluginSupervisor = makeLocationNode({
|
||||
service: PluginSupervisor.Service,
|
||||
layer: Layer.effect(
|
||||
PluginSupervisor.Service,
|
||||
registerToolPlugin(ShellTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ flush: Effect.void }))),
|
||||
),
|
||||
deps: [
|
||||
Config.node,
|
||||
Environment.node,
|
||||
LocationMutation.node,
|
||||
Permission.node,
|
||||
PluginRuntime.node,
|
||||
Shell.node,
|
||||
Tool.node,
|
||||
const layer = AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
Database.node,
|
||||
Bus.node,
|
||||
Job.node,
|
||||
Session.node,
|
||||
SessionExecution.node,
|
||||
PluginRuntime.providerNode,
|
||||
LocationServiceMap.node,
|
||||
filesystem,
|
||||
FSUtil.node,
|
||||
Global.node,
|
||||
]),
|
||||
[
|
||||
[SessionExecution.node, executionNode],
|
||||
[Permission.node, permission],
|
||||
[Global.node, tempGlobalLayer],
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
const nodes = LayerNode.group([
|
||||
Database.node,
|
||||
Bus.node,
|
||||
Job.node,
|
||||
Session.node,
|
||||
SessionExecution.node,
|
||||
PluginRuntime.providerNode,
|
||||
LocationServiceMap.node,
|
||||
filesystem,
|
||||
FSUtil.node,
|
||||
Global.node,
|
||||
])
|
||||
const replacements = [
|
||||
[SessionExecution.node, executionNode],
|
||||
[Permission.node, permission],
|
||||
[Global.node, tempGlobalLayer],
|
||||
] satisfies LayerNode.Replacements
|
||||
const productionIt = testEffect(AppNodeBuilder.build(nodes, replacements))
|
||||
const it = testEffect(AppNodeBuilder.build(nodes, [...replacements, [PluginSupervisor.node, shellPluginSupervisor]]))
|
||||
const it = testEffect(layer)
|
||||
|
||||
const call = (input: typeof ShellTool.Input.Type, id = "call-shell") => ({
|
||||
sessionID,
|
||||
@@ -183,6 +164,9 @@ const idleCommand = isWindows ? "Start-Sleep -Seconds 60" : "sleep 60"
|
||||
const timeoutOutputCommand = isWindows
|
||||
? "[Console]::Out.Write('before timeout'); Start-Sleep -Seconds 60"
|
||||
: "printf 'before timeout'; sleep 60"
|
||||
const steadyProgressCommand = isWindows
|
||||
? "[Console]::Out.Write('steady'); Start-Sleep -Milliseconds 3400"
|
||||
: "printf steady; sleep 3.4"
|
||||
const bodyExitCommand = isWindows
|
||||
? "[Console]::Out.Write('body'); Start-Sleep -Milliseconds 100; exit 7"
|
||||
: "printf body && exit 7"
|
||||
@@ -211,56 +195,49 @@ const withSession = <A, E, R>(directory: string, body: (registry: Tool.Interface
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const locationLayer = locations.get(location)
|
||||
return yield* Effect.gen(function* () {
|
||||
yield* (yield* PluginSupervisor.Service).flush
|
||||
const registry = yield* Tool.Service
|
||||
yield* waitForTool(registry, ShellTool.name)
|
||||
return yield* body(registry)
|
||||
}).pipe(Effect.provide(locationLayer), Effect.ensuring(locations.invalidate(location)))
|
||||
})
|
||||
|
||||
describe("ShellTool", () => {
|
||||
productionIt.live(
|
||||
"registers and returns real successful output from the active Location",
|
||||
() =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
return withSession(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
const definitions = yield* toolDefinitions(registry)
|
||||
const definition = definitions.find((tool) => tool.name === "shell")
|
||||
expect(definition?.description).toStartWith("Execute a shell command and return its output.")
|
||||
expect(definition?.inputSchema).not.toHaveProperty("properties.timeout.maximum")
|
||||
// Code Mode receives the declared output schema, including the command output text.
|
||||
expect(definition?.outputSchema).toHaveProperty("properties.output")
|
||||
expect(
|
||||
(yield* toolDefinitions(registry, [{ action: "shell", resource: "*", effect: "deny" }])).map(
|
||||
(tool) => tool.name,
|
||||
),
|
||||
).not.toContain("shell")
|
||||
it.live("registers and returns real successful output from the active Location", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
return withSession(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
const definitions = yield* toolDefinitions(registry)
|
||||
const definition = definitions.find((tool) => tool.name === "shell")
|
||||
expect(definition?.description).toStartWith("Execute a shell command and return its output.")
|
||||
expect(definition?.inputSchema).not.toHaveProperty("properties.timeout.maximum")
|
||||
// Code Mode receives the declared output schema, including the command output text.
|
||||
expect(definition?.outputSchema).toHaveProperty("properties.output")
|
||||
expect(
|
||||
(yield* toolDefinitions(registry, [{ action: "shell", resource: "*", effect: "deny" }])).map(
|
||||
(tool) => tool.name,
|
||||
),
|
||||
).not.toContain("shell")
|
||||
|
||||
const settled = yield* executeTool(registry, call({ command: helloCommand }))
|
||||
expect(settled.status).toBe("completed")
|
||||
expect(settled.metadata).toMatchObject({ exit: 0, truncated: false })
|
||||
expect(settled.content?.[0]).toEqual({ type: "text", text: "hello" })
|
||||
expect(settled.content?.[1]).toMatchObject({
|
||||
type: "text",
|
||||
text: expect.stringContaining("Command exited with code 0."),
|
||||
})
|
||||
expect(assertions).toMatchObject([
|
||||
{
|
||||
sessionID,
|
||||
action: "shell",
|
||||
resources: [isWindows ? "Start-Sleep -Milliseconds 100" : helloCommand],
|
||||
},
|
||||
])
|
||||
expect(assertions[0]?.save).toEqual([isWindows ? "Start-Sleep *" : "printf *"])
|
||||
}),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||
),
|
||||
{ timeout: 15_000 },
|
||||
const settled = yield* executeTool(registry, call({ command: helloCommand }))
|
||||
expect(settled.status).toBe("completed")
|
||||
expect(settled.metadata).toMatchObject({ exit: 0, truncated: false })
|
||||
expect(settled.content?.[0]).toEqual({ type: "text", text: "hello" })
|
||||
expect(settled.content?.[1]).toMatchObject({
|
||||
type: "text",
|
||||
text: expect.stringContaining("Command exited with code 0."),
|
||||
})
|
||||
expect(assertions).toMatchObject([
|
||||
{ sessionID, action: "shell", resources: [isWindows ? "Start-Sleep -Milliseconds 100" : helloCommand] },
|
||||
])
|
||||
expect(assertions[0]?.save).toEqual([isWindows ? "Start-Sleep *" : "printf *"])
|
||||
}),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("resolves a relative workdir from the active Location", () =>
|
||||
@@ -599,7 +576,7 @@ describe("ShellTool", () => {
|
||||
)
|
||||
|
||||
it.live(
|
||||
"reports shell ID progress once",
|
||||
"does not repeat shell ID progress",
|
||||
() =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
@@ -609,7 +586,7 @@ describe("ShellTool", () => {
|
||||
Effect.gen(function* () {
|
||||
const updates: Tool.Metadata[] = []
|
||||
yield* executeTool(registry, {
|
||||
...call({ command: helloCommand }, "call-shell-id-progress"),
|
||||
...call({ command: steadyProgressCommand }, "call-steady-progress"),
|
||||
progress: (update) => Effect.sync(() => updates.push(update)),
|
||||
})
|
||||
expect(updates).toHaveLength(1)
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
@@ -25,13 +24,12 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { SubagentTool } from "@opencode-ai/core/tool/plugin/subagent"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
|
||||
import { executeTool, toolIdentity, waitForTool } from "./lib/tool"
|
||||
|
||||
const childText = "child final response"
|
||||
const childModel = Model.Ref.make({ id: Model.ID.make("child"), providerID: Provider.ID.make("test") })
|
||||
@@ -94,30 +92,23 @@ const executionNode = makeGlobalNode({
|
||||
deps: [Bus.node, SessionStore.node],
|
||||
})
|
||||
|
||||
const subagentPluginSupervisor = makeLocationNode({
|
||||
service: PluginSupervisor.Service,
|
||||
layer: Layer.effect(
|
||||
PluginSupervisor.Service,
|
||||
registerToolPlugin(SubagentTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ flush: Effect.void }))),
|
||||
),
|
||||
deps: [Agent.node, Config.node, Permission.node, PluginRuntime.node, Tool.node],
|
||||
})
|
||||
const layer = AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
Database.node,
|
||||
Bus.node,
|
||||
Job.node,
|
||||
Session.node,
|
||||
SessionExecution.node,
|
||||
PluginRuntime.providerNode,
|
||||
LocationServiceMap.node,
|
||||
]),
|
||||
[
|
||||
[SessionExecution.node, executionNode],
|
||||
[Global.node, tempGlobalLayer],
|
||||
],
|
||||
)
|
||||
|
||||
const nodes = LayerNode.group([
|
||||
Database.node,
|
||||
Bus.node,
|
||||
Job.node,
|
||||
Session.node,
|
||||
SessionExecution.node,
|
||||
PluginRuntime.providerNode,
|
||||
LocationServiceMap.node,
|
||||
])
|
||||
const replacements = [
|
||||
[SessionExecution.node, executionNode],
|
||||
[Global.node, tempGlobalLayer],
|
||||
] satisfies LayerNode.Replacements
|
||||
const productionIt = testEffect(AppNodeBuilder.build(nodes, replacements))
|
||||
const it = testEffect(AppNodeBuilder.build(nodes, [...replacements, [PluginSupervisor.node, subagentPluginSupervisor]]))
|
||||
const it = testEffect(layer)
|
||||
|
||||
const withSubagent = (location: Location.Ref) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -145,7 +136,7 @@ const withSubagent = (location: Location.Ref) =>
|
||||
})
|
||||
|
||||
describe("SubagentTool", () => {
|
||||
productionIt.live("registers globally while resolving agents from the caller location", () =>
|
||||
it.live("registers globally while resolving agents from the caller location", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
@@ -159,6 +150,7 @@ describe("SubagentTool", () => {
|
||||
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
yield* waitForTool(registry, SubagentTool.name)
|
||||
expect((yield* registry.snapshot()).definitions.map((tool) => tool.name)).toContain(SubagentTool.name)
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
@@ -194,6 +186,7 @@ describe("SubagentTool", () => {
|
||||
yield* withSubagent(parent.location)
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
yield* waitForTool(registry, SubagentTool.name)
|
||||
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
@@ -236,6 +229,7 @@ describe("SubagentTool", () => {
|
||||
yield* withSubagent(parent.location)
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
yield* waitForTool(registry, SubagentTool.name)
|
||||
|
||||
const settled = yield* executeTool(registry, {
|
||||
sessionID: parent.id,
|
||||
@@ -276,6 +270,7 @@ describe("SubagentTool", () => {
|
||||
yield* withSubagent(parent.location)
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
yield* waitForTool(registry, SubagentTool.name)
|
||||
const progress: Tool.Metadata[] = []
|
||||
|
||||
const settled = yield* executeTool(registry, {
|
||||
@@ -338,6 +333,7 @@ describe("SubagentTool", () => {
|
||||
yield* withSubagent(parent.location)
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
yield* waitForTool(registry, SubagentTool.name)
|
||||
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
@@ -375,6 +371,7 @@ describe("SubagentTool", () => {
|
||||
yield* withSubagent(parent.location)
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
|
||||
yield* waitForTool(registry, SubagentTool.name)
|
||||
const bus = yield* Bus.Service
|
||||
const admitted = yield* bus.subscribe(SessionEvent.InputAdmitted).pipe(
|
||||
Stream.filter((event) => event.data.sessionID === parent.id && event.data.input.type === "synthetic"),
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Effect, Fiber, Layer, Logger, Schedule, Schema, Scope } from "effect"
|
||||
import { Effect, Layer, Logger, Schedule, Schema, Scope } from "effect"
|
||||
import { eq, sql } from "drizzle-orm"
|
||||
import type { SqlClient } from "effect/unstable/sql/SqlClient"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
@@ -63,7 +63,6 @@ const session = (
|
||||
time_compacting: 3,
|
||||
time_archived: null,
|
||||
time_suspended: null,
|
||||
resume_attempts: 0,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
@@ -799,35 +798,6 @@ describe("V1Migration database workflow", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("yields while clearing stale events in batches", async () => {
|
||||
await database(
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
yield* db.run(sql`INSERT INTO event_sequence (aggregate_id, seq) VALUES ('stale', 2500)`)
|
||||
yield* db.run(sql`
|
||||
WITH RECURSIVE rows(value) AS (
|
||||
VALUES(1)
|
||||
UNION ALL
|
||||
SELECT value + 1 FROM rows WHERE value < 2500
|
||||
)
|
||||
INSERT INTO event (id, aggregate_id, seq, created, type, data)
|
||||
SELECT printf('event_%04d', value), 'stale', value, 1, 'session.renamed.1', '{}'
|
||||
FROM rows
|
||||
`)
|
||||
let yielded = false
|
||||
const heartbeat = yield* Effect.yieldNow.pipe(
|
||||
Effect.andThen(Effect.sync(() => (yielded = true))),
|
||||
Effect.forkChild({ startImmediately: true }),
|
||||
)
|
||||
|
||||
expect(yield* V1Migration.run()).toEqual({ status: "completed" })
|
||||
expect(yielded).toBe(true)
|
||||
yield* Fiber.join(heartbeat)
|
||||
expect(yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM event`)).toEqual({ value: 0 })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("imports previous V2 sessions and messages as part of the migration", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filename = path.join(tmp.path, "opencode-next.db")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
TextRenderable,
|
||||
BoxRenderable,
|
||||
RenderableEvents,
|
||||
createMarkdownCodeBlockRenderer,
|
||||
parseColor,
|
||||
@@ -33,6 +34,7 @@ interface PreparedDiagram {
|
||||
readonly source: string
|
||||
readonly text: StyledText
|
||||
readonly height: number
|
||||
readonly width: number
|
||||
}
|
||||
|
||||
export interface MermaidMarkdownRendererOptions {
|
||||
@@ -55,16 +57,23 @@ function color(value: ColorInput | undefined): RGBA | undefined {
|
||||
return value === undefined ? undefined : parseColor(value)
|
||||
}
|
||||
|
||||
class StaticDiagramRenderable extends TextRenderable {
|
||||
class StaticDiagramRenderable extends BoxRenderable {
|
||||
constructor(ctx: RenderContext, prepared: PreparedDiagram) {
|
||||
super(ctx, {
|
||||
content: prepared.text,
|
||||
width: "100%",
|
||||
alignItems: "flex-start",
|
||||
flexShrink: 0,
|
||||
marginTop: 1,
|
||||
})
|
||||
const diagram = new TextRenderable(ctx, {
|
||||
content: prepared.text,
|
||||
width: prepared.width,
|
||||
maxWidth: "100%",
|
||||
height: prepared.height,
|
||||
wrapMode: "none",
|
||||
selectable: false,
|
||||
marginTop: 1,
|
||||
})
|
||||
this.add(diagram)
|
||||
let dragX: number | undefined
|
||||
this.onMouseDown = (event: MouseEvent) => {
|
||||
if (event.button !== 0) return
|
||||
@@ -79,7 +88,7 @@ class StaticDiagramRenderable extends TextRenderable {
|
||||
if (dragX === undefined) return
|
||||
const dx = event.x - dragX
|
||||
dragX = event.x
|
||||
if (dx) this.scrollX -= dx
|
||||
if (dx) diagram.scrollX -= dx
|
||||
}
|
||||
this.onMouseDragEnd = (event: MouseEvent) => {
|
||||
dragX = undefined
|
||||
@@ -121,6 +130,7 @@ function prepareDiagram(kind: DiagramKind, source: string, options: MermaidMarkd
|
||||
}),
|
||||
),
|
||||
height: size.height,
|
||||
width: size.width,
|
||||
}
|
||||
}
|
||||
case "sequence": {
|
||||
@@ -144,6 +154,7 @@ function prepareDiagram(kind: DiagramKind, source: string, options: MermaidMarkd
|
||||
}),
|
||||
),
|
||||
height: size.height,
|
||||
width: size.width,
|
||||
}
|
||||
}
|
||||
case "state": {
|
||||
@@ -168,6 +179,7 @@ function prepareDiagram(kind: DiagramKind, source: string, options: MermaidMarkd
|
||||
}),
|
||||
),
|
||||
height: size.height,
|
||||
width: size.width,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { CodeRenderable, MarkdownRenderable, RGBA, SyntaxStyle, TreeSitterClient } from "@opentui/core"
|
||||
import { CodeRenderable, MarkdownRenderable, RGBA, SyntaxStyle, TextRenderable, TreeSitterClient } from "@opentui/core"
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
import { createMermaidMarkdownRenderer } from "../markdown.js"
|
||||
|
||||
@@ -78,6 +78,31 @@ flowchart LR
|
||||
expect(markdown.getChildren()[0]?.marginTop).toBe(1)
|
||||
})
|
||||
|
||||
test("leaves Mermaid alignment to its containing layout", async () => {
|
||||
const testRenderer = await createTestRenderer({ width: 80, height: 14 })
|
||||
renderer = testRenderer.renderer
|
||||
const markdown = new MarkdownRenderable(renderer, {
|
||||
id: "markdown-centered-mermaid",
|
||||
content: `\`\`\`mermaid
|
||||
flowchart LR
|
||||
A[Start] --> B[Done]
|
||||
\`\`\``,
|
||||
syntaxStyle,
|
||||
treeSitterClient,
|
||||
renderNode: createMermaidMarkdownRenderer(renderer),
|
||||
})
|
||||
|
||||
renderer.root.add(markdown)
|
||||
await renderMarkdown(markdown, testRenderer.renderOnce)
|
||||
|
||||
const line = testRenderer
|
||||
.captureCharFrame()
|
||||
.split("\n")
|
||||
.find((value) => value.includes("Start"))
|
||||
if (!line) throw new Error("Expected the rendered diagram")
|
||||
expect(line.indexOf("Start")).toBeLessThan(10)
|
||||
})
|
||||
|
||||
test("recognizes normalized Mermaid fence info strings", async () => {
|
||||
const testRenderer = await createTestRenderer({ width: 80, height: 14 })
|
||||
renderer = testRenderer.renderer
|
||||
@@ -235,17 +260,19 @@ sequenceDiagram
|
||||
renderer.root.add(markdown)
|
||||
await renderMarkdown(markdown, testRenderer.renderOnce)
|
||||
|
||||
const diagram = markdown.getChildren()[0] as CodeRenderable
|
||||
const wrapper = markdown.getChildren()[0]
|
||||
if (!wrapper) throw new Error("Expected the rendered diagram wrapper")
|
||||
const diagram = wrapper.getChildren()[0] as TextRenderable
|
||||
expect(diagram.scrollWidth).toBeGreaterThan(diagram.width)
|
||||
expect(diagram.scrollX).toBe(0)
|
||||
|
||||
await testRenderer.mockMouse.drag(diagram.x + 20, diagram.y + 2, diagram.x + 5, diagram.y + 2)
|
||||
await testRenderer.mockMouse.drag(wrapper.x + 20, wrapper.y + 2, wrapper.x + 5, wrapper.y + 2)
|
||||
await testRenderer.renderOnce()
|
||||
expect(diagram.scrollX).toBeGreaterThan(0)
|
||||
expect(diagram.hasSelection()).toBe(false)
|
||||
|
||||
diagram.scrollX = 0
|
||||
await testRenderer.mockMouse.scroll(diagram.x + 20, diagram.y + 2, "right")
|
||||
await testRenderer.mockMouse.scroll(wrapper.x + 20, wrapper.y + 2, "right")
|
||||
await testRenderer.renderOnce()
|
||||
expect(diagram.scrollX).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
@@ -14373,9 +14373,6 @@
|
||||
},
|
||||
"agent": {
|
||||
"type": "string"
|
||||
},
|
||||
"previous": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["sessionID", "agent"],
|
||||
@@ -14444,9 +14441,6 @@
|
||||
},
|
||||
"model": {
|
||||
"$ref": "#/components/schemas/Model.Ref"
|
||||
},
|
||||
"previous": {
|
||||
"$ref": "#/components/schemas/Model.Ref"
|
||||
}
|
||||
},
|
||||
"required": ["sessionID", "model"],
|
||||
|
||||
@@ -69,7 +69,6 @@ export const AgentSelected = Event.durable({
|
||||
schema: {
|
||||
...Base,
|
||||
agent: Agent.ID,
|
||||
previous: Agent.ID.pipe(optional),
|
||||
},
|
||||
})
|
||||
export type AgentSelected = typeof AgentSelected.Type
|
||||
@@ -80,7 +79,6 @@ export const ModelSelected = Event.durable({
|
||||
schema: {
|
||||
...Base,
|
||||
model: Model.Ref,
|
||||
previous: Model.Ref.pipe(optional),
|
||||
},
|
||||
})
|
||||
export type ModelSelected = typeof ModelSelected.Type
|
||||
|
||||
@@ -13,19 +13,6 @@ const session = yield * opencode.sessions.get({ sessionID })
|
||||
|
||||
It also exports `Tool` for plugins that add tools with `ctx.tool.transform(...)`. Embedded plugins run through the ordinary discovery flow and register tools into each Location's `ToolRegistry` through the normal `Tools.Service.register(...)` path. Closing the owning Effect Scope releases router resources, location services, fibers, and scoped tool registrations.
|
||||
|
||||
Embedded hosts are silent by default. Set `log` to receive structured log entries at the selected minimum level:
|
||||
|
||||
```ts
|
||||
const opencode =
|
||||
yield *
|
||||
OpenCode.create({
|
||||
log: {
|
||||
level: "warn",
|
||||
emit: (entry) => console.error(entry.message, entry.attributes, entry.cause),
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
`sessions.events({ sessionID, after })` replays durable events after the optional aggregate sequence, then emits newly committed durable events. `sessions.interrupt(...)` targets execution owned by this host, and `sessions.message(...)` retrieves one projected Session message.
|
||||
|
||||
The same constructor is available as a service Layer:
|
||||
@@ -39,4 +26,4 @@ const program = Effect.gen(function* () {
|
||||
yield * program.pipe(Effect.provide(OpenCode.layer))
|
||||
```
|
||||
|
||||
`OpenCode.layer` adapts the silent default `OpenCode.create()` for dependency injection; use `OpenCode.layerWith(options)` to configure the host.
|
||||
`OpenCode.layer` adapts `OpenCode.create()` for dependency injection; it does not define another host implementation.
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { Context, Formatter, Layer, Logger, References } from "effect"
|
||||
|
||||
export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal"
|
||||
|
||||
export type LogEntry = {
|
||||
readonly level: LogLevel
|
||||
readonly message: string
|
||||
readonly attributes?: Readonly<Record<string, unknown>>
|
||||
readonly cause?: unknown
|
||||
}
|
||||
|
||||
export type LogWriter = (entry: LogEntry) => void
|
||||
|
||||
export type LogOptions = {
|
||||
readonly level?: LogLevel
|
||||
readonly emit: LogWriter
|
||||
}
|
||||
|
||||
const levels: Record<LogLevel, Logger.Options<unknown>["logLevel"]> = {
|
||||
trace: "Trace",
|
||||
debug: "Debug",
|
||||
info: "Info",
|
||||
warn: "Warn",
|
||||
error: "Error",
|
||||
fatal: "Fatal",
|
||||
}
|
||||
|
||||
function normalizeLevel(level: Logger.Options<unknown>["logLevel"]): LogLevel | undefined {
|
||||
const output = Object.fromEntries(Object.entries(levels).map(([name, effect]) => [effect, name]))
|
||||
return output[level] as LogLevel
|
||||
}
|
||||
|
||||
export function layer(log?: LogOptions) {
|
||||
const logger = Logger.make((options) => {
|
||||
if (!log) return
|
||||
const level = normalizeLevel(options.logLevel)
|
||||
if (!level) return
|
||||
const entry = Logger.formatStructured.log(options)
|
||||
const values = Array.isArray(entry.message) ? entry.message : [entry.message]
|
||||
const [message, ...data] = values
|
||||
const details =
|
||||
data.length === 1 && !Array.isArray(data[0]) ? (data[0] as Readonly<Record<string, unknown>>) : undefined
|
||||
const { cause: detailCause, ...detailAttributes } = details ?? {}
|
||||
const attributes = {
|
||||
...entry.annotations,
|
||||
...detailAttributes,
|
||||
...(Object.keys(entry.spans).length > 0 ? { spans: entry.spans } : {}),
|
||||
...(!details && data.length > 0 ? { data: data.length === 1 ? data[0] : data } : {}),
|
||||
}
|
||||
try {
|
||||
log.emit({
|
||||
level,
|
||||
message: typeof message === "string" ? message : Formatter.format(message),
|
||||
...(Object.keys(attributes).length > 0 ? { attributes } : {}),
|
||||
...(entry.cause === undefined && detailCause === undefined ? {} : { cause: entry.cause ?? detailCause }),
|
||||
})
|
||||
} catch {
|
||||
// A host logger must not break OpenCode operations.
|
||||
}
|
||||
})
|
||||
return Layer.merge(
|
||||
Logger.layer([logger], { mergeWithExisting: false }),
|
||||
Layer.succeed(References.MinimumLogLevel, levels[log?.level ?? "info"]),
|
||||
)
|
||||
}
|
||||
|
||||
export function context(source: Context.Context<never>) {
|
||||
return Context.make(Logger.CurrentLoggers, Context.get(source, Logger.CurrentLoggers)).pipe(
|
||||
Context.add(References.MinimumLogLevel, Context.get(source, References.MinimumLogLevel)),
|
||||
)
|
||||
}
|
||||
@@ -2,27 +2,18 @@ import { OpenCode } from "@opencode-ai/client/effect"
|
||||
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||
import { createEmbeddedRoutes } from "@opencode-ai/server/routes"
|
||||
import type { ServerOptions } from "@opencode-ai/server/options"
|
||||
import { Context, Effect, Layer, ManagedRuntime, Scope } from "effect"
|
||||
import { FetchHttpClient, HttpEffect, HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http"
|
||||
import * as Logging from "./logging"
|
||||
import { Context, Effect, Layer, ManagedRuntime } from "effect"
|
||||
import { FetchHttpClient, HttpEffect, HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
|
||||
export type { LogEntry, LogLevel, LogOptions, LogWriter } from "./logging"
|
||||
import type { LogOptions } from "./logging"
|
||||
|
||||
export type CreateOptions = ServerOptions & {
|
||||
readonly log?: LogOptions
|
||||
}
|
||||
|
||||
export const create = Effect.fn("OpenCode.create")(function* (options: CreateOptions = {}) {
|
||||
const { log, ...server } = options
|
||||
export const create = Effect.fn("OpenCode.create")(function* (options: ServerOptions = {}) {
|
||||
const runtime = yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
ManagedRuntime.make(
|
||||
createEmbeddedRoutes({
|
||||
...server,
|
||||
app: { ...server.app, name: server.app?.name ?? "sdk" },
|
||||
database: { path: ":memory:", ...server.database },
|
||||
}).pipe(Layer.provide(HttpServer.layerServices), Layer.provideMerge(Logging.layer(log))),
|
||||
...options,
|
||||
app: { ...options.app, name: options.app?.name ?? "sdk" },
|
||||
database: { path: ":memory:", ...options.database },
|
||||
}).pipe(Layer.provide(HttpServer.layerServices)),
|
||||
),
|
||||
),
|
||||
(runtime) => runtime.disposeEffect,
|
||||
@@ -30,9 +21,7 @@ export const create = Effect.fn("OpenCode.create")(function* (options: CreateOpt
|
||||
const context = yield* runtime.contextEffect
|
||||
const plugins = Context.get(context, SdkPlugins.Service)
|
||||
const router = Context.get(context, HttpRouter.HttpRouter)
|
||||
const handler = HttpEffect.toWebHandlerWith<never, HttpServerRequest.HttpServerRequest | Scope.Scope>(
|
||||
Logging.context(context),
|
||||
)(router.asHttpEffect())
|
||||
const handler = HttpEffect.toWebHandler(router.asHttpEffect())
|
||||
const fetch = Object.assign((input: RequestInfo | URL, init?: RequestInit) => handler(new Request(input, init)), {
|
||||
preconnect: () => undefined,
|
||||
}) satisfies typeof globalThis.fetch
|
||||
|
||||
@@ -12,19 +12,6 @@ export const ModelHandler = HttpApiBuilder.group(Api, "server.model", (handlers)
|
||||
.handle(
|
||||
"model.list",
|
||||
Effect.fn(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.flush.pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: "5 seconds",
|
||||
orElse: () =>
|
||||
Effect.fail(
|
||||
new ServiceUnavailableError({
|
||||
message: "Model catalog initialization timed out",
|
||||
service: "model.catalog",
|
||||
}),
|
||||
),
|
||||
}),
|
||||
)
|
||||
const catalog = yield* Catalog.Service
|
||||
return yield* response(catalog.model.available())
|
||||
}),
|
||||
|
||||
@@ -247,10 +247,12 @@ function unavailable(status: Status.State) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The managed server owns restart continuity: at boot it resumes Sessions whose execution claim was
|
||||
* never released. Claims are written when execution starts (see SessionExecution), so recovery covers
|
||||
* graceful restarts and unclean deaths alike — no shutdown hook participates.
|
||||
* The managed server owns restart continuity: it resumes Sessions the previous server suspended and
|
||||
* suspends its own active Sessions on graceful shutdown. Suspension runs while the drains are still
|
||||
* alive: connections close first, this finalizer runs next, and Session execution teardown follows.
|
||||
*/
|
||||
const installRestartContinuity = Effect.fnUntraced(function* (restart: SessionRestart.Interface) {
|
||||
yield* Effect.forkScoped(restart.resumeSuspendedSessions)
|
||||
// Registered after the fork so suspension observes still-running resumed drains during teardown.
|
||||
yield* Effect.addFinalizer(() => restart.suspendActiveSessions)
|
||||
})
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { tmpdir } from "../../core/test/fixture/tmpdir"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { ServerProcess } from "../src/process"
|
||||
|
||||
it.live("waits for plugin initialization before listing models", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir("opencode-model-endpoint-")),
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(
|
||||
path.join(tmp.path, "opencode.json"),
|
||||
JSON.stringify({
|
||||
providers: {
|
||||
custom: {
|
||||
package: "aisdk:@ai-sdk/openai-compatible",
|
||||
settings: { apiKey: "secret" },
|
||||
models: { chat: {} },
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
const server = yield* ServerProcess.start<never, never>({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
password: "secret",
|
||||
app: { version: "test-version" },
|
||||
database: { path: ":memory:" },
|
||||
config: { directory: tmp.path },
|
||||
fs: { filewatcher: false },
|
||||
})
|
||||
const url = new URL("/api/model", HttpServer.formatAddress(server.address))
|
||||
url.searchParams.set("location[directory]", tmp.path)
|
||||
const response = yield* Effect.promise(() =>
|
||||
fetch(url, { headers: { authorization: `Basic ${btoa("opencode:secret")}` } }),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const body: unknown = yield* Effect.promise(() => response.json())
|
||||
if (!isRecord(body) || !Array.isArray(body["data"])) throw new Error("Expected a model list response")
|
||||
expect(
|
||||
body["data"].some((model) => isRecord(model) && model["providerID"] === "custom" && model["id"] === "chat"),
|
||||
).toBeTrue()
|
||||
}),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
@@ -60,6 +60,15 @@ export const settings: Setting[] = [
|
||||
labels: ["off", "on"],
|
||||
keywords: ["scroll bar"],
|
||||
},
|
||||
{
|
||||
title: "Reading width",
|
||||
category: "Session",
|
||||
path: ["session", "max_width"],
|
||||
default: "auto",
|
||||
values: ["auto", 66, 72, 80],
|
||||
labels: ["auto", "66 columns", "72 columns", "80 columns"],
|
||||
keywords: ["transcript", "composer", "centered", "max width", "prose"],
|
||||
},
|
||||
{
|
||||
title: "Thinking",
|
||||
category: "Session",
|
||||
|
||||
@@ -125,6 +125,11 @@ export const Info = Schema.Struct({
|
||||
description: "Session sidebar visibility; 'auto' shows it when space permits",
|
||||
}),
|
||||
scrollbar: Schema.optional(Schema.Boolean).annotate({ description: "Show the session transcript scrollbar" }),
|
||||
max_width: Schema.optional(
|
||||
Schema.Union([Schema.Int.check(Schema.isGreaterThan(4)), Schema.Literal("auto")]),
|
||||
).annotate({
|
||||
description: "Session prose and composer max width, or 'auto' to use the available width",
|
||||
}),
|
||||
thinking: Schema.optional(Schema.Literals(["show", "hide"])).annotate({
|
||||
description: "Show or hide model reasoning by default",
|
||||
}),
|
||||
|
||||
@@ -68,7 +68,7 @@ import { errorMessage } from "../../util/error"
|
||||
import { useToast } from "../../ui/toast"
|
||||
import stripAnsi from "strip-ansi"
|
||||
import { usePromptRef } from "../../context/prompt"
|
||||
import { sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH } from "../../ui/layout"
|
||||
import { sessionLaneLayout, sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH } from "../../ui/layout"
|
||||
import { projectedPromptInput } from "../../prompt/codec"
|
||||
import { deduplicateVisibleImages } from "../../prompt/attachment"
|
||||
import { useEpilogue } from "../../context/epilogue"
|
||||
@@ -108,13 +108,13 @@ import { createSingleFlight } from "../../util/single-flight"
|
||||
import type { SessionPending } from "@opencode-ai/schema/session-pending"
|
||||
import { generateThinkingSyntax } from "./thinking-syntax"
|
||||
import { createDelayedPresence } from "../../util/delayed-presence"
|
||||
import { markdownLaneMarginTop, markdownLanes } from "./markdown-lanes"
|
||||
|
||||
addDefaultParsers(parsers.parsers)
|
||||
|
||||
// Exclude temporary bottom space when measuring the real transcript height.
|
||||
const NAVIGATION_SLACK_ID = "session-navigation-slack"
|
||||
const BACKGROUND_TOOL_HINT_DELAY = 1_000
|
||||
|
||||
// Tail-first transcript mounting: rows mounted with the session, then backfill cadence.
|
||||
// The tail comfortably overfills a tall viewport; backfill drains a 200-message transcript
|
||||
// in a few hundred milliseconds without a perceptible pause.
|
||||
@@ -1084,55 +1084,57 @@ export function Session() {
|
||||
{(height) => <box id={NAVIGATION_SLACK_ID} height={height()} flexShrink={0} />}
|
||||
</Show>
|
||||
</scrollbox>
|
||||
<box flexShrink={0}>
|
||||
<Show when={!composer.open && !disabled() && queuedPrompts().length > 0}>
|
||||
<QueuedPromptDock prompts={queuedPrompts()} onOpen={openQueuedPrompts} />
|
||||
</Show>
|
||||
<PluginSlot name="session.composer.top" input={{ sessionID: route.sessionID }} mode="all" />
|
||||
<Composer
|
||||
sessionID={route.sessionID}
|
||||
open={composer.open || (!!session()?.parentID && forms().length === 0)}
|
||||
defaultTab={composer.tab ?? (session()?.parentID ? "subagents" : undefined)}
|
||||
onClose={() => setComposer("open", false)}
|
||||
/>
|
||||
<Switch>
|
||||
<Match when={composer.open || (!!session()?.parentID && forms().length === 0)}>{null}</Match>
|
||||
<Match when={promptedPermissions().length > 0}>
|
||||
<Show when={promptedPermissions()[0]?.id} keyed>
|
||||
{(_) => {
|
||||
const request = promptedPermissions()[0]
|
||||
return request ? (
|
||||
<PermissionPrompt request={request} directory={session()?.location.directory} />
|
||||
) : null
|
||||
}}
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={forms().length > 0}>
|
||||
<Show when={forms()[0]?.id} keyed>
|
||||
{(_) => {
|
||||
const form = forms()[0]
|
||||
return form ? <FormPrompt form={form} /> : null
|
||||
}}
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={!disabled()}>
|
||||
<Prompt
|
||||
visible={true}
|
||||
ref={bind}
|
||||
disabled={false}
|
||||
onSubmit={() => {
|
||||
toBottom()
|
||||
}}
|
||||
onEmptySubmit={async () => {
|
||||
const next = queuedPrompts()[0]
|
||||
if (!next) return false
|
||||
return mutatePending("steer", next.id)
|
||||
}}
|
||||
sessionID={route.sessionID}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
<SessionContentLane width="readable">
|
||||
<box flexShrink={0}>
|
||||
<Show when={!composer.open && !disabled() && queuedPrompts().length > 0}>
|
||||
<QueuedPromptDock prompts={queuedPrompts()} onOpen={openQueuedPrompts} />
|
||||
</Show>
|
||||
<PluginSlot name="session.composer.top" input={{ sessionID: route.sessionID }} mode="all" />
|
||||
<Composer
|
||||
sessionID={route.sessionID}
|
||||
open={composer.open || (!!session()?.parentID && forms().length === 0)}
|
||||
defaultTab={composer.tab ?? (session()?.parentID ? "subagents" : undefined)}
|
||||
onClose={() => setComposer("open", false)}
|
||||
/>
|
||||
<Switch>
|
||||
<Match when={composer.open || (!!session()?.parentID && forms().length === 0)}>{null}</Match>
|
||||
<Match when={promptedPermissions().length > 0}>
|
||||
<Show when={promptedPermissions()[0]?.id} keyed>
|
||||
{(_) => {
|
||||
const request = promptedPermissions()[0]
|
||||
return request ? (
|
||||
<PermissionPrompt request={request} directory={session()?.location.directory} />
|
||||
) : null
|
||||
}}
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={forms().length > 0}>
|
||||
<Show when={forms()[0]?.id} keyed>
|
||||
{(_) => {
|
||||
const form = forms()[0]
|
||||
return form ? <FormPrompt form={form} /> : null
|
||||
}}
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={!disabled()}>
|
||||
<Prompt
|
||||
visible={true}
|
||||
ref={bind}
|
||||
disabled={false}
|
||||
onSubmit={() => {
|
||||
toBottom()
|
||||
}}
|
||||
onEmptySubmit={async () => {
|
||||
const next = queuedPrompts()[0]
|
||||
if (!next) return false
|
||||
return mutatePending("steer", next.id)
|
||||
}}
|
||||
sessionID={route.sessionID}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
</SessionContentLane>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={sidebarVisible()}>
|
||||
@@ -1176,22 +1178,30 @@ function SessionRowView(props: SessionRowViewProps) {
|
||||
)}
|
||||
</Match>
|
||||
<Match when={props.row.type === "compaction-queued"}>
|
||||
<CompactionQueued />
|
||||
<SessionContentLane width="readable">
|
||||
<CompactionQueued />
|
||||
</SessionContentLane>
|
||||
</Match>
|
||||
<Match when={props.row.type === "part" ? props.row : undefined}>
|
||||
{(row) => <SessionPartView partRef={row().ref} message={props.message} />}
|
||||
</Match>
|
||||
<Match when={props.row.type === "group" && props.row.kind === "reasoning" ? props.row : undefined}>
|
||||
{(row) => <SessionReasoningGroupView refs={row().refs} completed={row().completed} message={props.message} />}
|
||||
{(row) => (
|
||||
<SessionContentLane width="readable">
|
||||
<SessionReasoningGroupView refs={row().refs} completed={row().completed} message={props.message} />
|
||||
</SessionContentLane>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={props.row.type === "group" && props.row.kind === "exploration" ? props.row : undefined}>
|
||||
{(row) => (
|
||||
<SessionGroupView
|
||||
refs={row().refs}
|
||||
pending={row().pending}
|
||||
completed={row().completed}
|
||||
message={props.message}
|
||||
/>
|
||||
<SessionContentLane width="readable">
|
||||
<SessionGroupView
|
||||
refs={row().refs}
|
||||
pending={row().pending}
|
||||
completed={row().completed}
|
||||
message={props.message}
|
||||
/>
|
||||
</SessionContentLane>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={props.row.type === "assistant-footer" ? props.row : undefined}>
|
||||
@@ -1199,7 +1209,9 @@ function SessionRowView(props: SessionRowViewProps) {
|
||||
<Show when={props.message(row().messageID)}>
|
||||
{(message) => (
|
||||
<Show when={message().type === "assistant"}>
|
||||
<AssistantFooter message={message() as SessionMessageAssistant} />
|
||||
<SessionContentLane width="readable">
|
||||
<AssistantFooter message={message() as SessionMessageAssistant} />
|
||||
</SessionContentLane>
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
@@ -1207,7 +1219,13 @@ function SessionRowView(props: SessionRowViewProps) {
|
||||
</Match>
|
||||
<Match when={props.row.type === "turn-usage" ? props.row : undefined}>
|
||||
{(row) => (
|
||||
<TurnTokenUsage messageIDs={row().messageIDs} previousCache={row().previousCache} message={props.message} />
|
||||
<SessionContentLane width="technical">
|
||||
<TurnTokenUsage
|
||||
messageIDs={row().messageIDs}
|
||||
previousCache={row().previousCache}
|
||||
message={props.message}
|
||||
/>
|
||||
</SessionContentLane>
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
@@ -1215,6 +1233,42 @@ function SessionRowView(props: SessionRowViewProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function SessionContentLane(props: { children: JSX.Element; width: "readable" | "technical" }) {
|
||||
const ctx = use()
|
||||
const readable = () => ctx.config.session?.max_width ?? "auto"
|
||||
const layout = createMemo(() => {
|
||||
const width = readable()
|
||||
return width === "auto" ? undefined : sessionLaneLayout(ctx.width, width)
|
||||
})
|
||||
return (
|
||||
<Show when={layout()} fallback={props.children}>
|
||||
{(value) => (
|
||||
<box width="100%" paddingLeft={value().inset} flexShrink={0}>
|
||||
<box width={value()[props.width]} flexShrink={0}>
|
||||
{props.children}
|
||||
</box>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionBreakoutLane(props: { children: JSX.Element }) {
|
||||
const ctx = use()
|
||||
const readable = () => ctx.config.session?.max_width ?? "auto"
|
||||
const inset = createMemo(() => {
|
||||
const width = readable()
|
||||
return width === "auto" ? undefined : sessionLaneLayout(ctx.width, width).inset
|
||||
})
|
||||
return (
|
||||
<Show when={inset() !== undefined} fallback={props.children}>
|
||||
<box width="100%" paddingLeft={inset()} flexShrink={0}>
|
||||
{props.children}
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function TurnTokenUsage(props: {
|
||||
messageIDs: string[]
|
||||
previousCache?: CacheUsage
|
||||
@@ -1377,20 +1431,35 @@ function SessionMessageView(props: { message: SessionMessageInfo }) {
|
||||
<UserMessage message={props.message as SessionMessageUser} />
|
||||
</Match>
|
||||
<Match when={props.message.type === "shell"}>
|
||||
<ShellMessage message={props.message as Extract<SessionMessageInfo, { type: "shell" }>} />
|
||||
<SessionContentLane width="technical">
|
||||
<ShellMessage message={props.message as Extract<SessionMessageInfo, { type: "shell" }>} />
|
||||
</SessionContentLane>
|
||||
</Match>
|
||||
<Match when={props.message.type === "agent-switched" || props.message.type === "model-switched"}>
|
||||
<SessionSwitchMessageV2 message={props.message} />
|
||||
<SessionContentLane width="readable">
|
||||
<SessionSwitchMessageV2 message={props.message} />
|
||||
</SessionContentLane>
|
||||
</Match>
|
||||
<Match
|
||||
when={props.message.type === "system" || props.message.type === "synthetic" || props.message.type === "skill"}
|
||||
>
|
||||
<Show when={props.message.type === "skill"} fallback={<SessionNoticeMessageV2 message={props.message} />}>
|
||||
<SessionSkillMessage message={props.message as Extract<SessionMessageInfo, { type: "skill" }>} />
|
||||
<Show
|
||||
when={props.message.type === "skill"}
|
||||
fallback={
|
||||
<SessionContentLane width="readable">
|
||||
<SessionNoticeMessageV2 message={props.message} />
|
||||
</SessionContentLane>
|
||||
}
|
||||
>
|
||||
<SessionContentLane width="readable">
|
||||
<SessionSkillMessage message={props.message as Extract<SessionMessageInfo, { type: "skill" }>} />
|
||||
</SessionContentLane>
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={props.message.type === "compaction"}>
|
||||
<CompactionMessage message={props.message as Extract<SessionMessageInfo, { type: "compaction" }>} />
|
||||
<SessionContentLane width="readable">
|
||||
<CompactionMessage message={props.message as Extract<SessionMessageInfo, { type: "compaction" }>} />
|
||||
</SessionContentLane>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
@@ -1411,11 +1480,13 @@ function SessionPartView(props: { partRef: PartRef; message: (messageID: string)
|
||||
<TextPart part={item() as SessionMessageAssistantText} last={false} />
|
||||
</Match>
|
||||
<Match when={item().type === "reasoning"}>
|
||||
<ReasoningPart
|
||||
part={item() as SessionMessageAssistantReasoning}
|
||||
message={message() as SessionMessageAssistant}
|
||||
last={false}
|
||||
/>
|
||||
<SessionContentLane width="readable">
|
||||
<ReasoningPart
|
||||
part={item() as SessionMessageAssistantReasoning}
|
||||
message={message() as SessionMessageAssistant}
|
||||
last={false}
|
||||
/>
|
||||
</SessionContentLane>
|
||||
</Match>
|
||||
<Match when={item().type === "tool"}>
|
||||
<ToolPart part={item() as SessionMessageAssistantTool} />
|
||||
@@ -1938,79 +2009,56 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||
|
||||
return (
|
||||
<Show when={props.message.text.trim() || files().length || skills().length}>
|
||||
<box
|
||||
border={["left"]}
|
||||
borderColor={delivery() ? theme.border.default : color()}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
>
|
||||
<SessionImages images={images()} paddingLeft={2} />
|
||||
<SessionContentLane width="readable">
|
||||
<box
|
||||
onMouseOver={() => {
|
||||
setHover(true)
|
||||
}}
|
||||
onMouseOut={() => {
|
||||
setHover(false)
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
if (delivery() === "steer") {
|
||||
border={["left"]}
|
||||
borderColor={delivery() ? theme.border.default : color()}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
>
|
||||
<SessionImages images={images()} paddingLeft={2} />
|
||||
<box
|
||||
onMouseOver={() => {
|
||||
setHover(true)
|
||||
}}
|
||||
onMouseOut={() => {
|
||||
setHover(false)
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
if (delivery() === "steer") {
|
||||
dialog.replace(() => (
|
||||
<DialogSelect
|
||||
title="Pending steer"
|
||||
options={[
|
||||
{ title: "Move to queue", value: "queue" as const },
|
||||
{ title: "Delete", value: "cancel" as const },
|
||||
]}
|
||||
onSelect={(option) => {
|
||||
void updatePendingSteer(option.value)
|
||||
}}
|
||||
/>
|
||||
))
|
||||
return
|
||||
}
|
||||
dialog.replace(() => (
|
||||
<DialogSelect
|
||||
title="Pending steer"
|
||||
options={[
|
||||
{ title: "Move to queue", value: "queue" as const },
|
||||
{ title: "Delete", value: "cancel" as const },
|
||||
]}
|
||||
onSelect={(option) => {
|
||||
void updatePendingSteer(option.value)
|
||||
}}
|
||||
<DialogMessage
|
||||
messageID={props.message.id}
|
||||
sessionID={ctx.sessionID}
|
||||
setPrompt={(value) => promptRef.current?.set(value)}
|
||||
/>
|
||||
))
|
||||
return
|
||||
}
|
||||
dialog.replace(() => (
|
||||
<DialogMessage
|
||||
messageID={props.message.id}
|
||||
sessionID={ctx.sessionID}
|
||||
setPrompt={(value) => promptRef.current?.set(value)}
|
||||
/>
|
||||
))
|
||||
}}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
backgroundColor={hover() ? theme.raise(theme.background.default) : theme.background.default}
|
||||
flexShrink={0}
|
||||
>
|
||||
<text fg={theme.text.default}>{props.message.text}</text>
|
||||
<Show when={skills().length}>
|
||||
<box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
|
||||
<For each={skills()}>
|
||||
{(skill) => (
|
||||
<text fg={theme.text.default}>
|
||||
<span
|
||||
style={{
|
||||
bg: theme.hue.accent[mode() === "light" ? 700 : 200],
|
||||
fg: theme.background.default,
|
||||
bold: true,
|
||||
}}
|
||||
>
|
||||
{" skill "}
|
||||
</span>
|
||||
<span style={{ bg: theme.raise(theme.background.default), fg: theme.text.subdued }}>
|
||||
{` ${skill.name} `}
|
||||
</span>
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={files().length}>
|
||||
<box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
|
||||
<For each={files()}>
|
||||
{(file) => {
|
||||
const label = file.mime === "application/x-directory" ? "dir" : "file"
|
||||
return (
|
||||
}}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
backgroundColor={hover() ? theme.raise(theme.background.default) : theme.background.default}
|
||||
flexShrink={0}
|
||||
>
|
||||
<text fg={theme.text.default}>{props.message.text}</text>
|
||||
<Show when={skills().length}>
|
||||
<box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
|
||||
<For each={skills()}>
|
||||
{(skill) => (
|
||||
<text fg={theme.text.default}>
|
||||
<span
|
||||
style={{
|
||||
@@ -2019,20 +2067,45 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||
bold: true,
|
||||
}}
|
||||
>
|
||||
{` ${label} `}
|
||||
{" skill "}
|
||||
</span>
|
||||
<span style={{ bg: theme.raise(theme.background.default), fg: theme.text.subdued }}>
|
||||
{" "}
|
||||
{file.name ?? (file.source.type === "uri" ? file.source.uri : "attachment")}{" "}
|
||||
{` ${skill.name} `}
|
||||
</span>
|
||||
</text>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={files().length}>
|
||||
<box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
|
||||
<For each={files()}>
|
||||
{(file) => {
|
||||
const label = file.mime === "application/x-directory" ? "dir" : "file"
|
||||
return (
|
||||
<text fg={theme.text.default}>
|
||||
<span
|
||||
style={{
|
||||
bg: theme.hue.accent[mode() === "light" ? 700 : 200],
|
||||
fg: theme.background.default,
|
||||
bold: true,
|
||||
}}
|
||||
>
|
||||
{` ${label} `}
|
||||
</span>
|
||||
<span style={{ bg: theme.raise(theme.background.default), fg: theme.text.subdued }}>
|
||||
{" "}
|
||||
{file.name ?? (file.source.type === "uri" ? file.source.uri : "attachment")}{" "}
|
||||
</span>
|
||||
</text>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
</SessionContentLane>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -2215,14 +2288,16 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) {
|
||||
const theme = useTheme()
|
||||
const { currentSyntax: syntax } = useThemes()
|
||||
const plugins = usePlugin()
|
||||
return (
|
||||
<Show when={props.part.text.trim()}>
|
||||
const constrained = () => (ctx.config.session?.max_width ?? "auto") !== "auto"
|
||||
|
||||
function Content(input: { content: string }) {
|
||||
return (
|
||||
<box paddingLeft={3} flexShrink={0}>
|
||||
<markdown
|
||||
syntaxStyle={syntax()}
|
||||
streaming={true}
|
||||
internalBlockMode="top-level"
|
||||
content={props.part.text.trim()}
|
||||
content={input.content.trim()}
|
||||
tableOptions={{ style: "grid" }}
|
||||
conceal={ctx.markdownMode() === "rendered"}
|
||||
fg={theme.markdown.text}
|
||||
@@ -2230,6 +2305,38 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) {
|
||||
renderNode={plugins.markdown()}
|
||||
/>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={props.part.text.trim()}>
|
||||
<Switch>
|
||||
<Match when={!constrained()}>
|
||||
<Content content={props.part.text} />
|
||||
</Match>
|
||||
<Match when={constrained()}>
|
||||
<Index each={markdownLanes(props.part.text.trim())}>
|
||||
{(segment, index) => {
|
||||
const content = <Content content={segment().content} />
|
||||
return (
|
||||
<box width="100%" marginTop={markdownLaneMarginTop(index, segment().width)} flexShrink={0}>
|
||||
<Switch>
|
||||
<Match when={segment().width === "full"}>
|
||||
<SessionBreakoutLane>{content}</SessionBreakoutLane>
|
||||
</Match>
|
||||
<Match when={segment().width === "technical"}>
|
||||
<SessionContentLane width="technical">{content}</SessionContentLane>
|
||||
</Match>
|
||||
<Match when={segment().width === "readable"}>
|
||||
<SessionContentLane width="readable">{content}</SessionContentLane>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</Index>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -2238,6 +2345,7 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) {
|
||||
|
||||
function ToolPart(props: { part: SessionMessageAssistantTool; images?: boolean }) {
|
||||
const display = createMemo(() => toolDisplay(props.part.name))
|
||||
const width = createMemo(() => toolLane(props.part.name))
|
||||
|
||||
const toolprops = {
|
||||
get metadata() {
|
||||
@@ -2307,10 +2415,12 @@ function ToolPart(props: { part: SessionMessageAssistantTool; images?: boolean }
|
||||
</Switch>
|
||||
)
|
||||
return [
|
||||
content,
|
||||
<Show when={props.images !== false}>
|
||||
<ToolImages parts={[props.part]} />
|
||||
</Show>,
|
||||
<SessionContentLane width={width()}>{content}</SessionContentLane>,
|
||||
<SessionContentLane width="readable">
|
||||
<Show when={props.images !== false}>
|
||||
<ToolImages parts={[props.part]} />
|
||||
</Show>
|
||||
</SessionContentLane>,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3144,7 +3254,7 @@ function Edit(props: ToolProps) {
|
||||
const diffView = ctx.config.diffs?.view
|
||||
if (diffView === "unified") return "unified"
|
||||
if (diffView === "split") return "split"
|
||||
// Default to "auto" behavior
|
||||
if ((ctx.config.session?.max_width ?? "auto") !== "auto") return "unified"
|
||||
return ctx.width > 120 ? "split" : "unified"
|
||||
})
|
||||
|
||||
@@ -3222,6 +3332,7 @@ function ApplyPatch(props: ToolProps) {
|
||||
const view = createMemo(() => {
|
||||
if (ctx.config.diffs?.view === "unified") return "unified"
|
||||
if (ctx.config.diffs?.view === "split") return "split"
|
||||
if ((ctx.config.session?.max_width ?? "auto") !== "auto") return "unified"
|
||||
return ctx.width > 120 ? "split" : "unified"
|
||||
})
|
||||
|
||||
@@ -3407,11 +3518,17 @@ const toolDisplays = new Set([
|
||||
"skill",
|
||||
])
|
||||
|
||||
const technicalToolDisplays = new Set(["shell", "write", "edit", "execute", "patch", "generic"])
|
||||
|
||||
export function toolDisplay(tool: string) {
|
||||
const normalized = canonicalToolName(tool)
|
||||
return toolDisplays.has(normalized) ? normalized : "generic"
|
||||
}
|
||||
|
||||
export function toolLane(tool: string): "readable" | "technical" {
|
||||
return technicalToolDisplays.has(toolDisplay(tool)) ? "technical" : "readable"
|
||||
}
|
||||
|
||||
function recordValue(value: unknown): Record<string, unknown> | undefined {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return
|
||||
return value as Record<string, unknown>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
export type MarkdownLane = {
|
||||
content: string
|
||||
width: "readable" | "technical" | "full"
|
||||
}
|
||||
|
||||
export function markdownLanes(content: string): MarkdownLane[] {
|
||||
const result: MarkdownLane[] = []
|
||||
let fence: { marker: "`" | "~"; length: number } | undefined
|
||||
let table = false
|
||||
const lines = content.match(/[^\n]*(?:\n|$)/g)?.filter(Boolean) ?? []
|
||||
|
||||
for (const [index, line] of lines.entries()) {
|
||||
const opening = fence ? undefined : line.match(/^ {0,3}(`{3,}|~{3,})([^\n]*)/)
|
||||
const marker = opening?.[1]
|
||||
const tableOpening = !opening && !fence && isTableRow(line) && isTableDelimiter(lines[index + 1])
|
||||
if (marker) fence = { marker: marker.startsWith("`") ? "`" : "~", length: marker.length }
|
||||
if (tableOpening) table = true
|
||||
|
||||
const width = opening
|
||||
? opening[2]?.trim().split(/\s/, 1)[0]?.toLowerCase() === "mermaid"
|
||||
? "full"
|
||||
: "technical"
|
||||
: fence
|
||||
? (result.at(-1)?.width ?? "technical")
|
||||
: table
|
||||
? "technical"
|
||||
: "readable"
|
||||
const previous = result.at(-1)
|
||||
if (previous?.width === width) previous.content += line
|
||||
else result.push({ content: line, width })
|
||||
|
||||
if (!fence) {
|
||||
if (table && !isTableRow(lines[index + 1])) table = false
|
||||
continue
|
||||
}
|
||||
const currentFence = fence
|
||||
const trimmed = line.trim()
|
||||
if (
|
||||
!opening &&
|
||||
(line.match(/^ */)?.[0].length ?? 0) <= 3 &&
|
||||
trimmed.length >= currentFence.length &&
|
||||
[...trimmed].every((character) => character === currentFence.marker)
|
||||
) {
|
||||
fence = undefined
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function isTableRow(line: string | undefined) {
|
||||
return Boolean(line?.trim() && line.includes("|"))
|
||||
}
|
||||
|
||||
function isTableDelimiter(line: string | undefined) {
|
||||
if (!line) return false
|
||||
const value = line.trim().replace(/^\||\|$/g, "")
|
||||
const cells = value.split("|")
|
||||
return cells.length > 1 && cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim()))
|
||||
}
|
||||
|
||||
export function markdownLaneMarginTop(index: number, width: MarkdownLane["width"]) {
|
||||
if (index === 0 || width === "full") return 0
|
||||
return 1
|
||||
}
|
||||
@@ -1,6 +1,19 @@
|
||||
export const SESSION_SIDEBAR_WIDTH = 42
|
||||
export const SESSION_TECHNICAL_LANE_WIDTH = 88
|
||||
const SESSION_CONTENT_MIN_WIDTH = 44
|
||||
|
||||
export function sessionTabsFitVertically(total: number) {
|
||||
return total >= SESSION_SIDEBAR_WIDTH + SESSION_CONTENT_MIN_WIDTH
|
||||
}
|
||||
|
||||
// The shared spine centers the prose measure; the technical rail shares its
|
||||
// leading edge and extends rightward, clamped so it still fits the canvas.
|
||||
export function sessionLaneLayout(available: number, readable: number) {
|
||||
const technical = Math.min(available, Math.max(readable, SESSION_TECHNICAL_LANE_WIDTH))
|
||||
const centered = Math.floor((available - Math.min(readable, technical)) / 2)
|
||||
return {
|
||||
inset: Math.max(0, Math.min(centered, available - technical)),
|
||||
readable: Math.min(readable, technical),
|
||||
technical,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
parseQuestionAnswers,
|
||||
parseQuestions,
|
||||
toolDisplay,
|
||||
toolLane,
|
||||
} from "../../../src/routes/session"
|
||||
|
||||
let testSetup: Awaited<ReturnType<typeof testRender>> | undefined
|
||||
@@ -131,6 +132,11 @@ describe("TUI inline tool wrapping", () => {
|
||||
expect(toolDisplay("apply_patch")).toBe("patch")
|
||||
expect(toolDisplay("patch")).toBe("patch")
|
||||
expect(toolDisplay("plugin_tool")).toBe("generic")
|
||||
expect(toolLane("glob")).toBe("readable")
|
||||
expect(toolLane("webfetch")).toBe("readable")
|
||||
expect(toolLane("shell")).toBe("technical")
|
||||
expect(toolLane("apply_patch")).toBe("technical")
|
||||
expect(toolLane("plugin_tool")).toBe("technical")
|
||||
})
|
||||
|
||||
test("replaces pending copy when a tool fails before completion", async () => {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { markdownLaneMarginTop, markdownLanes } from "../../../src/routes/session/markdown-lanes"
|
||||
|
||||
test("keeps prose in the readable lane", () => {
|
||||
expect(markdownLanes("Before\n\nAfter")).toEqual([{ content: "Before\n\nAfter", width: "readable" }])
|
||||
})
|
||||
|
||||
test("moves Mermaid fences into the full lane", () => {
|
||||
expect(
|
||||
markdownLanes(`Before
|
||||
|
||||
\`\`\`mermaid
|
||||
flowchart LR
|
||||
A --> B
|
||||
\`\`\`
|
||||
|
||||
After`),
|
||||
).toEqual([
|
||||
{ content: "Before\n\n", width: "readable" },
|
||||
{ content: "```mermaid\nflowchart LR\n A --> B\n```\n", width: "full" },
|
||||
{ content: "\nAfter", width: "readable" },
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps an incomplete streaming Mermaid fence full width", () => {
|
||||
expect(markdownLanes("Before\n```mermaid\nflowchart LR\n A -->")).toEqual([
|
||||
{ content: "Before\n", width: "readable" },
|
||||
{ content: "```mermaid\nflowchart LR\n A -->", width: "full" },
|
||||
])
|
||||
})
|
||||
|
||||
test("supports tilde fences and longer closing fences", () => {
|
||||
expect(markdownLanes("~~~ts\nconst value = 1\n~~~~\nAfter")).toEqual([
|
||||
{ content: "~~~ts\nconst value = 1\n~~~~\n", width: "technical" },
|
||||
{ content: "After", width: "readable" },
|
||||
])
|
||||
})
|
||||
|
||||
test("does not close a fence indented as code", () => {
|
||||
expect(markdownLanes("```ts\n ```\nstill code")).toEqual([
|
||||
{ content: "```ts\n ```\nstill code", width: "technical" },
|
||||
])
|
||||
})
|
||||
|
||||
test("gives ordinary fenced code an intermediate lane", () => {
|
||||
expect(markdownLanes("```ts\nexport const value = true\n```")).toEqual([
|
||||
{ content: "```ts\nexport const value = true\n```", width: "technical" },
|
||||
])
|
||||
})
|
||||
|
||||
test("gives Markdown tables the technical lane", () => {
|
||||
expect(markdownLanes("Before\n\n| Name | Value |\n| --- | ---: |\n| Width | 88 |\n\nAfter")).toEqual([
|
||||
{ content: "Before\n\n", width: "readable" },
|
||||
{ content: "| Name | Value |\n| --- | ---: |\n| Width | 88 |\n", width: "technical" },
|
||||
{ content: "\nAfter", width: "readable" },
|
||||
])
|
||||
})
|
||||
|
||||
test("does not treat ordinary pipe characters as a table", () => {
|
||||
expect(markdownLanes("Use foo | bar in prose.")).toEqual([{ content: "Use foo | bar in prose.", width: "readable" }])
|
||||
})
|
||||
|
||||
test("restores spacing between separately rendered blocks", () => {
|
||||
expect(markdownLaneMarginTop(0, "readable")).toBe(0)
|
||||
expect(markdownLaneMarginTop(1, "technical")).toBe(1)
|
||||
expect(markdownLaneMarginTop(2, "readable")).toBe(1)
|
||||
expect(markdownLaneMarginTop(1, "full")).toBe(0)
|
||||
})
|
||||
@@ -27,6 +27,15 @@ test("validates the session tabs setting", () => {
|
||||
expect(decode({ session: { image_preview: true } })).toEqual({ session: { image_preview: true } })
|
||||
})
|
||||
|
||||
test("validates the session max width setting", () => {
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
expect(decode({ session: { max_width: "auto" } })).toEqual({ session: { max_width: "auto" } })
|
||||
expect(decode({ session: { max_width: 100 } })).toEqual({ session: { max_width: 100 } })
|
||||
expect(() => decode({ session: { max_width: 4 } })).toThrow()
|
||||
expect(() => decode({ session: { max_width: 100.5 } })).toThrow()
|
||||
})
|
||||
|
||||
test("resolves nested config and keybind defaults", () => {
|
||||
const config = resolve(
|
||||
{
|
||||
@@ -53,6 +62,13 @@ test("shows resolved tab defaults in settings", () => {
|
||||
expect(settings.find((setting) => setting.path.join(".") === "tabs.layout")?.default).toBe("horizontal")
|
||||
})
|
||||
|
||||
test("shows session reading width presets in settings", () => {
|
||||
const setting = settings.find((setting) => setting.path.join(".") === "session.max_width")
|
||||
|
||||
expect(setting?.default).toBe("auto")
|
||||
expect(setting?.values).toEqual(["auto", 66, 72, 80])
|
||||
})
|
||||
|
||||
test("provides config and its host interface", async () => {
|
||||
const config = resolve({}, { terminalSuspend: true })
|
||||
let current = {}
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH } from "../../src/ui/layout"
|
||||
import {
|
||||
sessionLaneLayout,
|
||||
sessionTabsFitVertically,
|
||||
SESSION_SIDEBAR_WIDTH,
|
||||
SESSION_TECHNICAL_LANE_WIDTH,
|
||||
} from "../../src/ui/layout"
|
||||
|
||||
test("vertical tabs match the session sidebar and preserve compact content width", () => {
|
||||
expect(SESSION_SIDEBAR_WIDTH).toBe(42)
|
||||
expect(sessionTabsFitVertically(86)).toBe(true)
|
||||
expect(sessionTabsFitVertically(85)).toBe(false)
|
||||
})
|
||||
|
||||
test("session lanes center the prose measure on one leading edge", () => {
|
||||
expect(SESSION_TECHNICAL_LANE_WIDTH).toBe(88)
|
||||
// Wide canvas: prose is truly centered, technical extends rightward.
|
||||
expect(sessionLaneLayout(156, 66)).toEqual({ inset: 45, readable: 66, technical: 88 })
|
||||
// Centering the prose would push the technical rail past the canvas; clamp.
|
||||
expect(sessionLaneLayout(100, 66)).toEqual({ inset: 12, readable: 66, technical: 88 })
|
||||
expect(sessionLaneLayout(80, 66)).toEqual({ inset: 0, readable: 66, technical: 80 })
|
||||
expect(sessionLaneLayout(60, 66)).toEqual({ inset: 0, readable: 60, technical: 60 })
|
||||
})
|
||||
|
||||
@@ -7,13 +7,13 @@ import Config from "@npmcli/config"
|
||||
import { definitions, flatten, nerfDarts, shorthands } from "@npmcli/config/lib/definitions/index.js"
|
||||
import { Effect } from "effect"
|
||||
|
||||
const npmPath = fileURLToPath(new URL("..", import.meta.url))
|
||||
|
||||
export const load = (dir: string) =>
|
||||
Effect.tryPromise({
|
||||
try: async () => {
|
||||
const config = new Config({
|
||||
// Resolved per call: on workerd import.meta.url is undefined and building
|
||||
// this URL at module scope fails startup validation; npm config never runs there.
|
||||
npmPath: fileURLToPath(new URL("..", import.meta.url)),
|
||||
npmPath,
|
||||
cwd: dir,
|
||||
env: { ...process.env },
|
||||
argv: [process.execPath, process.execPath, "--prefix", dir],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as Observability from "./observability.js"
|
||||
|
||||
import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { LayerNode } from "./effect/layer-node.js"
|
||||
import { Effect, Layer, Logger, References, Schema } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
@@ -50,10 +50,4 @@ export function layer(
|
||||
).pipe(Layer.catchCause(() => local))
|
||||
}
|
||||
|
||||
// Layer.suspend: constructing the loggers eagerly at module scope performs
|
||||
// I/O (file logger, run id) that workerd forbids in global scope.
|
||||
export const node = LayerNode.make({
|
||||
name: "observability",
|
||||
layer: Layer.suspend(() => layer()),
|
||||
deps: [],
|
||||
})
|
||||
export const node = LayerNode.make({ name: "observability", layer: layer(), deps: [] })
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user