Compare commits

..
Author SHA1 Message Date
Hona ea15a0d025 fix(windows): hide background subprocess windows 2026-10-02 23:29:05 +00:00
70 changed files with 357 additions and 1124 deletions

No files matched your search

+12 -10
View File
@@ -122,7 +122,7 @@
"@clack/core": "1.0.0-alpha.1",
"@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:",
"@opencode-ai/pty": "0.2.0",
"@opencode-ai/pty": "0.1.13",
"@opencode/client": "workspace:*",
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -355,7 +355,7 @@
"@lydell/node-pty": "catalog:",
"@modelcontextprotocol/client": "2.0.0",
"@modelcontextprotocol/core": "2.0.0",
"@opencode-ai/pty": "0.2.0",
"@opencode-ai/pty": "0.1.13",
"@opencode/ai": "workspace:*",
"@opencode/codemode": "workspace:*",
"@opencode/plugin": "workspace:*",
@@ -365,7 +365,7 @@
"@parcel/watcher": "2.5.1",
"@silvia-odwyer/photon-node": "0.3.4",
"@standard-schema/spec": "catalog:",
"bun-pty": "0.4.9",
"bun-pty": "0.4.8",
"diff": "catalog:",
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -2167,17 +2167,19 @@
"@opencode-ai/protocol": ["@opencode-ai/protocol@0.0.0-beta-18050", "", { "dependencies": { "@opencode-ai/schema": "0.0.0-beta-18050", "effect": "4.0.0-rc.111" } }, "sha512-HDQMnvGp8IU0MdBRbEuydX1WQm09BZ4HJm9iSMQwzweJuQ2HNscgzHJPIH6P02BsbbtfJ8J7sZGPItrz1tWSgw=="],
"@opencode-ai/pty": ["@opencode-ai/pty@0.2.0", "", { "optionalDependencies": { "@opencode-ai/pty-darwin-arm64": "0.2.0", "@opencode-ai/pty-darwin-x64": "0.2.0", "@opencode-ai/pty-linux-arm64-gnu": "0.2.0", "@opencode-ai/pty-linux-arm64-musl": "0.2.0", "@opencode-ai/pty-linux-x64-gnu": "0.2.0", "@opencode-ai/pty-linux-x64-musl": "0.2.0" }, "bin": { "opencode-pty": "bin/opencode-pty.js" } }, "sha512-pV86urAwinpwFXX8AJlOf8S9CVJhGsV+11I/J6TcxMW0c7u6LgeODzdj/BVBC6jUhsG2aKDxg8rACMcDCy3HiA=="],
"@opencode-ai/pty": ["@opencode-ai/pty@0.1.13", "", { "optionalDependencies": { "@opencode-ai/pty-darwin-arm64": "0.1.13", "@opencode-ai/pty-darwin-x64": "0.1.13", "@opencode-ai/pty-linux-arm64-gnu": "0.1.13", "@opencode-ai/pty-linux-arm64-musl": "0.1.13", "@opencode-ai/pty-linux-x64-gnu": "0.1.13", "@opencode-ai/pty-linux-x64-musl": "0.1.13" }, "bin": { "opencode-pty": "bin/opencode-pty.js" } }, "sha512-WPCN8h8HaZhhUcrMG0zu+4D9vco0EZiEg/gCF1K3JPRN6UsHMiXq1HVIy5IlyfcoyjfViRmQmXYE4AuU3laBjA=="],
"@opencode-ai/pty-darwin-arm64": ["@opencode-ai/pty-darwin-arm64@0.2.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2y6xrktb2J7rRk+mzAJHA8cCbWhC4Lo2zJ66t9ad59qFhL5nzAPfpEOwnyvvFqhxebHNvL08Mp64M9IHnM0aiA=="],
"@opencode-ai/pty-darwin-arm64": ["@opencode-ai/pty-darwin-arm64@0.1.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-fVtQZqVLBuJx/aB+5ojfmQifS1KMc9gxlxpFQ6bxEFU8tn8xHQTiFPaNroZgOtaw7I4ceGyx/eXieK1wp68yAA=="],
"@opencode-ai/pty-darwin-x64": ["@opencode-ai/pty-darwin-x64@0.2.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-XVQwK9+KgVGYunCYPCCBf1Or0z6zSkzjgfdJd7dEe/LOFg5vmMkOfSB9dCXnoRCWGviWYk7xd07iFIFOgyj5Ig=="],
"@opencode-ai/pty-darwin-x64": ["@opencode-ai/pty-darwin-x64@0.1.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-b/tAEm0hCMXraPM9cxR8Rg7X1UBZInRTaxWAS4Ht9eH1nWj1rANOLvHWiWX/vVh5TB0Ubg8bWPu4B0nZkEHROQ=="],
"@opencode-ai/pty-linux-arm64-gnu": ["@opencode-ai/pty-linux-arm64-gnu@0.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-TPVGCQk5E65IY3ipcpd17rwKehdvXcXEYRhhGzok/6Dske69ei2S+ERz7NXZ8cKFAvbiiT771HHM+XyVYAl+7A=="],
"@opencode-ai/pty-linux-arm64-gnu": ["@opencode-ai/pty-linux-arm64-gnu@0.1.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-I124aSYBBjpGZnYExHfIajkvVK1FiK+//OJBGdqqFp5pas2Oruq4O8tv+pMoxomZIYh2ce/QhOOYLHRwXsthTg=="],
"@opencode-ai/pty-linux-arm64-musl": ["@opencode-ai/pty-linux-arm64-musl@0.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-iH6/liY7xN1OXVD9eGzdH11BVGvnpPsH5Z1Unz0Pn9EzkPFf28oDNKNyeXV0xIAP53oiYp8gpRq2PADNwbVsTg=="],
"@opencode-ai/pty-linux-arm64-musl": ["@opencode-ai/pty-linux-arm64-musl@0.1.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-feWsfKpaDytGJzutoK43GqQwVghG2vHZt6BE/ydPZNuqIrySQ/6JfliUAMwn5BWs/Ky7ouSwKHCyAVeukusSvg=="],
"@opencode-ai/pty-linux-x64-gnu": ["@opencode-ai/pty-linux-x64-gnu@0.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-GhdrmbUzxGHRfWvt1qutgVD4HLb7aSBsgWGjFsc6k8HjO5ZbdaC3ScRyd512YAAG5KAROLKZ6+0SEBsm5xFYlA=="],
"@opencode-ai/pty-linux-x64-gnu": ["@opencode-ai/pty-linux-x64-gnu@0.1.13", "", { "os": "linux", "cpu": "x64" }, "sha512-jliNgsevGuxfIeX7eyzjHhrJkF8uEUPnDLbF2v16uv69FhEHrraf7jyWkxazMP6rNvn2CGtwMMc4BXPS5pzjhg=="],
"@opencode-ai/pty-linux-x64-musl": ["@opencode-ai/pty-linux-x64-musl@0.1.13", "", { "os": "linux", "cpu": "x64" }, "sha512-rXDpidW66gz2b2M/NbUN8ZKmAxaJcASnuHATeXevlrFdiPUv8uJwvkRd6Pla1fp01Q65MkBmgRa7Q9c+H1PlzA=="],
"@opencode-ai/schema": ["@opencode-ai/schema@0.0.0-beta-18050", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "effect": "4.0.0-rc.111" } }, "sha512-/D6VXaWlytTXR3IOiMLIKuPcfp7FQNUzRPm9z3K7UBFd1Bw4q/WZksaf5RVcBGz+0YRxYMc1V4D7MFlceSgtyg=="],
@@ -3547,7 +3549,7 @@
"bun-ffi-structs": ["bun-ffi-structs@0.3.1", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ=="],
"bun-pty": ["bun-pty@0.4.9", "", {}, "sha512-IUF/B3FANo8vIQ775Zt7Er7lphMpYMhLkes45am2WE8FaVI7KRYtj1rQwliZ18b6GpNzBNWcl7sz9QT5wDFBeQ=="],
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
"bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
+3 -3
View File
@@ -1,7 +1,7 @@
{
"nodeModules": {
"x86_64-linux": "sha256-onbxt1PCyU4Nh14S5gIeLH20OtbQ7v2ipRq1zqZkLWs=",
"aarch64-linux": "sha256-P7DAE018lTJNGmttg87U9oaTAuw/wHlnMStdnXYKCr0=",
"aarch64-darwin": "sha256-N+NfV1ObOTnW+ez7As+CS+6ci/iRGCQ6cslvyQFCp6E="
"x86_64-linux": "sha256-EEBz2IQ14YPShAzY13zpZ7btq/EuSw86pZ6nrizc9BI=",
"aarch64-linux": "sha256-X7om4U4OlKL4OddEMcyKL+1DCQXc9/f1xzzopE16Zmk=",
"aarch64-darwin": "sha256-3bejEuX3AGv4SR/16O8KjNSO2t9WDaWCHRbBXsJ6z8A="
}
}
+1 -9
View File
@@ -124,15 +124,7 @@ const fromRequest = Effect.fn("CohereChat.fromRequest")(function* (request: LLMR
const options = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))(request.providerOptions ?? {})
const flattened = ProviderShared.flattenToolRequest(request)
const messages: (typeof Message.Type)[] = request.system.length
? [
{
role: "system",
content:
request.system.length === 1
? request.system[0].text
: request.system.map((part) => ({ type: "text", text: part.text })),
},
]
? [{ role: "system", content: ProviderShared.joinText(request.system) }]
: []
for (const message of flattened.request.messages) {
if (message.role === "system") {
+1 -1
View File
@@ -475,7 +475,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
safetySettings: options.safetySettings,
serviceTier: options.serviceTier,
systemInstruction:
request.system.length === 0 ? undefined : { parts: request.system.map((part) => ({ text: part.text })) },
request.system.length === 0 ? undefined : { parts: [{ text: ProviderShared.joinText(request.system) }] },
tools: hasTools
? [
{
+2 -15
View File
@@ -68,10 +68,7 @@ const MistralAssistantToolCall = Schema.Struct({
type MistralAssistantToolCall = Schema.Schema.Type<typeof MistralAssistantToolCall>
const MistralMessage = Schema.Union([
Schema.Struct({
role: Schema.Literal("system"),
content: Schema.Union([Schema.String, Schema.Array(MistralTextContent)]),
}),
Schema.Struct({ role: Schema.Literal("system"), content: Schema.String }),
Schema.Struct({
role: Schema.Literal("user"),
content: Schema.Union([Schema.String, Schema.Array(MistralUserContent)]),
@@ -338,17 +335,7 @@ const lowerToolResults = Effect.fn("MistralChat.lowerToolResults")(function* (
const lowerMessages = Effect.fn("MistralChat.lowerMessages")(function* (request: LLMRequest) {
const normalizeID = MistralToolID.normalizer(request)
const messages: MistralMessage[] =
request.system.length === 0
? []
: [
{
role: "system",
content:
request.system.length === 1
? request.system[0].text
: request.system.map((part) => ({ type: "text", text: part.text })),
},
]
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
for (const message of request.messages) {
if (message.role === "system") {
const update = yield* ProviderShared.wrappedSystemUpdate("Mistral Chat", message)
@@ -6,7 +6,7 @@
"provider:cohere"
],
"name": "cohere/continues-a-native-tool-call",
"recordedAt": "2026-10-03T04:09:51.600Z"
"recordedAt": "2026-10-02T02:36:16.143Z"
},
"interactions": [
{
@@ -17,14 +17,14 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"command-a-plus-05-2026\",\"messages\":[{\"role\":\"system\",\"content\":[{\"type\":\"text\",\"text\":\"Use the get_weather tool exactly once.\"},{\"type\":\"text\",\"text\":\"After the tool result, reply exactly: Paris is sunny.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]}],\"stream\":true,\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"max_tokens\":2048}"
"body": "{\"model\":\"command-a-plus-05-2026\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]}],\"stream\":true,\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"max_tokens\":2048}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "event: message-start\ndata: {\"id\":\"d6c0d412-926c-42ed-b152-043404f8c33c\",\"type\":\"message-start\",\"delta\":{\"message\":{\"role\":\"assistant\",\"content\":[],\"tool_plan\":\"\",\"tool_calls\":[],\"citations\":[]}}}\n\nevent: content-start\ndata: {\"type\":\"content-start\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"type\":\"thinking\",\"thinking\":\"\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"The\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" user\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" is\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" asking\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" for\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" the\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" weather\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" in\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" Paris\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\".\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" I\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" have\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" a\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" get\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"_weather\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" function\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" available\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" that\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" takes\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" a\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" city\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" parameter\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\".\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" The\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" user\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" specifically\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" mentioned\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" \\\"\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"Paris\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"\\\"\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" as\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" the\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" city\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\".\"}}}}\n\nevent: conteLine truncated
"body": "event: message-start\ndata: {\"id\":\"a57af21c-0496-4123-8e83-bcc6abb9bcae\",\"type\":\"message-start\",\"delta\":{\"message\":{\"role\":\"assistant\",\"content\":[],\"tool_plan\":\"\",\"tool_calls\":[],\"citations\":[]}}}\n\nevent: content-start\ndata: {\"type\":\"content-start\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"type\":\"thinking\",\"thinking\":\"\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"The\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" user\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" is\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" asking\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" about\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" the\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" weather\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" in\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" Paris\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\".\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" I\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" have\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" access\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" to\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" a\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" get\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"_weather\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" function\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" that\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" takes\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" a\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" city\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" parameter\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\".\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" The\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" instructions\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" say\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" to\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" use\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" the\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" get\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"_weather\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" tool\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" exactly\"}}}}\n\nevent: conteLine truncated
}
},
{
@@ -35,14 +35,14 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"command-a-plus-05-2026\",\"messages\":[{\"role\":\"system\",\"content\":[{\"type\":\"text\",\"text\":\"Use the get_weather tool exactly once.\"},{\"type\":\"text\",\"text\":\"After the tool result, reply exactly: Paris is sunny.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"thinking\",\"thinking\":\"The user is asking for the weather in Paris. I have a get_weather function available that takes a city parameter. The user specifically mentioned \\\"Paris\\\" as the city. I need to use the get_weather tool exactly once as per the policies, and I have all the required parameters (city = \\\"Paris\\\").\\n\\nLet me call the function with the city parameter set to \\\"Paris\\\".\"}],\"tool_calls\":[{\"id\":\"get_weather_vfp5ryredwrg\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\",\"tool_call_id\":\"get_weather_vfp5ryredwrg\"}],\"stream\":true,\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"max_tokens\":2048}"
"body": "{\"model\":\"command-a-plus-05-2026\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"thinking\",\"thinking\":\"The user is asking about the weather in Paris. I have access to a get_weather function that takes a city parameter. The instructions say to use the get_weather tool exactly once, and then reply exactly \\\"Paris is sunny\\\" after getting the tool result.\\n\\nLet me call the get_weather function with \\\"Paris\\\" as the city parameter.\"}],\"tool_calls\":[{\"id\":\"get_weather_mdhvw7kbf1e3\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\",\"tool_call_id\":\"get_weather_mdhvw7kbf1e3\"}],\"stream\":true,\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"max_tokens\":2048}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "event: message-start\ndata: {\"id\":\"0c6cba0e-442d-4e28-96b2-c3cd6ff3b22d\",\"type\":\"message-start\",\"delta\":{\"message\":{\"role\":\"assistant\",\"content\":[],\"tool_plan\":\"\",\"tool_calls\":[],\"citations\":[]}}}\n\nevent: content-start\ndata: {\"type\":\"content-start\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"type\":\"thinking\",\"thinking\":\"\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"The\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" function\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" returned\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" weather\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" data\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" for\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" Paris\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\".\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" The\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" result\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" shows\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\":\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"\\n-\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" Temperature\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\":\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" 22\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" (\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"presumably\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" degrees\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" Celsius\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\")\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"\\n-\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" Condition\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\":\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" sunny\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"\\n\\nI\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" should\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" present\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" this\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" information\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" to\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" the\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" user\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" in\"}}}}\Line truncated
"body": "event: message-start\ndata: {\"id\":\"fd1ac100-b2c5-42f4-ae1c-6de65aa5ef6a\",\"type\":\"message-start\",\"delta\":{\"message\":{\"role\":\"assistant\",\"content\":[],\"tool_plan\":\"\",\"tool_calls\":[],\"citations\":[]}}}\n\nevent: content-start\ndata: {\"type\":\"content-start\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"type\":\"thinking\",\"thinking\":\"\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\"The\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" tool\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" result\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" shows\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" that\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" Paris\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" has\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" sunny\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" weather\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" with\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" a\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" temperature\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" of\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" 22\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" degrees\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\".\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" According\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" to\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" the\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" instructions\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\",\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" after\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" getting\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" the\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" tool\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" result\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\",\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" I\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" need\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" to\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" reply\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" exactly\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\":\"}}}}\n\nevent: content-delta\ndata: {\"type\":\"content-delta\",\"index\":0,\"delta\":{\"message\":{\"content\":{\"thinking\":\" \\\"\"}}}}\n\nevent: cLine truncated
}
}
]
@@ -2,16 +2,9 @@
"version": 1,
"metadata": {
"model": "mistral-small-latest",
"tags": [
"prefix:mistral-chat",
"provider:mistral",
"protocol:mistral-chat",
"tool",
"tool-loop",
"usage"
],
"tags": ["prefix:mistral-chat", "provider:mistral", "protocol:mistral-chat", "tool", "tool-loop", "usage"],
"name": "mistral-chat/drives-a-tool-loop",
"recordedAt": "2026-10-03T04:09:49.878Z"
"recordedAt": "2026-08-30T17:18:49.552Z"
},
"interactions": [
{
@@ -22,14 +15,14 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"mistral-small-latest\",\"messages\":[{\"role\":\"system\",\"content\":[{\"type\":\"text\",\"text\":\"Call lookup_weather exactly once with Paris.\"},{\"type\":\"text\",\"text\":\"After the tool result, describe the weather briefly.\"}]},{\"role\":\"user\",\"content\":\"What is the weather?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\"}},\"stream\":true,\"max_tokens\":160,\"temperature\":0,\"reasoning_effort\":\"none\"}"
"body": "{\"model\":\"mistral-small-latest\",\"messages\":[{\"role\":\"system\",\"content\":\"Call lookup_weather exactly once with Paris.\"},{\"role\":\"user\",\"content\":\"What is the weather?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\"}},\"stream\":true,\"max_tokens\":160,\"temperature\":0,\"reasoning_effort\":\"none\"}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"id\":\"81417fdbfbeb4714ae737aab701cbee4\",\"object\":\"chat.completion.chunk\",\"created\":1791000589,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"81417fdbfbeb4714ae737aab701cbee4\",\"object\":\"chat.completion.chunk\",\"created\":1791000589,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"id\":\"EwgHkPRLW\",\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"arguments\":\"{\\\"city\\\": \\\"Paris\\\"}\"},\"index\":0}]},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":120,\"total_tokens\":132,\"completion_tokens\":12,\"prompt_tokens_details\":{\"cached_tokens\":0},\"service_tier\":\"standard\"},\"p\":\"abcdefghij\"}\n\ndata: [DONE]\n\n"
"body": "data: {\"id\":\"07491e37a5ed48f9987f1583753a466b\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"07491e37a5ed48f9987f1583753a466b\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"id\":\"ffJovBNqY\",\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"arguments\":\"{\\\"city\\\": \\\"Paris\\\"}\"},\"index\":0}]},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":110,\"total_tokens\":122,\"completion_tokens\":12,\"prompt_tokens_details\":{\"cached_tokens\":0},\"service_tier\":\"standard\"},\"p\":\"abcdefghijklm\"}\n\ndata: [DONE]\n\n"
}
},
{
@@ -40,14 +33,14 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"mistral-small-latest\",\"messages\":[{\"role\":\"system\",\"content\":[{\"type\":\"text\",\"text\":\"Call lookup_weather exactly once with Paris.\"},{\"type\":\"text\",\"text\":\"After the tool result, describe the weather briefly.\"}]},{\"role\":\"user\",\"content\":\"What is the weather?\"},{\"role\":\"assistant\",\"content\":\"\",\"tool_calls\":[{\"id\":\"EwgHkPRLW\",\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"EwgHkPRLW\",\"name\":\"lookup_weather\",\"content\":\"{\\\"condition\\\":\\\"sunny\\\",\\\"temperature\\\":\\\"18C\\\"}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":\"none\",\"stream\":true,\"max_tokens\":160,\"temperature\":0,\"reasoning_effort\":\"none\"}"
"body": "{\"model\":\"mistral-small-latest\",\"messages\":[{\"role\":\"system\",\"content\":\"Call lookup_weather exactly once with Paris.\"},{\"role\":\"user\",\"content\":\"What is the weather?\"},{\"role\":\"assistant\",\"content\":\"\",\"tool_calls\":[{\"id\":\"ffJovBNqY\",\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"ffJovBNqY\",\"name\":\"lookup_weather\",\"content\":\"{\\\"condition\\\":\\\"sunny\\\",\\\"temperature\\\":\\\"18C\\\"}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":\"none\",\"stream\":true,\"max_tokens\":160,\"temperature\":0,\"reasoning_effort\":\"none\"}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"id\":\"a46eb8ccd1b947ca9d360c3446b295a5\",\"object\":\"chat.completion.chunk\",\"created\":1791000589,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"a46eb8ccd1b947ca9d360c3446b295a5\",\"object\":\"chat.completion.chunk\",\"created\":1791000589,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"The\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz01234\"}\n\ndata: {\"id\":\"a46eb8ccd1b947ca9d360c3446b295a5\",\"object\":\"chat.completion.chunk\",\"created\":1791000589,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" weather in Paris is\"},\"finish_reason\":null}],\"p\":\"abcdefghijkl\"}\n\ndata: {\"id\":\"a46eb8ccd1b947ca9d360c3446b295a5\",\"object\":\"chat.completion.chunk\",\"created\":1791000589,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" currently sunny with a\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmn\"}\n\ndata: {\"id\":\"a46eb8ccd1b947ca9d360c3446b295a5\",\"object\":\"chat.completion.chunk\",\"created\":1791000589,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" temperature of 1\"},\"finish_reason\":null}],\"p\":\"abcdefghi\"}\n\ndata: {\"id\":\"a46eb8ccd1b947ca9d360c3446b295a5\",\"object\":\"chat.completion.chunk\",\"created\":1791000589,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"8°C\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwx\"}\n\ndata: {\"id\":\"a46eb8ccd1b947ca9d360c3446b295a5\",\"object\":\"chat.completion.chunk\",\"created\":1791000589,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":67,\"total_tokens\":84,\"completion_tokens\":17,\"prompt_tokens_details\":{\"cached_tokens\":0},\"service_tier\":\"standard\"},\"p\":\"abcdefghijklmnopqrs\"}\n\ndata: [DONE]\n\n"
"body": "data: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"The\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmn\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" weather in Paris is\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmn\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" currently sunny with\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstu\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" a temperature of \"},\"finish_reason\":null}],\"p\":\"abcdef\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"18°C\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqr\"}\n\ndata: {\"id\":\"8fcd293093b849139fc0893a48bbc7ce\",\"object\":\"chat.completion.chunk\",\"created\":1788110328,\"model\":\"mistral-small-latest\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":57,\"total_tokens\":74,\"completion_tokens\":17,\"prompt_tokens_details\":{\"cached_tokens\":0},\"service_tier\":\"standard\"},\"p\":\"abcdefghijklmnopqrstuvwxyz\"}\n\ndata: [DONE]\n\n"
}
}
]
@@ -1,6 +1,6 @@
import { expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent, LLMRequest, SystemPart } from "../../src/index.js"
import { LLM, LLMEvent } from "../../src/index.js"
import { Cohere } from "../../src/providers/cohere.js"
import { LLMClient } from "../../src/route.js"
import { expectWeatherToolLoop, goldenWeatherToolLoopRequest, runWeatherToolLoop } from "../recorded-scenarios.js"
@@ -55,20 +55,12 @@ recorded.effect(
() =>
Effect.gen(function* () {
const events = yield* runWeatherToolLoop(
LLMRequest.update(
goldenWeatherToolLoopRequest({
id: "cohere-tool-loop",
model: cohere.model("command-a-plus-05-2026"),
maxTokens: 2048,
temperature: false,
}),
{
system: [
SystemPart.make("Use the get_weather tool exactly once."),
SystemPart.make("After the tool result, reply exactly: Paris is sunny."),
],
},
),
goldenWeatherToolLoopRequest({
id: "cohere-tool-loop",
model: cohere.model("command-a-plus-05-2026"),
maxTokens: 2048,
temperature: false,
}),
)
expectWeatherToolLoop(events)
expect(events.some(LLMEvent.is.toolInputDelta)).toBe(true)
+3 -9
View File
@@ -1,6 +1,6 @@
import { expect, test } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMClient, LLMEvent, Media, Message, SystemPart, isRetryable } from "../../src/index.js"
import { LLM, LLMClient, LLMEvent, Media, Message, isRetryable } from "../../src/index.js"
import { Cohere } from "../../src/providers/cohere.js"
import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
@@ -23,7 +23,7 @@ it.effect("Cohere lowers native history, thinking, tools, and sampling", () =>
const prepared = yield* compileRequest(
LLM.request({
model: cohere.model("command-a-reasoning-08-2025"),
system: [SystemPart.make("Be concise.\nKeep this newline."), SystemPart.make("Second instructions.")],
system: "Be concise.",
messages: [
Message.user("Lookup Paris"),
Message.assistant([{ type: "tool-call", id: "lookup-1", name: "lookup", input: { city: "Paris" } }]),
@@ -44,13 +44,7 @@ it.effect("Cohere lowers native history, thinking, tools, and sampling", () =>
thinking: { type: "enabled", token_budget: 128 },
tool_choice: "REQUIRED",
messages: [
{
role: "system",
content: [
{ type: "text", text: "Be concise.\nKeep this newline." },
{ type: "text", text: "Second instructions." },
],
},
{ role: "system", content: "Be concise." },
{ role: "user", content: [{ type: "text", text: "Lookup Paris" }] },
{
role: "assistant",
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import { ConfigProvider, Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMEvent, Message, SystemPart, ToolDefinition, Media } from "../../src/index.js"
import { LLM, LLMEvent, Message, ToolDefinition, Media } from "../../src/index.js"
import { Mistral } from "../../src/providers/index.js"
import { MistralChat } from "../../src/protocols/index.js"
import { LLMClient } from "../../src/route.js"
@@ -41,7 +41,7 @@ describe("Mistral Chat", () => {
const prepared = yield* compileRequest(
LLM.request({
model,
system: [SystemPart.make("Initial\nKeep this newline."), SystemPart.make("Second instructions.")],
system: "Initial",
messages: [
Message.system("Updated"),
Message.user([
@@ -105,13 +105,7 @@ describe("Mistral Chat", () => {
reasoning_effort: "high",
})
expect(prepared.body.messages.slice(0, 4)).toMatchObject([
{
role: "system",
content: [
{ type: "text", text: "Initial\nKeep this newline." },
{ type: "text", text: "Second instructions." },
],
},
{ role: "system", content: "Initial" },
{ role: "user", content: "<system-update>\nUpdated\n</system-update>" },
{
role: "user",
@@ -1,7 +1,7 @@
import { configure } from "@opencode/ai/providers/mistral"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent, LLMRequest, Message, SystemPart, ToolChoice, ToolDefinition } from "../../src/index.js"
import { LLM, LLMEvent, LLMRequest, Message, ToolChoice, ToolDefinition } from "../../src/index.js"
import { LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import { recordedTests } from "../recorded-test.js"
@@ -97,10 +97,7 @@ describe("Mistral recorded", () => {
const model = configure({ apiKey, providerOptions: { reasoningEffort: "none" } }).model("mistral-small-latest")
const firstRequest = LLM.request({
model,
system: [
SystemPart.make("Call lookup_weather exactly once with Paris."),
SystemPart.make("After the tool result, describe the weather briefly."),
],
system: "Call lookup_weather exactly once with Paris.",
prompt: "What is the weather?",
tools: [weather],
toolChoice: weather,
+1 -1
View File
@@ -30,7 +30,7 @@
"@effect/platform-node": "catalog:",
"@opencode/client": "workspace:*",
"@opencode/plugin": "workspace:*",
"@opencode-ai/pty": "0.2.0",
"@opencode-ai/pty": "0.1.13",
"@opencode/schema": "workspace:*",
"@opencode/server": "workspace:*",
"@opencode/tui": "workspace:*",
+1 -5
View File
@@ -49,11 +49,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
inherited === undefined
? undefined
: yield* Schema.decodeUnknownEffect(Schema.fromJsonString(PersistentPty.Handoff))(inherited).pipe(
Effect.catch(() =>
Effect.logWarning("Ignoring invalid PTY restart handoff; persistent terminals will start fresh").pipe(
Effect.as(undefined),
),
),
Effect.mapError(() => new Error("Invalid PTY restart handoff")),
)
const global = yield* Global.Service
if (options.mode === "service") yield* Effect.sync(() => process.chdir(global.home))
+5 -8
View File
@@ -145,14 +145,11 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
/** Stop the registered local service. */
export const stop = Effect.fn("service.stop")(function* (options: StopOptions = {}) {
const info = yield* read(options.file)
// Terminal handoff is best-effort; it must never keep the old service running.
yield* Effect.tryPromise(() =>
options.pty === "handoff" && info !== undefined
? PtyHandoff.prepare(options.file ?? fallback(), info, defaultEnsureTiming.requestTimeout)
: PtyHandoff.clear(options.file ?? fallback()),
).pipe(
Effect.catch((cause) => Effect.logWarning("Failed to prepare persistent terminals for replacement", cause)),
)
if (options.pty === "handoff" && info !== undefined)
yield* Effect.tryPromise(() =>
PtyHandoff.prepare(options.file ?? fallback(), info, defaultEnsureTiming.requestTimeout),
)
else yield* Effect.tryPromise(() => PtyHandoff.clear(options.file ?? fallback()))
if (info !== undefined) yield* terminate(info, options, defaultEnsureTiming)
})
+45 -51
View File
@@ -449,7 +449,7 @@ export function make(options: ClientOptions) {
path: `/api/location`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -474,7 +474,7 @@ export function make(options: ClientOptions) {
path: `/api/agent`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -500,7 +500,7 @@ export function make(options: ClientOptions) {
path: `/api/plugin`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -513,7 +513,7 @@ export function make(options: ClientOptions) {
query: { location: input?.["location"] },
body: { target: input?.["target"] },
successStatus: 200,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -526,7 +526,7 @@ export function make(options: ClientOptions) {
query: { location: input["location"] },
body: { targets: input["targets"] },
successStatus: 204,
declaredStatuses: [400, 401, 404, 503],
declaredStatuses: [400, 401, 503],
empty: true,
},
requestOptions,
@@ -1116,7 +1116,7 @@ export function make(options: ClientOptions) {
path: `/api/model`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 404, 503],
declaredStatuses: [400, 401, 503],
empty: false,
},
requestOptions,
@@ -1128,7 +1128,7 @@ export function make(options: ClientOptions) {
path: `/api/model/default`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 404, 503],
declaredStatuses: [400, 401, 503],
empty: false,
},
requestOptions,
@@ -1142,7 +1142,7 @@ export function make(options: ClientOptions) {
path: `/api/experimental/generate`,
body: { prompt: input["prompt"], model: input["model"] },
successStatus: 200,
declaredStatuses: [400, 401, 404, 503],
declaredStatuses: [400, 401, 503],
empty: false,
},
requestOptions,
@@ -1156,7 +1156,7 @@ export function make(options: ClientOptions) {
path: `/api/provider`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 404, 503],
declaredStatuses: [400, 401, 503],
empty: false,
},
requestOptions,
@@ -1182,7 +1182,7 @@ export function make(options: ClientOptions) {
path: `/api/integration`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -1208,7 +1208,7 @@ export function make(options: ClientOptions) {
query: { location: input["location"] },
body: { url: input["url"] },
successStatus: 204,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401],
empty: true,
},
requestOptions,
@@ -1238,7 +1238,7 @@ export function make(options: ClientOptions) {
query: { location: input["location"] },
body: { methodID: input["methodID"], answer: input["answer"], label: input["label"] },
successStatus: 200,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -1275,7 +1275,7 @@ export function make(options: ClientOptions) {
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/oauth/${encodeURIComponent(input.attemptID)}`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401],
empty: true,
},
requestOptions,
@@ -1314,7 +1314,7 @@ export function make(options: ClientOptions) {
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/command/${encodeURIComponent(input.attemptID)}`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401],
empty: true,
},
requestOptions,
@@ -1329,7 +1329,7 @@ export function make(options: ClientOptions) {
path: `/api/mcp`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -1342,7 +1342,7 @@ export function make(options: ClientOptions) {
query: { location: input["location"] },
body: { config: input["config"] },
successStatus: 204,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401],
empty: true,
},
requestOptions,
@@ -1391,7 +1391,7 @@ export function make(options: ClientOptions) {
path: `/api/mcp/resource`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -1460,7 +1460,7 @@ export function make(options: ClientOptions) {
project: {
list: (requestOptions?: RequestOptions) =>
request<ProjectListOutput>(
{ method: "GET", path: `/api/project`, successStatus: 200, declaredStatuses: [400, 401, 404], empty: false },
{ method: "GET", path: `/api/project`, successStatus: 200, declaredStatuses: [400, 401], empty: false },
requestOptions,
),
update: (input: ProjectUpdateInput, requestOptions?: RequestOptions) =>
@@ -1489,7 +1489,7 @@ export function make(options: ClientOptions) {
path: `/api/form`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -1504,7 +1504,7 @@ export function make(options: ClientOptions) {
path: `/api/permission/request`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -1518,7 +1518,7 @@ export function make(options: ClientOptions) {
path: `/api/permission/saved`,
query: { projectID: input?.["projectID"] },
successStatus: 200,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -1529,7 +1529,7 @@ export function make(options: ClientOptions) {
method: "DELETE",
path: `/api/permission/saved/${encodeURIComponent(input.id)}`,
successStatus: 204,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401],
empty: true,
},
requestOptions,
@@ -1611,7 +1611,7 @@ export function make(options: ClientOptions) {
path: `/api/fs/list`,
query: { location: input?.["location"], path: input?.["path"] },
successStatus: 200,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -1623,7 +1623,7 @@ export function make(options: ClientOptions) {
path: `/api/fs/find`,
query: { location: input["location"], query: input["query"], type: input["type"], limit: input["limit"] },
successStatus: 200,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -1636,7 +1636,7 @@ export function make(options: ClientOptions) {
query: { location: input["location"], path: input["path"] },
body: input["payload"],
successStatus: 200,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401],
empty: false,
binaryBody: true,
},
@@ -1651,7 +1651,7 @@ export function make(options: ClientOptions) {
path: `/api/command`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -1665,7 +1665,7 @@ export function make(options: ClientOptions) {
path: `/api/skill`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -1680,7 +1680,7 @@ export function make(options: ClientOptions) {
query: { location: input["location"] },
body: { input: input["input"] },
successStatus: 200,
declaredStatuses: [400, 401, 404, 500],
declaredStatuses: [400, 401, 500],
empty: false,
},
requestOptions,
@@ -1701,7 +1701,7 @@ export function make(options: ClientOptions) {
path: `/api/pty`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -1720,7 +1720,7 @@ export function make(options: ClientOptions) {
env: input?.["env"],
},
successStatus: 200,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -1911,7 +1911,7 @@ export function make(options: ClientOptions) {
path: `/api/shell`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -1929,7 +1929,7 @@ export function make(options: ClientOptions) {
metadata: input["metadata"],
},
successStatus: 200,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -1965,7 +1965,7 @@ export function make(options: ClientOptions) {
path: `/api/shell/${encodeURIComponent(input.id)}`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401],
empty: true,
},
requestOptions,
@@ -1979,7 +1979,7 @@ export function make(options: ClientOptions) {
path: `/api/reference`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -2049,7 +2049,7 @@ export function make(options: ClientOptions) {
path: `/api/vcs/init`,
query: { location: input?.["location"], provider: input?.["provider"] },
successStatus: 204,
declaredStatuses: [400, 401, 404, 409, 501, 503],
declaredStatuses: [400, 401, 409, 501, 503],
empty: true,
},
requestOptions,
@@ -2061,7 +2061,7 @@ export function make(options: ClientOptions) {
path: `/api/vcs`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -2073,7 +2073,7 @@ export function make(options: ClientOptions) {
path: `/api/vcs/base`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 404, 503],
declaredStatuses: [400, 401, 503],
empty: false,
},
requestOptions,
@@ -2085,7 +2085,7 @@ export function make(options: ClientOptions) {
path: `/api/vcs/status`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -2098,7 +2098,7 @@ export function make(options: ClientOptions) {
path: `/api/vcs/branch`,
query: { location: input?.["location"], search: input?.["search"], limit: input?.["limit"] },
successStatus: 200,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -2111,7 +2111,7 @@ export function make(options: ClientOptions) {
path: `/api/vcs/diff`,
query: { location: input["location"], mode: input["mode"], base: input["base"], context: input["context"] },
successStatus: 200,
declaredStatuses: [400, 401, 404, 503],
declaredStatuses: [400, 401, 503],
empty: false,
},
requestOptions,
@@ -2167,7 +2167,7 @@ export function make(options: ClientOptions) {
path: `/api/websearch/provider`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 404, 503],
declaredStatuses: [400, 401, 503],
empty: false,
},
requestOptions,
@@ -2180,7 +2180,7 @@ export function make(options: ClientOptions) {
query: { location: input["location"] },
body: { query: input["query"], providerID: input["providerID"] },
successStatus: 200,
declaredStatuses: [400, 401, 404, 503],
declaredStatuses: [400, 401, 503],
empty: false,
},
requestOptions,
@@ -2194,20 +2194,14 @@ export function make(options: ClientOptions) {
path: `/api/config`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
shells: (requestOptions?: RequestOptions) =>
request<ConfigShellsOutput>(
{
method: "GET",
path: `/api/config/shell`,
successStatus: 200,
declaredStatuses: [400, 401, 404],
empty: false,
},
{ method: "GET", path: `/api/config/shell`, successStatus: 200, declaredStatuses: [400, 401], empty: false },
requestOptions,
),
update: (input: ConfigUpdateInput, requestOptions?: RequestOptions) =>
@@ -2217,7 +2211,7 @@ export function make(options: ClientOptions) {
path: `/api/experimental/config`,
body: { shell: input["shell"] },
successStatus: 204,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401],
empty: true,
},
requestOptions,
@@ -2531,14 +2531,6 @@ export type UnauthorizedError = { readonly _tag: "UnauthorizedError"; readonly m
export const isUnauthorizedError = (value: unknown): value is UnauthorizedError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "UnauthorizedError"
export type LocationNotFoundError = {
readonly _tag: "LocationNotFoundError"
readonly location: { readonly directory: string }
readonly message: string
}
export const isLocationNotFoundError = (value: unknown): value is LocationNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "LocationNotFoundError"
export type ServiceUnavailableError = {
readonly _tag: "ServiceUnavailableError"
readonly message: string
+3 -6
View File
@@ -118,12 +118,9 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
/** Stop the registered local service. */
export async function stop(options: StopOptions = {}) {
const info = await read(options.file)
// Terminal handoff is best-effort; it must never keep the old service running.
await (
options.pty === "handoff" && info !== undefined
? PtyHandoff.prepare(options.file ?? fallback(), info, defaultEnsureTiming.requestTimeout)
: PtyHandoff.clear(options.file ?? fallback())
).catch((cause: unknown) => console.warn("Failed to prepare persistent terminals for replacement", cause))
if (options.pty === "handoff" && info !== undefined)
await PtyHandoff.prepare(options.file ?? fallback(), info, defaultEnsureTiming.requestTimeout)
else await PtyHandoff.clear(options.file ?? fallback())
if (info !== undefined) await terminate(info, options, defaultEnsureTiming)
}
+34 -24
View File
@@ -10,14 +10,11 @@ type Sidecar = {
readonly expiresAt: number
}
/**
* Publish the ticket before stopping its owner so every replacement contender can adopt it.
* Never throws: terminals are best-effort, so any failure replaces the service without a handoff.
*/
/** Publish the ticket before stopping its owner so every replacement contender can adopt it. */
export async function prepare(file: string, info: Info, timeout: number) {
const existing = await read(file)
if (existing !== undefined && existing.expiresAt > Date.now() && same(existing.source, info)) return
const { OpenCode } = await import("./promise/index.js")
const { ClientError, OpenCode } = await import("./promise/index.js")
const client = OpenCode.make({
baseUrl: info.url,
headers:
@@ -25,30 +22,43 @@ export async function prepare(file: string, info: Info, timeout: number) {
? undefined
: { authorization: "Basic " + Buffer.from(`opencode:${info.password}`).toString("base64") },
})
const handoff = await client.experimental.persistentPty
.handoff({ signal: AbortSignal.timeout(timeout) })
.then(parse)
.catch(async (cause: unknown) => {
// Another caller may already have prepared and stopped this server.
const concurrent = await read(file)
if (concurrent !== undefined && concurrent.expiresAt > Date.now() && same(concurrent.source, info)) return
console.warn("Background service cannot hand off persistent terminals; shutting them down before replacement", cause)
await client.experimental.persistentPty.shutdown({ signal: AbortSignal.timeout(timeout) }).catch(() => {})
return null
})
if (handoff === undefined) return
await publish(file, info, handoff).catch((cause: unknown) =>
console.warn("Failed to publish persistent terminal handoff", cause),
const missing = (error: unknown) =>
error instanceof ClientError &&
error.reason === "UnexpectedStatus" &&
typeof error.cause === "object" &&
error.cause !== null &&
"status" in error.cause &&
error.cause.status === 404
const result = await client.experimental.persistentPty.handoff({ signal: AbortSignal.timeout(timeout) }).then(
(value) => ({ value }),
(cause: unknown) => ({ cause }),
)
}
function parse(body: unknown) {
if ("cause" in result) {
// Another caller may already have prepared and stopped this server.
const concurrent = await read(file)
if (concurrent !== undefined && concurrent.expiresAt > Date.now() && same(concurrent.source, info)) return
if (!missing(result.cause))
throw new Error("Failed to prepare persistent terminals for service replacement", { cause: result.cause })
console.warn("Background service cannot hand off persistent terminals; shutting them down before replacement")
await client.experimental.persistentPty
.shutdown({ signal: AbortSignal.timeout(timeout) })
.catch((cause: unknown) => {
if (missing(cause)) return
throw new Error("Failed to shut down persistent terminals before service replacement", { cause })
})
await publish(file, info, null)
return
}
const body: unknown = result.value
if (typeof body !== "object" || body === null || !("handoff" in body))
throw new Error("Invalid persistent terminal handoff response")
if (body.handoff === null) return null
if (body.handoff === null) {
await publish(file, info, null)
return
}
if (!isHandoff(body.handoff) || body.handoff.expiresAt <= Date.now())
throw new Error("Invalid or expired persistent terminal handoff")
return body.handoff
await publish(file, info, body.handoff)
}
async function publish(file: string, info: Info, handoff: PersistentPty.Handoff | null) {
+1
View File
@@ -17,6 +17,7 @@ export function spawnServiceContender(
): ServiceContender {
const child = spawn(command, args, {
detached: true,
windowsHide: true,
stdio: ["ignore", "ignore", "pipe"],
env: { ...process.env, ...env },
})
-2
View File
@@ -52,8 +52,6 @@ const server = Bun.serve({
await writeFile(registration + ".prepared", JSON.stringify(handoff))
return Response.json({ handoff })
}
if (pathname === "/api/experimental/persistent-pty/handoff" && mode === "handoff-broken")
return new Response(null, { status: 500 })
if (pathname !== "/api/info") return new Response(null, { status: 404 })
requests += 1
if (mode === "starting") await writeFile(registration + ".status-request", "")
@@ -147,19 +147,6 @@ test("evicts an unresponsive registered service before starting its replacement"
expect(endpoint.url).toBe(replacement.url)
})
test("stops the registered service even when terminal handoff fails", async () => {
await using fixture = await serviceFixture()
const registration = fixture.registration
fixture.spawn("handoff-broken")
await fixture.waitForFile()
await Service.stop({ file: registration, pty: "handoff" })
expect(await Bun.file(registration + ".signal").text()).toBe("SIGTERM")
expect(await Bun.file(registration).exists()).toBe(false)
expect((await Bun.file(registration + ".pty-handoff").json()).handoff).toBeNull()
})
test("signals the registered service process", async () => {
await using fixture = await serviceFixture()
const registration = fixture.registration
+2 -2
View File
@@ -106,7 +106,7 @@
"@lydell/node-pty": "catalog:",
"@modelcontextprotocol/client": "2.0.0",
"@modelcontextprotocol/core": "2.0.0",
"@opencode-ai/pty": "0.2.0",
"@opencode-ai/pty": "0.1.13",
"@opencode/ai": "workspace:*",
"@opencode/codemode": "workspace:*",
"@opencode/plugin": "workspace:*",
@@ -116,7 +116,7 @@
"@parcel/watcher": "2.5.1",
"@silvia-odwyer/photon-node": "0.3.4",
"@standard-schema/spec": "catalog:",
"bun-pty": "0.4.9",
"bun-pty": "0.4.8",
"diff": "catalog:",
"drizzle-orm": "catalog:",
"effect": "catalog:",
+4 -14
View File
@@ -114,7 +114,7 @@ const nodes = [
export const graph = LayerNode.group(nodes)
export type Services = LayerNode.Output<typeof graph>
export type Error = FileSystem.DirectoryNotFoundError
export type Error = Layer.Error<ReturnType<typeof layer>>
export interface Options {
// Plugins this instance is born with; empty and absent are equivalent.
@@ -145,7 +145,7 @@ const vanillaReplacements: LayerNode.Replacements = [
]
// One instance is one compiled, fresh copy of the graph standing on a directory.
export function layer(ref: Location.Ref, options: Options = {}): Layer.Layer<Services, Error> {
export function layer(ref: Location.Ref, options: Options = {}): Layer.Layer<Services> {
const startedAt = performance.now()
// Ordered: vanilla defaults, then caller replacements (which win over the
// defaults), then instance bindings (which win over everything).
@@ -157,18 +157,8 @@ export function layer(ref: Location.Ref, options: Options = {}): Layer.Layer<Ser
]
return LayerNode.compile(graph, { replacements, shared: Node.tags.values.global }).pipe(
// A missing directory is expected; other instance boot failures remain defects.
Layer.catchCause(
(cause): Layer.Layer<Services, Error> =>
Layer.unwrap(
Effect.failCause(cause).pipe(
Effect.catch(
(error): Effect.Effect<never, Error> =>
error instanceof FileSystem.DirectoryNotFoundError ? Effect.fail(error) : Effect.die(error),
),
),
),
),
// Instance boot failures are defects; provided operations retain their typed errors.
Layer.orDie,
Layer.tap((context) => Effect.addFinalizer(() => Context.get(context, LocationLifecycle.Service).shutdown)),
Layer.tap(() =>
Effect.logInfo("location services booted", {
+2 -2
View File
@@ -5,13 +5,13 @@ import { Context, type Effect } from "effect"
import type { Session } from "@opencode/schema/session"
import { Node } from "@opencode/util/effect/app-node"
import { LayerNode } from "@opencode/util/effect/layer-node"
import type { Services, Error } from "../instance.js"
import type { Services } from "../instance.js"
/** Selects Session capabilities; implementations own caching and lifetime. */
export interface Interface {
readonly provide: (
session: Session.Info,
) => <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E | Error, Exclude<R, Services>>
) => <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, Exclude<R, Services>>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Instance") {}
+1 -1
View File
@@ -17,7 +17,7 @@ export function buildLocationServiceMap(
Effect.gen(function* () {
const owner = yield* Effect.scope
const builds = MutableHashMap.empty<Location.Ref, { close?: Effect.Effect<void> }>()
const inner: LayerMap.LayerMap<Location.Ref, LocationServices, Instance.Error> = yield* LayerMap.make(
const inner: LayerMap.LayerMap<Location.Ref, LocationServices> = yield* LayerMap.make(
(ref: Location.Ref) => {
const build: { close?: Effect.Effect<void> } = {}
MutableHashMap.set(builds, ref, build)
+1 -1
View File
@@ -1 +1 @@
{"deepinfra":{"id":"deepinfra","env":["DEEPINFRA_API_KEY"],"npm":"@ai-sdk/deepinfra","name":"Deep Infra","doc":"https://deepinfra.com/models","models":{"tencent/Hy3":{"id":"tencent/Hy3","name":"Hy3","description":"Tencent Hy reasoning model for coding, instruction following, and agent tasks","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-07-06","last_updated":"2026-07-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":262144,"input":192000,"output":128000},"cost":{"input":0.13,"output":0.53,"cache_read":0.033},"canonical_model_id":"tencent/hy3"},"tencent/Hy4-preview":{"id":"tencent/Hy4-preview","name":"Hy4 preview","description":"A next-generation productivity model with significantly enhanced Agent and complex task execution capabilities.","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","high"]}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-08-28","last_updated":"2026-08-28","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":64000},"cost":{"input":0.834,"output":2.501,"cache_read":0.042},"canonical_model_id":"tencent/hy4-preview"},"meta-llama/Llama-3.3-70B-Instruct-Turbo":{"id":"meta-llama/Llama-3.3-70B-Instruct-Turbo","name":"Llama 3.3 70B Turbo","description":"Compact Llama instruction model for fast chat and local deployment","family":"llama","attachment":false,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2024-12-06","last_updated":"2024-12-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":131072,"output":16384},"cost":{"input":0.1,"output":0.32}},"meta-llama/Llama-4-Scout-17B-16E-Instruct":{"id":"meta-llama/Llama-4-Scout-17B-16E-Instruct","name":"Llama 4 Scout 17B","description":"Open multimodal Llama model for long-context analysis and efficient agents","family":"llama","attachment":true,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":327680,"output":16384},"cost":{"input":0.1,"output":0.3}},"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8":{"id":"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8","name":"Llama 4 Maverick 17B FP8","description":"Open multimodal Llama model for strong reasoning and fast responses","family":"llama","attachment":true,"reasoning":false,"tool_call":false,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":0.2,"output":0.8}},"XiaomiMiMo/MiMo-V2.6-Pro":{"id":"XiaomiMiMo/MiMo-V2.6-Pro","name":"MiMo-V2.6-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.43,"output":0.87,"cache_read":0.0036},"canonical_model_id":"xiaomi/mimo-v2.6-pro"},"XiaomiMiMo/MiMo-V2.5-Pro":{"id":"XiaomiMiMo/MiMo-V2.5-Pro","name":"MiMo-V2.5-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"interleaved":{"field":"reasoning_content"},"structured_output":true,"temperature":true,"knowledge":"2024-12","release_date":"2026-04-22","last_updated":"2026-04-22","modalities":{"input":["text","audio"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":1,"output":3,"cache_read":0.2},"canonical_model_id":"xiaomi/mimo-v2.5-pro"},"XiaomiMiMo/MiMo-V2.6-Flash":{"id":"XiaomiMiMo/MiMo-V2.6-Flash","name":"MiMo-V2.6-Flash","description":"MiMo Flash model for multimodal coding agents and long-context automation","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.14,"output":0.28,"cache_read":0.0028},"canonical_model_id":"xiaomi/mimo-v2.6-flash"},"XiaomiMiMo/MiMo-V2.5":{"id":"XiaomiMiMo/MiMo-V2.5","name":"MiMo-V2.5","description":"Open MiMo model for multimodal coding agents and long-coLine truncated
{"deepinfra":{"id":"deepinfra","env":["DEEPINFRA_API_KEY"],"npm":"@ai-sdk/deepinfra","name":"Deep Infra","doc":"https://deepinfra.com/models","models":{"tencent/Hy3":{"id":"tencent/Hy3","name":"Hy3","description":"Tencent Hy reasoning model for coding, instruction following, and agent tasks","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-07-06","last_updated":"2026-07-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":262144,"input":192000,"output":128000},"cost":{"input":0.13,"output":0.53,"cache_read":0.033},"canonical_model_id":"tencent/hy3"},"tencent/Hy4-preview":{"id":"tencent/Hy4-preview","name":"Hy4 preview","description":"A next-generation productivity model with significantly enhanced Agent and complex task execution capabilities.","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","high"]}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-08-28","last_updated":"2026-08-28","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":64000},"cost":{"input":0.834,"output":2.501,"cache_read":0.042},"canonical_model_id":"tencent/hy4-preview"},"meta-llama/Llama-3.3-70B-Instruct-Turbo":{"id":"meta-llama/Llama-3.3-70B-Instruct-Turbo","name":"Llama 3.3 70B Turbo","description":"Compact Llama instruction model for fast chat and local deployment","family":"llama","attachment":false,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2024-12-06","last_updated":"2024-12-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":131072,"output":16384},"cost":{"input":0.1,"output":0.32}},"meta-llama/Llama-4-Scout-17B-16E-Instruct":{"id":"meta-llama/Llama-4-Scout-17B-16E-Instruct","name":"Llama 4 Scout 17B","description":"Open multimodal Llama model for long-context analysis and efficient agents","family":"llama","attachment":true,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":327680,"output":16384},"cost":{"input":0.1,"output":0.3}},"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8":{"id":"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8","name":"Llama 4 Maverick 17B FP8","description":"Open multimodal Llama model for strong reasoning and fast responses","family":"llama","attachment":true,"reasoning":false,"tool_call":false,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":0.2,"output":0.8}},"XiaomiMiMo/MiMo-V2.6-Pro":{"id":"XiaomiMiMo/MiMo-V2.6-Pro","name":"MiMo-V2.6-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.43,"output":0.87,"cache_read":0.0036},"canonical_model_id":"xiaomi/mimo-v2.6-pro"},"XiaomiMiMo/MiMo-V2.5-Pro":{"id":"XiaomiMiMo/MiMo-V2.5-Pro","name":"MiMo-V2.5-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"interleaved":{"field":"reasoning_content"},"structured_output":true,"temperature":true,"knowledge":"2024-12","release_date":"2026-04-22","last_updated":"2026-04-22","modalities":{"input":["text","audio"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":1,"output":3,"cache_read":0.2},"canonical_model_id":"xiaomi/mimo-v2.5-pro"},"XiaomiMiMo/MiMo-V2.6-Flash":{"id":"XiaomiMiMo/MiMo-V2.6-Flash","name":"MiMo-V2.6-Flash","description":"MiMo Flash model for multimodal coding agents and long-context automation","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.14,"output":0.28,"cache_read":0.0028},"canonical_model_id":"xiaomi/mimo-v2.6-flash"},"XiaomiMiMo/MiMo-V2.5":{"id":"XiaomiMiMo/MiMo-V2.5","name":"MiMo-V2.5","description":"Open MiMo model for multimodal coding agents and long-coLine truncated
+22 -80
View File
@@ -1,8 +1,8 @@
import { spawn } from "node:child_process"
import { mkdir, readFile, rename } from "node:fs/promises"
import { readFile } from "node:fs/promises"
import net from "node:net"
import path from "node:path"
import { Cause, Data, Duration, Effect, Schema, Semaphore } from "effect"
import { Data, Duration, Effect, Schema, Semaphore } from "effect"
import type { Handoff } from "@opencode/schema/persistent-pty"
const ProtocolVersion = 7
@@ -156,14 +156,11 @@ export interface DaemonTransport {
}
export const makeDaemonTransport = Effect.fn("PersistentPty.makeDaemonTransport")(function* (
root: string,
initialID: string,
directory: string,
binary: () => Promise<string> = () => Promise.resolve(process.env.OPENCODE_PTY_BIN || "opencode-pty"),
inherited?: Handoff,
) {
const startup = Semaphore.makeUnsafe(1)
// Each daemon owns the runtime directory `<root>/<id>`.
let id = initialID
let registration: Registration | undefined
let owner: Awaited<ReturnType<typeof openOwner>> | undefined
let closed = false
@@ -198,7 +195,7 @@ export const makeDaemonTransport = Effect.fn("PersistentPty.makeDaemonTransport"
}),
)
const discover = Effect.fn("PersistentPty.daemon.discover")(function* (directory = path.join(root, id)) {
const discover = Effect.fn("PersistentPty.daemon.discover")(function* () {
const value = yield* Effect.tryPromise({
try: () => readFile(path.join(directory, "service.json"), "utf8"),
catch: (cause) => failure("connect", cause),
@@ -225,16 +222,17 @@ export const makeDaemonTransport = Effect.fn("PersistentPty.makeDaemonTransport"
return decoded
})
const spawnDaemon = Effect.fn("PersistentPty.daemon.spawn")(function* () {
const start = Effect.fn("PersistentPty.daemon.start")(function* () {
const executable = yield* Effect.tryPromise({ try: binary, catch: (cause) => failure("spawn", cause) })
const child = yield* Effect.tryPromise({
try: () =>
new Promise<ReturnType<typeof spawn>>((resolve, reject) => {
const child = spawn(
executable,
["daemon", "--runtime-dir", root, "--name", id],
{ detached: true, stdio: "ignore" },
)
const child = spawn(executable, ["daemon"], {
detached: true,
windowsHide: true,
stdio: "ignore",
env: { ...process.env, OPENCODE_PTY_RUNTIME_DIR: directory },
})
child.once("spawn", () => {
child.unref()
resolve(child)
@@ -246,7 +244,7 @@ export const makeDaemonTransport = Effect.fn("PersistentPty.makeDaemonTransport"
return yield* Effect.gen(function* () {
const deadline = Date.now() + 5_000
let last: DaemonError | undefined
while (Date.now() < deadline && child.exitCode === null && child.signalCode === null) {
while (Date.now() < deadline) {
const found = yield* discover().pipe(
Effect.map((value) => ({ value })),
Effect.catch((error) => {
@@ -272,25 +270,6 @@ export const makeDaemonTransport = Effect.fn("PersistentPty.makeDaemonTransport"
)
})
// An unreachable daemon can still hold this directory's lock, so retry once in a fresh directory.
const start = () =>
spawnDaemon().pipe(
Effect.catch((error) =>
Effect.gen(function* () {
yield* Effect.logWarning("Starting opencode-pty in a fresh runtime directory", { cause: error.message })
abandon()
return yield* spawnDaemon()
}),
),
)
const abandon = () => {
owner?.socket.destroy()
owner = undefined
registration = undefined
id = crypto.randomUUID()
}
const connect = Effect.fn("PersistentPty.daemon.connect")(function* (shouldStart: boolean) {
if (closed) return yield* Effect.fail(new DaemonError({ kind: "connect", message: "PTY owner scope is closed" }))
if (registration) return registration
@@ -370,23 +349,18 @@ export const makeDaemonTransport = Effect.fn("PersistentPty.makeDaemonTransport"
)
if (response.type !== "handoff")
return yield* Effect.fail(new DaemonError({ kind: "protocol", message: "Expected PTY handoff ticket" }))
return {
directory: path.join(root, id),
instanceID: registered.instance_id,
ticket: response.ticket,
expiresAt: response.expires_at,
}
return { directory, instanceID: registered.instance_id, ticket: response.ticket, expiresAt: response.expires_at }
}),
)
const subscribe = Effect.fn("PersistentPty.daemon.subscribe")(function* (
terminalID: number,
id: number,
input: Parameters<DaemonTransport["subscribe"]>[1],
) {
const attempt = Effect.gen(function* () {
const current = yield* connect(false)
return yield* Effect.tryPromise({
try: () => subscribePromise(current, terminalID, input),
try: () => subscribePromise(current, id, input),
catch: (cause) => (cause instanceof DaemonError ? cause : failure("connect", cause)),
}).pipe(
Effect.catch((error) => {
@@ -398,51 +372,19 @@ export const makeDaemonTransport = Effect.fn("PersistentPty.makeDaemonTransport"
return yield* attempt.pipe(Effect.catch((error) => (error.kind === "registration" ? attempt : Effect.fail(error))))
})
// A replacement must own its inherited daemon before the server becomes ready. Terminals are
// best-effort: any failure abandons the old daemon so the restart itself always succeeds.
// A replacement must own its inherited daemon before the server becomes ready.
if (inherited) {
let found: Registration | undefined
yield* Effect.gen(function* () {
if (inherited.expiresAt <= Date.now())
return yield* Effect.fail(new DaemonError({ kind: "registration", message: "PTY restart handoff expired" }))
// Older releases put the daemon directory outside the root; its directory name is still the ID.
id = path.basename(inherited.directory)
found = yield* discover(inherited.directory)
if (found.instance_id !== inherited.instanceID)
return yield* Effect.fail(new DaemonError({ kind: "registration", message: "PTY restart daemon changed" }))
if (path.resolve(inherited.directory) !== path.resolve(root, id))
yield* migrate(inherited.directory, path.join(root, id))
yield* claim(found, inherited.ticket)
}).pipe(
Effect.catchCause((cause) =>
Effect.gen(function* () {
if (Cause.hasInterruptsOnly(cause)) return yield* Effect.failCause(cause)
yield* Effect.logWarning("Discarding PTY restart handoff; persistent terminals will start fresh", {
cause: Cause.pretty(cause),
})
if (found) yield* oneShot(found, { op: "shutdown" }).pipe(Effect.ignore)
abandon()
}),
),
)
if (inherited.expiresAt <= Date.now())
return yield* Effect.fail(new DaemonError({ kind: "registration", message: "PTY restart handoff expired" }))
const current = yield* discover()
if (current.instance_id !== inherited.instanceID)
return yield* Effect.fail(new DaemonError({ kind: "registration", message: "PTY restart daemon changed" }))
yield* claim(current, inherited.ticket)
}
return { request, requestIfRunning, shutdown, handoff, subscribe } satisfies DaemonTransport
})
// Older releases kept registrations in purgeable temp directories. Renaming keeps the lock file's
// inode, so the still-running daemon keeps holding its lock in the new location.
const migrate = Effect.fn("PersistentPty.daemon.migrate")(function* (directory: string, target: string) {
yield* Effect.tryPromise({
try: async () => {
await mkdir(target, { recursive: true, mode: 0o700 })
await rename(path.join(directory, "service.lock"), path.join(target, "service.lock"))
await rename(path.join(directory, "service.json"), path.join(target, "service.json"))
},
catch: (cause) => failure("registration", cause),
})
})
async function openOwner(registration: Registration, ticket: string | undefined, signal: AbortSignal) {
const socket = net.createConnection({ path: registration.socket })
const frames = decoder(socket)
+15 -7
View File
@@ -1,5 +1,6 @@
export * as PersistentPty from "./index.js"
import os from "node:os"
import path from "node:path"
import { Context, Effect, Layer, Schema } from "effect"
import { makeGlobalNode } from "@opencode/util/effect/app-node"
@@ -23,11 +24,7 @@ export type { Role, StreamEvent } from "./daemon.js"
export { Handoff } from "@opencode/schema/persistent-pty"
export { available } from "#persistent-pty-binary"
export const Options = Schema.Struct({
handoff: Schema.optional(Handoff),
/** Parent of per-server daemon runtime directories; defaults to `<state>/pty`. */
root: Schema.optional(Schema.String),
})
export const Options = Schema.Struct({ handoff: Schema.optional(Handoff) })
export type Options = typeof Options.Type
export type Info = PersistentPty.Info
@@ -126,8 +123,7 @@ const makeLayer = (options: Options = {}) =>
const runFork = Effect.runForkWith(context)
let binary: Promise<string> | undefined
const daemon = yield* makeDaemonTransport(
options.root ?? path.join(global.state, "pty"),
crypto.randomUUID(),
options.handoff?.directory ?? runtimeDirectory(),
() =>
(binary ??= resolveBinary(global.bin).catch((error) => {
binary = undefined
@@ -387,6 +383,18 @@ const unexpected = (response: WireResponse) =>
const unavailable = (error: unknown) =>
new UnavailableError({ message: error instanceof Error ? error.message : String(error) })
const runtimeDirectory = () => {
const root =
process.env.OPENCODE_PTY_RUNTIME_DIR ??
(process.env.XDG_RUNTIME_DIR
? path.join(process.env.XDG_RUNTIME_DIR, "opencode-pty")
: path.join(
os.tmpdir(),
`opencode-pty-${typeof process.getuid === "function" ? process.getuid() : process.env.USER || "unknown"}`,
))
return path.join(root, crypto.randomUUID())
}
function toInfo(value: WireTerminal): Info {
const status = value.lifecycle.status
return {
+4 -16
View File
@@ -8,7 +8,6 @@ import { and, desc, eq } from "drizzle-orm"
import { Project } from "./project.js"
import { Model } from "@opencode/schema/model"
import { Location } from "./location.js"
import type { FileSystem } from "./filesystem.js"
import { SessionMessage } from "./session/message.js"
import { PromptInput } from "@opencode/schema/prompt-input"
import { Bus } from "./bus.js"
@@ -146,10 +145,7 @@ export interface Interface {
readonly from?: SessionMessage.ID
readonly to?: SessionMessage.ID
readonly context?: number
}) => Effect.Effect<
readonly FileDiff.Info[],
NotFoundError | MessageNotFoundError | TurnRangeError | Snapshot.Error | FileSystem.DirectoryNotFoundError
>
}) => Effect.Effect<readonly FileDiff.Info[], NotFoundError | MessageNotFoundError | TurnRangeError | Snapshot.Error>
/**
* Durable admitted session work not yet visible in projected history,
* ordered by admission. Includes unpromoted user and synthetic inputs and
@@ -199,10 +195,7 @@ export interface Interface {
agents?: PromptInput.Prompt["agents"]
skills?: PromptInput.Prompt["skills"]
delivery?: SessionInbox.Delivery
}) => Effect.Effect<
void,
NotFoundError | Command.NotFoundError | Command.ExecutionError | FileSystem.DirectoryNotFoundError
>
}) => Effect.Effect<void, NotFoundError | Command.NotFoundError | Command.ExecutionError>
readonly shell: (
input: Parameters<Session.Handle["shell"]>[0] & { sessionID: SessionSchema.ID },
) => ReturnType<Session.Handle["shell"]>
@@ -225,13 +218,8 @@ export interface Interface {
sessionID: SessionSchema.ID
messageID: SessionMessage.ID
files?: boolean
}) => Effect.Effect<
SessionSchema.Revert,
NotFoundError | MessageNotFoundError | BusyError | Snapshot.Error | FileSystem.DirectoryNotFoundError
>
readonly clear: (
sessionID: SessionSchema.ID,
) => Effect.Effect<void, NotFoundError | BusyError | Snapshot.Error | FileSystem.DirectoryNotFoundError>
}) => Effect.Effect<SessionSchema.Revert, NotFoundError | MessageNotFoundError | BusyError | Snapshot.Error>
readonly clear: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | BusyError | Snapshot.Error>
readonly commit: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | BusyError>
}
}
+1 -7
View File
@@ -1,6 +1,5 @@
export * as SessionGenerate from "./generate.js"
import type { FileSystem } from "../filesystem.js"
import { LLMClient, Message, type AIError } from "@opencode/ai"
import { Effect } from "effect"
import { Database } from "../database/database.js"
@@ -15,12 +14,7 @@ import { SessionModelRequest } from "./model-request.js"
import type { SessionRunnerModel } from "./runner/model.js"
import type { SessionSchema } from "./schema.js"
export type Error =
| AgentNotFoundError
| Instructions.InitializationBlocked
| SessionRunnerModel.Error
| AIError
| FileSystem.DirectoryNotFoundError
export type Error = AgentNotFoundError | Instructions.InitializationBlocked | SessionRunnerModel.Error | AIError
/** Generates text from current Session context without mutating the Session. */
export const generate = Effect.fn("SessionGenerate.generate")(function* (input: {
+2 -7
View File
@@ -91,8 +91,7 @@ const layer = Layer.effect(
yield* locations.contextEffect(destination.location).pipe(
Effect.scoped,
Effect.catchCause((cause) => {
if (Cause.hasInterruptsOnly(cause))
return Effect.failCause(Cause.fromReasons<never>(cause.reasons.filter(Cause.isInterruptReason)))
if (Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause)
return Effect.logWarning("session move destination unavailable", { directory, cause }).pipe(
Effect.andThen(Effect.fail(new DestinationUnavailableError({ directory }))),
)
@@ -107,11 +106,7 @@ const layer = Layer.effect(
return yield* SessionRunner.Service.pipe(
instances.provide(session),
Effect.as(false),
Effect.catchCause((cause) =>
Cause.hasInterrupts(cause)
? Effect.failCause(Cause.fromReasons<never>(cause.reasons.filter(Cause.isInterruptReason)))
: Effect.succeed(true),
),
Effect.catchCause((cause) => (Cause.hasInterrupts(cause) ? Effect.failCause(cause) : Effect.succeed(true))),
)
})
@@ -1,6 +1,5 @@
export * as SessionRunner from "./index.js"
import type { FileSystem } from "../../filesystem.js"
import type { AIError } from "@opencode/ai"
import { Context, Data, Effect } from "effect"
import { SessionSchema } from "../schema.js"
@@ -16,7 +15,6 @@ export type RunError =
| AgentNotFoundError
| StepFailedError
| Instructions.InitializationBlocked
| FileSystem.DirectoryNotFoundError
export type Continuation = { readonly step: number }
-2
View File
@@ -12,7 +12,6 @@ import { Slug } from "./util/slug.js"
import { Bus } from "./bus.js"
import { Database } from "./database/database.js"
import { Location } from "./location.js"
import type { FileSystem } from "./filesystem.js"
import { LocationServiceMap } from "./location-service-map.js"
import { Project } from "./project.js"
import { Worktree } from "@opencode/schema/worktree"
@@ -68,7 +67,6 @@ export class StrategyUnavailableError extends Schema.TaggedError<StrategyUnavail
) {}
export type Error =
| FileSystem.DirectoryNotFoundError
| Project.NotFoundError
| SourceDirectoryNotFoundError
| DestinationExistsError
@@ -1,4 +1,3 @@
import type { FileSystem } from "@opencode/core/filesystem"
import { Bus } from "@opencode/core/bus"
import { Image } from "@opencode/core/image"
import { LocationServiceMap } from "@opencode/core/location-service-map"
@@ -25,7 +24,7 @@ export const promptLocationNode = makeGlobalNode({
Bus.node.replace(Layer.succeed(Bus.Service, bus)),
Plugin.node.replace(Layer.mock(Plugin.Service, { awaitActivation: Effect.void })),
],
}) as unknown as Layer.Layer<LocationServices, FileSystem.DirectoryNotFoundError>,
}) as Layer.Layer<LocationServices>,
)
}),
),
+1 -2
View File
@@ -1,4 +1,3 @@
import type { FileSystem } from "@opencode/core/filesystem"
import { describe, expect } from "bun:test"
import { Context, Deferred, Duration, Effect, Fiber, Layer, LayerMap, RcMap, Schema } from "effect"
import { TestClock } from "effect/testing"
@@ -66,7 +65,7 @@ const locations = Layer.effect(
Layer.provideMerge(Form.layer),
Layer.provide(Layer.succeed(Bus.Service, bus)),
Layer.fresh,
) as unknown as Layer.Layer<LocationServices, FileSystem.DirectoryNotFoundError>,
) as unknown as Layer.Layer<LocationServices>,
{ idleTimeToLive: Duration.infinity },
)
return {
+1 -2
View File
@@ -1,4 +1,3 @@
import type { FileSystem } from "@opencode/core/filesystem"
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
@@ -68,7 +67,7 @@ const activityLocations = Layer.effect(
workspaceID: ref.workspaceID,
project: { id: Project.ID.global, directory: ref.directory, canonical: ref.directory },
}),
) as unknown as Layer.Layer<LocationServices, FileSystem.DirectoryNotFoundError>,
) as unknown as Layer.Layer<LocationServices>,
{ idleTimeToLive: Duration.infinity },
),
)
@@ -1,7 +1,6 @@
import { expect } from "bun:test"
import { spawn } from "node:child_process"
import { existsSync } from "node:fs"
import { mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises"
import { mkdtemp, rm, writeFile } from "node:fs/promises"
import net from "node:net"
import os from "node:os"
import path from "node:path"
@@ -13,7 +12,7 @@ const pong = { type: "pong", instance_id: "test", pid: process.pid, protocol: 7
it.live("rediscovers a same-protocol daemon after its registration rotates", () =>
Effect.gen(function* () {
const { root, directory } = yield* temporaryRuntime()
const directory = yield* temporaryDirectory()
const socketPath = path.join(directory, "daemon.sock")
let token = "old-token"
let instance = "old-instance"
@@ -25,7 +24,7 @@ it.live("rediscovers a same-protocol daemon after its registration rotates", ()
return request.op === "list" ? { type: "terminals", terminals: [] } : { type: "ok" }
})
yield* Effect.promise(() => writeRegistration(directory, socketPath, instance, token))
const daemon = yield* makeDaemonTransport(root, "daemon")
const daemon = yield* makeDaemonTransport(directory)
yield* daemon.request({ op: "list" })
token = "new-token"
@@ -45,7 +44,7 @@ it.live("rediscovers a same-protocol daemon after its registration rotates", ()
it.live("rediscovers a rotated registration when acquiring a subscription", () =>
Effect.gen(function* () {
const { root, directory } = yield* temporaryRuntime()
const directory = yield* temporaryDirectory()
const socketPath = path.join(directory, "daemon.sock")
let token = "old-token"
let instance = "old-instance"
@@ -68,7 +67,7 @@ it.live("rediscovers a rotated registration when acquiring a subscription", () =
}
})
yield* Effect.promise(() => writeRegistration(directory, socketPath, instance, token))
const daemon = yield* makeDaemonTransport(root, "daemon")
const daemon = yield* makeDaemonTransport(directory)
yield* daemon.request({ op: "list" })
token = "new-token"
@@ -90,7 +89,7 @@ it.live("rediscovers a rotated registration when acquiring a subscription", () =
it.live("retries a start-required request when connection fails before dispatch", () =>
Effect.gen(function* () {
const { root, directory } = yield* temporaryRuntime()
const directory = yield* temporaryDirectory()
const firstSocket = path.join(directory, "first.sock")
const secondSocket = path.join(directory, "second.sock")
const first = yield* listen(firstSocket, (_socket, request) => {
@@ -98,7 +97,7 @@ it.live("retries a start-required request when connection fails before dispatch"
return { type: "terminals", terminals: [] }
})
yield* Effect.promise(() => writeRegistration(directory, firstSocket))
const daemon = yield* makeDaemonTransport(root, "daemon")
const daemon = yield* makeDaemonTransport(directory)
yield* daemon.request({ op: "list" })
yield* Effect.promise(first.close)
@@ -116,7 +115,7 @@ it.live("retries a start-required request when connection fails before dispatch"
it.live("does not replay a dispatched mutating request when its response is lost", () =>
Effect.gen(function* () {
const { root, directory } = yield* temporaryRuntime()
const directory = yield* temporaryDirectory()
const socketPath = path.join(directory, "daemon.sock")
let creates = 0
yield* listen(socketPath, (socket, request) => {
@@ -126,7 +125,7 @@ it.live("does not replay a dispatched mutating request when its response is lost
return undefined
})
yield* Effect.promise(() => writeRegistration(directory, socketPath))
const daemon = yield* makeDaemonTransport(root, "daemon")
const daemon = yield* makeDaemonTransport(directory)
const error = yield* Effect.flip(daemon.request({ op: "create" }, true))
expect(error.kind).toBe("response")
@@ -136,7 +135,7 @@ it.live("does not replay a dispatched mutating request when its response is lost
it.live("rejects incompatible daemons without replacing or killing them", () =>
Effect.gen(function* () {
const { root, directory } = yield* temporaryRuntime()
const directory = yield* temporaryDirectory()
const existing = yield* Effect.acquireRelease(
Effect.sync(() => spawn("sleep", ["30"])),
(child) =>
@@ -154,7 +153,7 @@ it.live("rejects incompatible daemons without replacing or killing them", () =>
JSON.stringify({ instance_id: "old", pid: existing.pid, protocol: 6, socket: "/unused", token: "old" }),
),
)
const daemon = yield* makeDaemonTransport(root, "daemon", () => Promise.resolve("/missing/opencode-pty"))
const daemon = yield* makeDaemonTransport(directory, () => Promise.resolve("/missing/opencode-pty"))
const optional = yield* Effect.flip(daemon.requestIfRunning({ op: "list" }))
expect(optional).toMatchObject({
@@ -171,84 +170,6 @@ it.live("rejects incompatible daemons without replacing or killing them", () =>
}),
)
it.live("moves an inherited legacy registration into the runtime root and keeps its lock file", () =>
Effect.gen(function* () {
const base = yield* temporaryDirectory()
const legacy = path.join(base, "legacy", "inherited")
const socketPath = path.join(base, "daemon.sock")
yield* listen(socketPath, (_socket, request) => {
if (request.op === "ping") return pong
return { type: "terminals", terminals: [] }
})
yield* Effect.promise(async () => {
await mkdir(legacy, { recursive: true })
await writeFile(path.join(legacy, "service.lock"), "")
await writeRegistration(legacy, socketPath)
})
const lock = yield* Effect.promise(() => stat(path.join(legacy, "service.lock")))
const daemon = yield* makeDaemonTransport(path.join(base, "root"), "fresh", undefined, {
directory: legacy,
instanceID: "test",
ticket: "ticket",
expiresAt: Date.now() + 60_000,
})
const migrated = path.join(base, "root", "inherited")
expect(existsSync(path.join(legacy, "service.json"))).toBeFalse()
expect((yield* Effect.promise(() => stat(path.join(migrated, "service.lock")))).ino).toBe(lock.ino)
expect(existsSync(path.join(migrated, "service.json"))).toBeTrue()
expect(yield* daemon.request({ op: "list" })).toEqual({ type: "terminals", terminals: [] })
}),
)
it.live("starts fresh instead of failing when an inherited registration is gone or expired", () =>
Effect.gen(function* () {
const base = yield* temporaryDirectory()
const legacy = path.join(base, "legacy")
yield* Effect.promise(() => mkdir(legacy))
for (const expiresAt of [Date.now() + 60_000, Date.now() - 1]) {
const daemon = yield* makeDaemonTransport(path.join(base, "root"), "fresh", undefined, {
directory: legacy,
instanceID: "test",
ticket: "ticket",
expiresAt,
})
expect(yield* daemon.handoff).toBeNull()
expect(yield* daemon.requestIfRunning({ op: "list" })).toBeUndefined()
}
}),
)
it.live("stops a discovered inherited daemon when adoption fails", () =>
Effect.gen(function* () {
const { root, directory } = yield* temporaryRuntime()
const socketPath = path.join(directory, "daemon.sock")
let shutdowns = 0
yield* listen(socketPath, (_socket, request) => {
if (request.op === "ping") return pong
if (request.op === "shutdown") shutdowns++
return { type: "ok" }
})
yield* Effect.promise(() => writeRegistration(directory, socketPath))
yield* makeDaemonTransport(root, "fresh", undefined, {
directory,
instanceID: "replaced",
ticket: "ticket",
expiresAt: Date.now() + 60_000,
})
expect(shutdowns).toBe(1)
}),
)
function temporaryRuntime() {
return Effect.gen(function* () {
const root = yield* temporaryDirectory()
const directory = path.join(root, "daemon")
yield* Effect.promise(() => mkdir(directory))
return { root, directory }
})
}
function temporaryDirectory() {
return Effect.acquireRelease(
Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "opencode-pty-test-"))),
+1 -2
View File
@@ -1,4 +1,3 @@
import type { FileSystem } from "@opencode/core/filesystem"
import { describe, expect } from "bun:test"
import { LLMClient, LLMEvent, LanguageModel, type LLMRequest } from "@opencode/ai"
import { OpenAIChat } from "@opencode/ai/protocols"
@@ -59,7 +58,7 @@ const locations = Layer.effect(
Layer.provide(client),
Layer.provide(config),
Layer.provide(models),
) as unknown as Layer.Layer<LocationServices, FileSystem.DirectoryNotFoundError>,
) as unknown as Layer.Layer<LocationServices>,
),
)
const it = testEffect(
+1 -2
View File
@@ -1,4 +1,3 @@
import type { FileSystem } from "@opencode/core/filesystem"
import { describe, expect, test } from "bun:test"
import { AIError, TransportError } from "@opencode/ai"
import { Database } from "@opencode/core/database/database"
@@ -1372,7 +1371,7 @@ function buildExecution(
() =>
// The local execution test only needs the Session runner from the Location graph.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
runner as unknown as Layer.Layer<LocationServices, FileSystem.DirectoryNotFoundError>,
runner as unknown as Layer.Layer<LocationServices>,
),
)
return yield* Layer.buildWithScope(
+2 -7
View File
@@ -1,4 +1,3 @@
import type { FileSystem } from "@opencode/core/filesystem"
import { describe, expect } from "bun:test"
import path from "path"
import { chmod, mkdir, readdir, rm } from "fs/promises"
@@ -59,7 +58,7 @@ const itWithActiveExecution = testEffect(
replacements: [Project.node.replace(globalProjectNode), offlineModels],
}),
Layer.succeed(SessionRunner.Service, { drain: () => Effect.never }),
) as unknown as Layer.Layer<LocationServices, FileSystem.DirectoryNotFoundError>,
) as unknown as Layer.Layer<LocationServices>,
),
),
),
@@ -69,11 +68,7 @@ const itWithActiveExecution = testEffect(
const unavailableLocations = Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
() =>
Layer.effectDiscard(Effect.die(new Error("broken location"))) as unknown as Layer.Layer<
LocationServices,
FileSystem.DirectoryNotFoundError
>,
() => Layer.effectDiscard(Effect.fail(new Error("broken location"))) as unknown as Layer.Layer<LocationServices>,
),
)
const itWithUnavailableDestination = testEffect(
+1 -2
View File
@@ -1,5 +1,4 @@
import { describe, expect } from "bun:test"
import type { FileSystem } from "@opencode/core/filesystem"
import { DateTime, Effect, Fiber, Layer, LayerMap, Schema, Stream } from "effect"
import path from "path"
import { pathToFileURL } from "url"
@@ -84,7 +83,7 @@ const locations = makeGlobalNode({
restore: () => Effect.void,
}),
Layer.mock(Plugin.Service, { awaitActivation: Effect.void }),
).pipe(Layer.fresh) as unknown as Layer.Layer<LocationServices, FileSystem.DirectoryNotFoundError>,
).pipe(Layer.fresh) as unknown as Layer.Layer<LocationServices>,
)
}),
),
+1 -2
View File
@@ -1,4 +1,3 @@
import type { FileSystem } from "@opencode/core/filesystem"
import path from "path"
import { describe, expect } from "bun:test"
import { Effect, Layer, LayerMap } from "effect"
@@ -50,7 +49,7 @@ const locations = makeGlobalNode({
list: () => Effect.succeed([info]),
}),
Layer.mock(Plugin.Service, { awaitActivation: Effect.void }),
) as unknown as Layer.Layer<LocationServices, FileSystem.DirectoryNotFoundError>,
) as unknown as Layer.Layer<LocationServices>,
),
),
deps: [],
+1 -1
View File
@@ -27,7 +27,7 @@ async function signWindows(configuration: { path: string }) {
await execFileAsync(
"pwsh",
["-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", signScript, configuration.path],
{ cwd: rootDir },
{ cwd: rootDir, windowsHide: true },
)
}
+1 -1
View File
@@ -35,7 +35,7 @@ const checkMacosApp = Effect.fn("DesktopFiles.checkMacosApp")(function* (appName
const resolveWindowsAppPath = Effect.fn("DesktopFiles.resolveWindowsAppPath")(function* (appName: string) {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const result = yield* Effect.tryPromise(() => execFilePromise("where", [appName])).pipe(
const result = yield* Effect.tryPromise(() => execFilePromise("where", [appName], { windowsHide: true })).pipe(
Effect.orElseSucceed(() => undefined),
)
if (!result) return null
+1 -6
View File
@@ -438,12 +438,7 @@ export interface KeymapActive {
}
export interface Keymap {
/**
* Creates a reactive keymap layer. It is removed when the plugin deactivates, or earlier when the component or reactive
* computation that created it is disposed. The factory must be pure. It runs once untracked for initial command-shape
* validation, then reactively. Initial command-shape errors throw synchronously. OpenTUI handles binding-parse errors.
* Errors from later reactive updates follow Solid's error handling.
*/
/** Creates a reactive keymap layer owned by the calling component. */
layer(input: () => KeymapLayer): void
/** Dispatches a reachable command by ID. */
dispatch(id: string, input?: string): void
+2 -3
View File
@@ -1,4 +1,4 @@
import { LocationNotFoundError, InvalidRequestError, SessionNotFoundError } from "./errors.js"
import { InvalidRequestError, SessionNotFoundError } from "./errors.js"
import { makeDefaultApi } from "./api.js"
import type { Api } from "./api.js"
import type { Context } from "effect"
@@ -7,12 +7,11 @@ import type { EventGroup } from "./groups/event.js"
class LocationMiddleware extends HttpApiMiddleware.Service<LocationMiddleware>()(
"@opencode/client/LocationMiddleware",
{ error: [LocationNotFoundError] },
) {}
class SessionLocationMiddleware extends HttpApiMiddleware.Service<SessionLocationMiddleware>()(
"@opencode/client/SessionLocationMiddleware",
{ error: [InvalidRequestError, SessionNotFoundError, LocationNotFoundError] },
{ error: [InvalidRequestError, SessionNotFoundError] },
) {}
type ClientApiShape = Api<
-7
View File
@@ -1,12 +1,5 @@
import { Schema } from "effect"
import { Skill } from "@opencode/schema/skill"
import { Location } from "@opencode/schema/location"
export class LocationNotFoundError extends Schema.TaggedError<LocationNotFoundError>()(
"LocationNotFoundError",
{ location: Location.PublicRef, message: Schema.String },
{ httpApiStatus: 404 },
) {}
export class InvalidRequestError extends Schema.TaggedError<InvalidRequestError>()(
"InvalidRequestError",
+2 -2
View File
@@ -1,7 +1,7 @@
import { Model } from "@opencode/schema/model"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { LocationNotFoundError, InvalidRequestError, ServiceUnavailableError } from "../errors.js"
import { InvalidRequestError, ServiceUnavailableError } from "../errors.js"
export const GenerateGroup = HttpApiGroup.make("server.generate")
.add(
@@ -13,7 +13,7 @@ export const GenerateGroup = HttpApiGroup.make("server.generate")
success: Schema.Struct({
data: Schema.Struct({ text: Schema.String }),
}).annotate({ identifier: "GenerateTextResponse" }),
error: [InvalidRequestError, ServiceUnavailableError, LocationNotFoundError],
error: [InvalidRequestError, ServiceUnavailableError],
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.generate.text",
+2 -2
View File
@@ -6,7 +6,7 @@ import { Project } from "@opencode/schema/project"
import { Session } from "@opencode/schema/session"
import { Context, Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { LocationNotFoundError, PermissionNotFoundError, SessionNotFoundError } from "../errors.js"
import { PermissionNotFoundError, SessionNotFoundError } from "../errors.js"
import { LocationQuery, locationQueryOpenApi } from "./location.js"
export const makePermissionGroup = <
@@ -89,7 +89,7 @@ export const makePermissionGroup = <
HttpApiEndpoint.get("session.permission.list", "/api/session/:sessionID/permission", {
params: { sessionID: Session.ID },
success: Schema.Struct({ data: Schema.Array(Permission.Request) }),
error: [SessionNotFoundError, LocationNotFoundError],
error: SessionNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.permission.list",
+10 -7
View File
@@ -18,7 +18,6 @@ import { Context, Effect, Encoding, Result, Schema, SchemaGetter, Struct } from
import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import {
ConflictError,
LocationNotFoundError,
CommandExecutionError,
CommandNotFoundError,
FormAlreadySettledError,
@@ -171,10 +170,12 @@ export const SessionsQuery = Schema.Struct({
cursor: SessionsQueryCursor.pipe(Schema.optional),
}).annotate({ identifier: "SessionsQuery" })
export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S, FormI extends HttpApiMiddleware.AnyId, FormS>(
sessionLocationMiddleware: Context.Key<I, S>,
formLocationMiddleware: Context.Key<FormI, FormS>,
) =>
export const makeSessionGroup = <
I extends HttpApiMiddleware.AnyId,
S,
FormI extends HttpApiMiddleware.AnyId,
FormS,
>(sessionLocationMiddleware: Context.Key<I, S>, formLocationMiddleware: Context.Key<FormI, FormS>) =>
HttpApiGroup.make("server.session")
.add(
HttpApiEndpoint.get("session.list", "/api/session", {
@@ -561,7 +562,9 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S, FormI ext
error: [SessionNotFoundError, SessionBusyError],
})
.middleware(sessionLocationMiddleware)
.annotateMerge(OpenApi.annotations({ identifier: "session.revert.commit", summary: "Commit staged revert" })),
.annotateMerge(
OpenApi.annotations({ identifier: "session.revert.commit", summary: "Commit staged revert" }),
),
)
.add(
HttpApiEndpoint.get("session.context", "/api/session/:sessionID/context", {
@@ -591,7 +594,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S, FormI ext
}),
}),
success: Schema.Struct({ data: Schema.Array(FileDiff.Info) }),
error: [InvalidRequestError, MessageNotFoundError, SessionNotFoundError, UnknownError, LocationNotFoundError],
error: [InvalidRequestError, MessageNotFoundError, SessionNotFoundError, UnknownError],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.diff",
+1 -5
View File
@@ -57,11 +57,7 @@ export function layer(options: Options, replacements: () => LayerNode.Replacemen
...LocationServiceMap.canonical(session.location),
})
const provide = (session: Session.Info) => Effect.provide(instances.get(key(session)))
const instances: LayerMap.LayerMap<
ReturnType<typeof key>,
Instance.Services,
Instance.Error
> = yield* LayerMap.make(
const instances: LayerMap.LayerMap<ReturnType<typeof key>, Instance.Services> = yield* LayerMap.make(
(input: ReturnType<typeof key>) =>
Layer.unwrap(
Effect.gen(function* () {
+13 -18
View File
@@ -7,7 +7,6 @@ import { Global } from "@opencode/util/global"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
import { locationErrors } from "../location"
export const GenerateHandler = HttpApiBuilder.group(Api, "server.generate", (handlers) =>
Effect.gen(function* () {
@@ -16,23 +15,19 @@ export const GenerateHandler = HttpApiBuilder.group(Api, "server.generate", (han
const services = locations.get(Location.Ref.make({ directory: AbsolutePath.make(global.config) }))
return handlers.handle(
"generate.text",
Effect.fn("server.generate.text")(
function* (request) {
const generate = yield* Generate.Service
const text = yield* generate
.text(request.payload)
.pipe(
Effect.mapError((error) =>
error._tag === "Generate.ModelSelectionError"
? new InvalidRequestError({ message: error.message })
: new ServiceUnavailableError({ message: error.message, service: error.service }),
),
)
return { data: { text } }
},
Effect.provide(services),
locationErrors,
),
Effect.fn("server.generate.text")(function* (request) {
const generate = yield* Generate.Service
const text = yield* generate
.text(request.payload)
.pipe(
Effect.mapError((error) =>
error._tag === "Generate.ModelSelectionError"
? new InvalidRequestError({ message: error.message })
: new ServiceUnavailableError({ message: error.message, service: error.service }),
),
)
return { data: { text } }
}, Effect.provide(services)),
)
}),
)
+1 -1
View File
@@ -24,7 +24,7 @@ export const LocationHandler = HttpApiBuilder.group(Api, "server.location", (han
Effect.provideService(LocationServiceMap.Service, locations),
Effect.catchCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.failCause(Cause.fromReasons<never>(cause.reasons.filter(Cause.isInterruptReason)))
? Effect.failCause(cause)
: Effect.fail(new ServiceUnavailableError({ message: Cause.pretty(cause), service: "location" })),
),
),
+2 -2
View File
@@ -7,7 +7,7 @@ import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
import { PermissionNotFoundError } from "@opencode/protocol/errors"
import { locationErrors, response, sessionInfo } from "../location"
import { response, sessionInfo } from "../location"
import { missingSession } from "./session-error"
function missingRequest(id: Permission.ID) {
@@ -62,7 +62,7 @@ export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission",
const session = yield* sessionInfo(sessions, ctx.params.sessionID)
const requests = yield* Permission.Service.use((permission) =>
permission.forSession(ctx.params.sessionID),
).pipe(instances.provide(session), locationErrors)
).pipe(instances.provide(session))
return { data: requests }
}),
)
-7
View File
@@ -23,7 +23,6 @@ import {
SkillNotFoundError,
} from "@opencode/protocol/errors"
import { AbsolutePath } from "@opencode/core/schema"
import { locationErrors } from "../location"
import { failedMessageDecode, failedSnapshot, missingMessage, missingSession } from "./session-error"
const DefaultSessionsLimit = 50
@@ -336,7 +335,6 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
Effect.catchTag("Session.SkillNotFoundError", (error) =>
Effect.fail(new InvalidRequestError({ message: `Skill not found: ${error.skill}`, field: "skills" })),
),
locationErrors,
),
}
}),
@@ -372,7 +370,6 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
}),
),
),
locationErrors,
)
return HttpApiSchema.NoContent.make()
}),
@@ -391,7 +388,6 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
Effect.catchTag("Session.SkillNotFoundError", (error) =>
Effect.fail(new SkillNotFoundError({ skill: error.skill, message: `Skill not found: ${error.skill}` })),
),
locationErrors,
)
return HttpApiSchema.NoContent.make()
}),
@@ -475,7 +471,6 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
Effect.catchTag("Session.MessageNotFoundError", missingMessage),
Effect.catchTag("Session.BusyError", busySession),
Effect.catchTag("Snapshot.Error", failedSnapshot("stage session revert", ctx.params.sessionID)),
locationErrors,
),
}
}),
@@ -490,7 +485,6 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag("Session.BusyError", busySession),
Effect.catchTag("Snapshot.Error", failedSnapshot("clear session revert", ctx.params.sessionID)),
locationErrors,
)
return HttpApiSchema.NoContent.make()
}),
@@ -533,7 +527,6 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
(error) => new InvalidRequestError({ message: error.message, field: error.field }),
),
Effect.catchTag("Snapshot.Error", failedSnapshot("diff session turn", ctx.params.sessionID)),
locationErrors,
),
}
}),
+2 -20
View File
@@ -1,9 +1,8 @@
import { FileSystem } from "@opencode/core/filesystem"
import { Location } from "@opencode/core/location"
import { LocationServiceMap } from "@opencode/core/location-services"
import { AbsolutePath } from "@opencode/core/schema"
import { Session } from "@opencode/core/session"
import { LocationNotFoundError, InvalidRequestError } from "@opencode/protocol/errors"
import { InvalidRequestError } from "@opencode/protocol/errors"
import { Effect, Layer, Schema } from "effect"
import { HttpServerRequest } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
@@ -13,25 +12,8 @@ export type LocationServices = Layer.Success<ReturnType<(typeof LocationServiceM
export class LocationMiddleware extends HttpApiMiddleware.Service<LocationMiddleware, { provides: LocationServices }>()(
"@opencode/HttpApiLocation",
{ error: [LocationNotFoundError] },
) {}
export function locationErrors<A, E, R>(effect: Effect.Effect<A, E, R>) {
return effect.pipe(
Effect.catchIf(
(error): error is Extract<E, FileSystem.DirectoryNotFoundError> =>
error instanceof FileSystem.DirectoryNotFoundError,
(error) =>
Effect.fail(
new LocationNotFoundError({
location: { directory: error.directory },
message: `Location not found: ${error.directory}`,
}),
),
),
)
}
export function response<A, E, R>(data: Effect.Effect<A, E, R>) {
return Effect.gen(function* () {
const location = yield* Location.Service
@@ -79,7 +61,7 @@ export const layer = Layer.effect(
return LocationMiddleware.of((effect) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
return yield* effect.pipe(Effect.provide(locations.get(requestRef(request))), locationErrors)
return yield* effect.pipe(Effect.provide(locations.get(requestRef(request))))
}),
)
}),
@@ -1,17 +1,17 @@
import { Instance } from "@opencode/core/instance/service"
import { LocationServiceMap } from "@opencode/core/location-services"
import { Session } from "@opencode/core/session"
import { LocationNotFoundError, InvalidRequestError, SessionNotFoundError } from "@opencode/protocol/errors"
import { InvalidRequestError, SessionNotFoundError } from "@opencode/protocol/errors"
import { Effect, Layer } from "effect"
import { HttpRouter, HttpServerRequest } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
import { locationErrors, requestRef, sessionInfo, type LocationServices } from "../location"
import { requestRef, sessionInfo, type LocationServices } from "../location"
export class FormLocationMiddleware extends HttpApiMiddleware.Service<
FormLocationMiddleware,
{ provides: LocationServices }
>()("@opencode/HttpApiFormLocation", {
error: [InvalidRequestError, SessionNotFoundError, LocationNotFoundError],
error: [InvalidRequestError, SessionNotFoundError],
}) {}
export const formLocationLayer = Layer.effect(
@@ -29,11 +29,11 @@ export const formLocationLayer = Layer.effect(
// the session row lookup because some MCP elicitations cannot currently be attributed to
// a real session. Keep this undocumented and remove once elicitations carry session ownership.
const request = yield* HttpServerRequest.HttpServerRequest
return yield* effect.pipe(Effect.provide(locations.get(requestRef(request))), locationErrors)
return yield* effect.pipe(Effect.provide(locations.get(requestRef(request))))
}
const session = yield* sessionInfo(sessions, route.params.sessionID)
return yield* effect.pipe(instances.provide(session), locationErrors)
return yield* effect.pipe(instances.provide(session))
}),
)
}),
@@ -3,14 +3,14 @@ import { Session } from "@opencode/core/session"
import { Effect, Layer } from "effect"
import { HttpRouter } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
import { LocationNotFoundError, InvalidRequestError, SessionNotFoundError } from "@opencode/protocol/errors"
import { locationErrors, sessionInfo, type LocationServices } from "../location"
import { InvalidRequestError, SessionNotFoundError } from "@opencode/protocol/errors"
import { sessionInfo, type LocationServices } from "../location"
export class SessionLocationMiddleware extends HttpApiMiddleware.Service<
SessionLocationMiddleware,
{ provides: LocationServices }
>()("@opencode/HttpApiSessionLocation", {
error: [InvalidRequestError, SessionNotFoundError, LocationNotFoundError],
error: [InvalidRequestError, SessionNotFoundError],
}) {}
export const sessionLocationLayer = Layer.effect(
@@ -23,7 +23,7 @@ export const sessionLocationLayer = Layer.effect(
Effect.gen(function* () {
const route = yield* HttpRouter.RouteContext
const session = yield* sessionInfo(sessions, route.params.sessionID)
return yield* effect.pipe(instances.provide(session), locationErrors)
return yield* effect.pipe(instances.provide(session))
}),
)
}),
+1 -58
View File
@@ -6,7 +6,6 @@ import { Agent } from "@opencode/schema/agent"
import { Integration } from "@opencode/schema/integration"
import { ServerInfo } from "@opencode/protocol/groups/server"
import { Effect, Schedule, Schema } from "effect"
import { Session } from "@opencode/schema/session"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { ServerFetch } from "../src/fetch"
@@ -19,60 +18,6 @@ const options = {
fs: { filewatcher: false },
} as const
it.live("returns LocationNotFoundError for a missing folder and recovers once it exists", () =>
Effect.gen(function* () {
const config = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-directory-errors-")))
const directory = path.join(config.path, "project")
const handler = yield* ServerFetch.make({ ...options, config: { directory: config.path } })
// Session creation only resolves its placement; it does not boot the Location graph.
const created = yield* Effect.promise(() =>
handler(
new Request("http://opencode.local/api/session", {
method: "POST",
headers: { "content-type": "application/json", "x-opencode-directory": encodeURIComponent(directory) },
body: JSON.stringify({ location: { directory } }),
}),
),
)
expect(created.status).toBe(200)
const session = Schema.decodeUnknownSync(Schema.Struct({ data: Session.Info }))(
yield* Effect.promise(() => created.json()),
).data
const endpoints = [
"/api/model",
"/api/integration",
`/api/session/${session.id}/permission`,
`/api/experimental/session/${session.id}/instructions/entries`,
`/api/session/${session.id}/form`,
"/api/session/global/form",
]
for (const endpoint of endpoints) {
const response = yield* Effect.promise(() =>
handler(
new Request(`http://opencode.local${endpoint}`, {
headers: { "x-opencode-directory": encodeURIComponent(directory) },
}),
),
)
expect({ endpoint, status: response.status }).toEqual({ endpoint, status: 404 })
expect(yield* Effect.promise(() => response.json())).toEqual({
_tag: "LocationNotFoundError",
location: { directory },
message: `Location not found: ${directory}`,
})
}
yield* Effect.promise(() => fs.mkdir(directory))
const recovered = yield* Effect.promise(() =>
handler(
new Request("http://opencode.local/api/model", {
headers: { "x-opencode-directory": encodeURIComponent(directory) },
}),
),
)
expect(recovered.status).toBe(200)
}),
)
type Handler = (request: Request) => Promise<Response>
function occupy(port: number, cancel = false) {
@@ -160,9 +105,7 @@ it.live("serves the HttpApi and enforces Basic auth like the Node server", () =>
),
)
expect(response.status).toBe(200)
const body = yield* Effect.promise(() => response.json()).pipe(
Effect.flatMap(Schema.decodeUnknownEffect(ServerInfo)),
)
const body = yield* Effect.promise(() => response.json()).pipe(Effect.flatMap(Schema.decodeUnknownEffect(ServerInfo)))
expect(body.version).toBe("test-version")
expect(body.paths.tmp).toEndWith("opencode")
expect(body.capabilities?.persistentPty).toBe(process.platform !== "win32")
+18 -84
View File
@@ -18,14 +18,13 @@ smoke(
"reads the latest controlled terminal with optional physical line counts through the SDK",
() =>
Effect.gen(function* () {
const fixture = yield* testDirectory()
const fixture = yield* testDirectory("xdg")
const server = yield* ServerProcess.start<never, never>({
hostname: "127.0.0.1",
port: 0,
password: "secret",
app: { version: "test-version" },
database: { path: fixture.database },
pty: { root: fixture.directory },
fs: { filewatcher: false },
})
const base = HttpServer.formatAddress(server.address)
@@ -143,14 +142,13 @@ smoke(
"creates two persistent terminals for one session through the client API",
() =>
Effect.gen(function* () {
const fixture = yield* testDirectory()
const fixture = yield* testDirectory("xdg")
const server = yield* ServerProcess.start<never, never>({
hostname: "127.0.0.1",
port: 0,
password: "secret",
app: { version: "test-version" },
database: { path: fixture.database },
pty: { root: fixture.directory },
fs: { filewatcher: false },
})
const base = HttpServer.formatAddress(server.address)
@@ -317,7 +315,7 @@ smoke(
"isolates servers sharing a database and preserves terminals only through explicit restart handoff",
() =>
Effect.gen(function* () {
const fixture = yield* testDirectory()
const fixture = yield* testDirectory("override")
const scope = yield* Scope.Scope
const originalScope = yield* Scope.fork(scope)
const options = {
@@ -326,7 +324,6 @@ smoke(
password: "secret",
app: { version: "test-version" },
database: { path: fixture.database },
pty: { root: fixture.directory },
fs: { filewatcher: false },
}
const original = yield* ServerProcess.start<never, never>(options).pipe(
@@ -368,7 +365,7 @@ smoke(
expect(process.kill(first.pid, 0)).toBeTrue()
const replacementScope = yield* Scope.fork(scope)
const replacement = yield* ServerProcess.start<never, never>({ ...options, pty: { ...options.pty, handoff } }).pipe(
const replacement = yield* ServerProcess.start<never, never>({ ...options, pty: { handoff } }).pipe(
Effect.provideService(Scope.Scope, replacementScope),
)
const replacementBase = HttpServer.formatAddress(replacement.address)
@@ -388,90 +385,25 @@ smoke(
30_000,
)
smoke(
"migrates a legacy runtime directory on restart and boots fresh when the registration is gone",
() =>
Effect.gen(function* () {
const fixture = yield* testDirectory()
const scope = yield* Scope.Scope
const legacy = path.join(fixture.root, "legacy")
const options = {
hostname: "127.0.0.1",
port: 0,
password: "secret",
app: { version: "test-version" },
database: { path: fixture.database },
fs: { filewatcher: false },
}
const sessionID = Session.ID.make("ses_persistent_pty_migration")
const originalScope = yield* Scope.fork(scope)
const original = yield* ServerProcess.start<never, never>({ ...options, pty: { root: legacy } }).pipe(
Effect.provideService(Scope.Scope, originalScope),
)
const base = HttpServer.formatAddress(original.address)
const first = Schema.decodeUnknownSync(PersistentPty.Info)(
(yield* request(base, "POST", `/api/experimental/session/${sessionID}/terminal`, {
command: "/bin/sh",
args: ["-c", "stty -echo; printf before-migration; exec cat"],
cwd: process.cwd(),
title: "legacy",
env: {},
})).data,
)
expect(yield* waitForText(base, first.id, "before-migration")).toContain("before-migration")
const legacyHandoff = Schema.decodeUnknownSync(PersistentPty.Handoff)(
(yield* request(base, "POST", "/api/experimental/persistent-pty/handoff")).handoff,
)
yield* Scope.close(originalScope, Exit.void)
const migratedScope = yield* Scope.fork(scope)
const migrated = yield* ServerProcess.start<never, never>({
...options,
pty: { root: fixture.directory, handoff: legacyHandoff },
}).pipe(Effect.provideService(Scope.Scope, migratedScope))
const migratedBase = HttpServer.formatAddress(migrated.address)
const directory = path.join(fixture.directory, path.basename(legacyHandoff.directory))
expect(existsSync(path.join(directory, "service.json"))).toBeTrue()
expect(existsSync(path.join(legacyHandoff.directory, "service.json"))).toBeFalse()
expect(
(yield* request(migratedBase, "GET", `/api/experimental/session/${sessionID}/terminal`)).data,
).toMatchObject([{ id: first.id, pid: first.pid }])
const handoff = Schema.decodeUnknownSync(PersistentPty.Handoff)(
(yield* request(migratedBase, "POST", "/api/experimental/persistent-pty/handoff")).handoff,
)
expect(handoff.directory).toBe(directory)
yield* Scope.close(migratedScope, Exit.void)
// Simulate macOS purging the registration while the daemon waits for its successor.
yield* Effect.promise(() => fs.rm(path.join(directory, "service.json")))
const fresh = yield* ServerProcess.start<never, never>({ ...options, pty: { root: fixture.directory, handoff } })
const freshBase = HttpServer.formatAddress(fresh.address)
expect((yield* request(freshBase, "GET", `/api/experimental/session/${sessionID}/terminal`)).data).toEqual([])
const second = Schema.decodeUnknownSync(PersistentPty.Info)(
(yield* request(freshBase, "POST", `/api/experimental/session/${sessionID}/terminal`, {
command: "/bin/sh",
args: ["-c", "printf after-restart; exec cat"],
cwd: process.cwd(),
title: "fresh",
env: {},
})).data,
)
expect(yield* waitForText(freshBase, second.id, "after-restart")).toContain("after-restart")
}),
30_000,
)
function testDirectory() {
function testDirectory(mode: "xdg" | "override") {
return Effect.acquireRelease(
Effect.promise(async () => {
const environment = { binary: process.env.OPENCODE_PTY_BIN, shell: process.env.SHELL }
const environment = {
binary: process.env.OPENCODE_PTY_BIN,
runtime: process.env.OPENCODE_PTY_RUNTIME_DIR,
xdg: process.env.XDG_RUNTIME_DIR,
shell: process.env.SHELL,
}
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-pty-server-test-"))
const runtime = path.join(root, "runtime")
process.env.OPENCODE_PTY_BIN = binary
delete process.env.OPENCODE_PTY_RUNTIME_DIR
process.env.XDG_RUNTIME_DIR = runtime
process.env.SHELL = "/bin/sh"
if (mode === "override") process.env.OPENCODE_PTY_RUNTIME_DIR = runtime
return {
database: path.join(root, "opencode.db"),
directory: path.join(root, "runtime"),
directory: mode === "override" ? runtime : path.join(runtime, "opencode-pty"),
environment,
root,
}
@@ -480,6 +412,8 @@ function testDirectory() {
Effect.promise(async () => {
await fs.rm(fixture.root, { recursive: true, force: true })
restore("OPENCODE_PTY_BIN", fixture.environment.binary)
restore("OPENCODE_PTY_RUNTIME_DIR", fixture.environment.runtime)
restore("XDG_RUNTIME_DIR", fixture.environment.xdg)
restore("SHELL", fixture.environment.shell)
}),
)
+21 -26
View File
@@ -207,7 +207,27 @@ function createLayer(input: () => KeymapLayer) {
useBindings(() => {
const layer = input()
const { commands, bindings, mode, ...options } = layer
const grouped = groupCommands(commands)
const grouped = (commands ?? []).reduce(
(result, command) => {
if (command.id !== undefined) {
if (!command.id) throw new Error("Keymap command IDs cannot be empty")
if (typeof command.bind === "string" && !command.bind)
throw new Error("Keymap command bindings cannot be empty")
result.named.push({ ...command, id: command.id })
return result
}
if (command.palette) throw new Error("Palette commands require an ID")
if (command.slash) throw new Error("Slash commands require an ID")
if (typeof command.bind !== "string") throw new Error("Inline keymap commands require bind")
if (!command.bind) throw new Error("Keymap command bindings cannot be empty")
result.inline.push({ ...command, id: undefined, bind: command.bind })
return result
},
{
named: [] as Array<KeymapCommand & { readonly id: string }>,
inline: [] as Array<KeymapCommand & { readonly id?: undefined; readonly bind: string }>,
},
)
return {
...options,
enabled: enabled() ? options.enabled : false,
@@ -250,30 +270,6 @@ function createLayer(input: () => KeymapLayer) {
})
}
function groupCommands(commands: readonly KeymapCommand[] = []) {
return commands.reduce(
(result, command) => {
if (command.id !== undefined) {
if (!command.id) throw new Error("Keymap command IDs cannot be empty")
if (typeof command.bind === "string" && !command.bind)
throw new Error("Keymap command bindings cannot be empty")
result.named.push({ ...command, id: command.id })
return result
}
if (command.palette) throw new Error("Palette commands require an ID")
if (command.slash) throw new Error("Slash commands require an ID")
if (typeof command.bind !== "string") throw new Error("Inline keymap commands require bind")
if (!command.bind) throw new Error("Keymap command bindings cannot be empty")
result.inline.push({ ...command, id: undefined, bind: command.bind })
return result
},
{
named: [] as Array<KeymapCommand & { readonly id: string }>,
inline: [] as Array<KeymapCommand & { readonly id?: undefined; readonly bind: string }>,
},
)
}
function useShortcuts() {
const value = useValue()
const shortcuts = useKeymapSelector((keymap) => {
@@ -407,7 +403,6 @@ export const Keymap = {
Provider,
use,
createLayer,
validateLayer: (layer: KeymapLayer) => void groupCommands(layer.commands),
useShortcuts,
useShortcut,
useLeaderActive,
+2 -21
View File
@@ -1,5 +1,5 @@
import { PluginContextProvider } from "@opencode/plugin/tui"
import { createRoot, getOwner, onCleanup, runWithOwner, untrack, type JSX } from "solid-js"
import type { JSX } from "solid-js"
import type { Context, Dialog, Page, SlotClaim, SlotMap, SlotPath, Toast } from "@opencode/plugin/tui/context"
import type { Placement, PlacementKind } from "./structure"
import { infoStringToFiletype, type MarkdownCodeBlockRenderer } from "@opentui/core"
@@ -54,7 +54,6 @@ export type Registry = {
// (hooks must run during component setup) and shared by every activation.
export function usePluginHost() {
return {
owner: getOwner(),
renderer: useRenderer(),
client: useClient(),
data: useData(),
@@ -134,12 +133,6 @@ export function createPluginContext(input: {
input.owned.push(async () => unregister())
return unregister
}
let layers: Set<() => void> | undefined = new Set()
input.owned.push(async () => {
const active = layers
layers = undefined
active?.forEach((dispose) => dispose())
})
context = {
options: input.options ?? {},
get location() {
@@ -168,19 +161,7 @@ export function createPluginContext(input: {
},
},
keymap: {
layer(layer) {
const active = layers
if (!active) return
// Validate outside Solid, whose error routing would bypass the caller.
Keymap.validateLayer(untrack(layer))
const caller = getOwner()
createRoot((dispose) => {
active.add(dispose)
onCleanup(() => active.delete(dispose))
if (caller) runWithOwner(caller, () => onCleanup(dispose))
Keymap.createLayer(layer)
}, caller ?? host.owner)
},
layer: Keymap.createLayer,
dispatch: host.keymap.dispatch,
shortcuts: host.shortcuts.list,
commands: host.keymapState.commands,
+18 -6
View File
@@ -806,7 +806,13 @@ export function FormPrompt(props: { form: FormWithLocation }) {
return (
<box
paddingRight={2}
backgroundColor={theme.background.raised.base}
backgroundColor={
isTab()
? theme.background.formfield.selected
: tabHover() === index()
? theme.background.formfield.focused
: theme.background.raised.base
}
onMouseOver={() => setTabHover(index())}
onMouseOut={() => setTabHover(null)}
onMouseUp={() => {
@@ -822,7 +828,13 @@ export function FormPrompt(props: { form: FormWithLocation }) {
}}
</For>
<box
backgroundColor={theme.background.raised.base}
backgroundColor={
confirm()
? theme.background.formfield.selected
: tabHover() === "confirm"
? theme.background.formfield.focused
: theme.background.raised.base
}
onMouseOver={() => setTabHover("confirm")}
onMouseOut={() => setTabHover(null)}
onMouseUp={() => {
@@ -929,7 +941,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
>
<box flexDirection="row">
<box
backgroundColor={theme.background.raised.base}
backgroundColor={active() ? theme.background.formfield.focused : theme.background.raised.base}
paddingRight={1}
>
<text
@@ -937,7 +949,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
>{`${i() + 1}.`}</text>
</box>
<box
backgroundColor={theme.background.raised.base}
backgroundColor={active() ? theme.background.formfield.focused : theme.background.raised.base}
flexDirection="row"
>
<Show when={multi()}>
@@ -983,7 +995,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
>
<box flexDirection="row">
<box
backgroundColor={theme.background.raised.base}
backgroundColor={other() ? theme.background.formfield.focused : theme.background.raised.base}
paddingRight={1}
>
<text fg={other() ? theme.text.formfield.focused : theme.text.muted}>
@@ -993,7 +1005,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
<box
flexDirection="row"
flexGrow={1}
backgroundColor={theme.background.raised.base}
backgroundColor={other() ? theme.background.formfield.focused : theme.background.raised.base}
>
<Show when={multi()}>
<text
+17 -47
View File
@@ -21,7 +21,6 @@ import {
type Verbosity,
defaultVerbosity,
} from "./grouping/session"
import { groupID } from "./anchors"
export type { CacheUsage, PartRef, SessionRow } from "./grouping/session"
/**
@@ -48,7 +47,7 @@ export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessi
const data = useData()
const client = useClient()
const config = useConfig()
const [rows, setRows] = createStore<(SessionRow & { key?: string })[]>([])
const [rows, setRows] = createStore<SessionRow[]>([])
const revertBoundary = () => data.session.get(sessionID())?.revert?.messageID
const turnTokens = () => Boolean(config.data.debug?.turn_tokens)
const verbosity = () => config.data.session?.verbosity ?? defaultVerbosity
@@ -80,25 +79,6 @@ export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessi
return rows
}
// Rows have no `id`, Solid's default reconcile key. Matching by position instead would move every
// row into another store object whenever older history is prepended, remounting the whole transcript.
// Rows inserted live carry no key, so key the current rows too before matching.
function rebuild() {
setRows(
produce((draft) => {
draft.forEach((row) => {
row.key = rowKey(row)
})
}),
)
setRows(
reconcile(
reduce().map((row) => ({ ...row, key: rowKey(row) })),
{ key: "key" },
),
)
}
function pendingPermissions() {
return new Set(
(data.session.permission.list(sessionID()) ?? []).flatMap((request) =>
@@ -119,13 +99,13 @@ export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessi
createEffect(
on([sessionID, () => client.connection.status()], ([id, status]) => {
if (status !== "connected") return
rebuild()
setRows(reconcile(reduce()))
void data.session.pending.sync(id).catch(() => undefined)
void data.session.message
.sync(id)
.then(async () => {
if (sessionID() !== id) return
rebuild()
setRows(reconcile(reduce()))
// Restoration waits for complete boundary groups so saved group IDs resolve.
await completeGroupBoundary({
rows,
@@ -142,7 +122,15 @@ export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessi
// Re-reduce when the revert boundary changes (stage/clear/commit). These reactions defer
// their first run: the mount effect above has already reduced the same state.
createEffect(on(revertBoundary, rebuild, { defer: true }))
createEffect(
on(
revertBoundary,
() => {
setRows(reconcile(reduce()))
},
{ defer: true },
),
)
createEffect(
on(
@@ -152,7 +140,7 @@ export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessi
if (item.type === "user" && item.delivery === "queue") return [`${item.id}:queue`]
return []
}),
rebuild,
() => setRows(reconcile(reduce())),
{ defer: true },
),
)
@@ -178,12 +166,12 @@ export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessi
]
: [],
),
rebuild,
() => setRows(reconcile(reduce())),
{ defer: true },
),
)
createEffect(on([turnTokens, verbosity], rebuild, { defer: true }))
createEffect(on([turnTokens, verbosity], () => setRows(reconcile(reduce())), { defer: true }))
const appendMessage = (messageID: string) =>
setRows(
@@ -309,12 +297,12 @@ export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessi
data.on("session.step.ended", (event) => {
if (event.data.sessionID !== sessionID() || ["tool-calls", "unknown"].includes(event.data.finish)) return
appendFooter(event.data.assistantMessageID)
if (turnTokens()) rebuild()
if (turnTokens()) setRows(reconcile(reduce()))
}),
data.on("session.step.failed", (event) => {
if (event.data.sessionID !== sessionID()) return
appendFooter(event.data.assistantMessageID)
if (turnTokens()) rebuild()
if (turnTokens()) setRows(reconcile(reduce()))
}),
]
onCleanup(() => subscriptions.forEach((unsubscribe) => unsubscribe()))
@@ -322,24 +310,6 @@ export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessi
return rows
}
function rowKey(row: SessionRow) {
switch (row.type) {
case "group":
return groupID(row, 0)
case "part":
return JSON.stringify([row.type, row.ref.messageID, row.ref.partID])
case "message":
case "assistant-footer":
return JSON.stringify([row.type, row.messageID])
case "compaction-queued":
return JSON.stringify([row.type, row.inboxID])
case "turn-usage":
return JSON.stringify([row.type, row.messageIDs[0]])
default:
return row satisfies never
}
}
export function reduceSessionRows(
messages: SessionMessageInfo[],
inputs = new Set<string>(),
+21 -68
View File
@@ -850,7 +850,7 @@ test("reconnects the event stream and resyncs active data", async () => {
}
})
test("completes exploration and keeps live rows when a queued prompt is promoted", async () => {
test("completes exploration when a queued prompt is promoted", async () => {
const events = createEventStream()
const sessionID = "session-promotion"
const calls = createFetch((url) => {
@@ -858,16 +858,10 @@ test("completes exploration and keeps live rows when a queued prompt is promoted
}, events)
let rows!: ReturnType<typeof createSessionRows>
let client!: ReturnType<typeof useClient>
let data!: ReturnType<typeof useData>
let synced = false
function Probe() {
client = useClient()
data = useData()
rows = createSessionRows(
() => sessionID,
() => (synced = true),
)
rows = createSessionRows(() => sessionID)
return <box />
}
@@ -885,9 +879,6 @@ test("completes exploration and keeps live rows when a queued prompt is promoted
try {
await wait(() => client.connection.status() === "connected")
// Rebuilds from the history sync and the new assistant message must land before the parts stream in,
// as they do live; otherwise those rebuilds, not the live append, create the part rows.
await wait(() => synced)
emitEvent(events, {
id: "evt_step_started",
created: 1,
@@ -901,25 +892,11 @@ test("completes exploration and keeps live rows when a queued prompt is promoted
model: { id: "model", providerID: "provider" },
},
})
await wait(() => data.session.message.get(sessionID, "message-assistant") !== undefined)
emitEvent(events, {
id: "evt_text_started",
created: 1,
type: "session.text.started",
durable: durable(sessionID, 1),
data: { sessionID, assistantMessageID: "message-assistant", ordinal: 0 },
})
emitEvent(events, {
id: "evt_text_delta",
created: 1,
type: "session.text.delta",
data: { sessionID, assistantMessageID: "message-assistant", ordinal: 0, delta: "Looking" },
})
emitEvent(events, {
id: "evt_tool_started",
created: 2,
type: "session.tool.input.started",
durable: durable(sessionID, 2),
durable: durable(sessionID, 1),
data: {
sessionID,
assistantMessageID: "message-assistant",
@@ -928,13 +905,12 @@ test("completes exploration and keeps live rows when a queued prompt is promoted
},
})
await wait(() => rows.some((row) => row.type === "group" && !row.completed))
const text = rows.find((row) => row.type === "part")
emitEvent(events, {
id: "evt_prompt_admitted",
created: 3,
type: "session.inbox.enqueued",
durable: durable(sessionID, 3),
durable: durable(sessionID, 2),
data: {
sessionID,
inboxID: "message-user",
@@ -948,17 +924,14 @@ test("completes exploration and keeps live rows when a queued prompt is promoted
id: "evt_prompt_promoted",
created: 4,
type: "session.inbox.delivered",
durable: durable(sessionID, 4),
durable: durable(sessionID, 3),
data: {
sessionID,
inboxID: "message-user",
},
})
await wait(() => rows.find((row) => row.type === "group")?.completed === true)
expect(rows.at(-1)).toMatchObject({ type: "message", messageID: "message-user" })
// Promotion rebuilds every row; the live text must keep its store object or it remounts.
expect(text).toMatchObject({ type: "part", ref: { messageID: "message-assistant", partID: "text:0" } })
expect(rows.find((row) => row.type === "part")).toBe(text)
expect(rows.at(-1)).toEqual({ type: "message", messageID: "message-user" })
} finally {
app.renderer.destroy()
}
@@ -1007,7 +980,7 @@ test("updates and removes queued inputs from durable lifecycle events", async ()
},
})
await wait(() => data.session.pending.list(sessionID).length === 1)
expect(rows).not.toContainEqual(expect.objectContaining({ type: "message", messageID: "message-queued" }))
expect(rows).not.toContainEqual({ type: "message", messageID: "message-queued" })
emitEvent(events, {
id: "evt_queue_steered",
@@ -1021,7 +994,7 @@ test("updates and removes queued inputs from durable lifecycle events", async ()
.list(sessionID)
.some((item) => item.id === "message-queued" && item.type !== "compaction" && item.delivery === "steer"),
)
expect(rows).toContainEqual(expect.objectContaining({ type: "message", messageID: "message-queued" }))
expect(rows).toContainEqual({ type: "message", messageID: "message-queued" })
emitEvent(events, {
id: "evt_queue_restored",
@@ -1035,7 +1008,7 @@ test("updates and removes queued inputs from durable lifecycle events", async ()
.list(sessionID)
.some((item) => item.id === "message-queued" && item.type !== "compaction" && item.delivery === "queue"),
)
expect(rows).not.toContainEqual(expect.objectContaining({ type: "message", messageID: "message-queued" }))
expect(rows).not.toContainEqual({ type: "message", messageID: "message-queued" })
emitEvent(events, {
id: "evt_cancel_admitted",
@@ -1107,19 +1080,17 @@ test("classifies live tool rows independently of their call ID", async () => {
})
await wait(() => rows.length > 0)
expect(unwrap(rows)).toMatchObject([
{ type: "part", ref: { messageID: "message-assistant", partID: "reasoning:0" } },
])
expect(rows).toEqual([{ type: "part", ref: { messageID: "message-assistant", partID: "reasoning:0" } }])
} finally {
app.renderer.destroy()
}
})
test("loads older pages until the oldest exploration group is complete before reporting sync and keeps existing rows", async () => {
test("loads older pages until the oldest exploration group is complete before reporting sync", async () => {
const events = createEventStream()
const sessionID = "session-boundary"
const model = { id: "model", providerID: "provider" }
// One prompt, 50 single-read steps, then an answer: the 20-message first page cuts the group.
// One prompt, then 50 single-read steps: the 20-message first page cuts the group.
const history = [
{ type: "user", id: "msg_000", text: "Explore", time: { created: 0 } },
...Array.from({ length: 50 }, (_, index) => ({
@@ -1139,23 +1110,11 @@ test("loads older pages until the oldest exploration group is complete before re
},
],
})),
{
type: "assistant",
id: "msg_051",
agent: "build",
model,
time: { created: 51, completed: 51 },
finish: "stop",
content: [{ type: "text", text: "Done" }],
},
]
const pages: string[] = []
const older = Promise.withResolvers<void>()
const calls = createFetch(async (url) => {
const calls = createFetch((url) => {
if (url.pathname !== `/api/session/${sessionID}/message`) return
const cursor = url.searchParams.get("cursor")
if (cursor) await older.promise
const end = Number(cursor ?? history.length)
const end = Number(url.searchParams.get("cursor") ?? history.length)
const start = Math.max(0, end - Number(url.searchParams.get("limit") ?? 20))
pages.push(`${start}-${end}`)
return json({ data: history.slice(start, end).toReversed(), cursor: start > 0 ? { next: String(start) } : {} })
@@ -1187,20 +1146,14 @@ test("loads older pages until the oldest exploration group is complete before re
try {
await wait(() => client.connection.status() === "connected")
const answer = () => rows.find((row) => row.type === "part" && row.ref.messageID === "msg_051")
await wait(() => answer() !== undefined)
const mounted = answer()
older.resolve()
await wait(() => synced.length > 0, 4000)
expect(pages).toEqual(["32-52", "12-32", "0-12"])
expect(pages).toEqual(["31-51", "11-31", "0-11"])
// Sync is reported only once the group's true first read is loaded.
expect(synced[0]).toMatchObject({ type: "message", messageID: "msg_000" })
expect(synced[0]).toEqual({ type: "message", messageID: "msg_000" })
const group = rows[1]
if (group?.type !== "group") throw new Error("Expected exploration group")
expect(group.size).toBe(50)
expect(groupRefs(group)[0]).toEqual({ messageID: "msg_001", partID: "read-0" })
// The transcript keys rows by store object, so a new object would remount the answer.
expect(answer()).toBe(mounted)
} finally {
app.renderer.destroy()
}
@@ -1650,7 +1603,7 @@ test("tracks session status from active sessions and execution events", async ()
const message = data.session.message.get("session-manual", "message-compaction")
return message?.type === "compaction" && message.status === "completed"
})
expect(manualRows.filter((row) => row.type === "message")).toMatchObject([
expect(manualRows.filter((row) => row.type === "message")).toEqual([
{ type: "message", messageID: "message-compaction" },
])
expect(manualRows.find((row) => row.type === "message" && row.messageID === "message-compaction")).toBe(
@@ -1753,7 +1706,7 @@ test.each(["before", "between", "after"])("shows compaction admitted %s steers i
}),
)
await wait(() => rows.length === 3)
expect(unwrap(rows)).toMatchObject([
expect(rows).toEqual([
{ type: "compaction-queued", inboxID: "compact" },
{ type: "message", messageID: "a" },
{ type: "message", messageID: "b" },
@@ -1766,7 +1719,7 @@ test.each(["before", "between", "after"])("shows compaction admitted %s steers i
data: { sessionID, reason: "manual", recent: "", inputID: "compact" },
})
await wait(() => rows[0]?.type === "message")
expect(unwrap(rows)).toMatchObject(["compact", "a", "b"].map((messageID) => ({ type: "message", messageID })))
expect(rows).toEqual(["compact", "a", "b"].map((messageID) => ({ type: "message", messageID })))
emitEvent(events, {
id: "evt_compaction_ended",
created: 5,
@@ -1784,7 +1737,7 @@ test.each(["before", "between", "after"])("shows compaction admitted %s steers i
})
}
await app.renderOnce()
expect(unwrap(rows)).toMatchObject(["compact", "a", "b"].map((messageID) => ({ type: "message", messageID })))
expect(rows).toEqual(["compact", "a", "b"].map((messageID) => ({ type: "message", messageID })))
} finally {
app.renderer.destroy()
}
@@ -1846,7 +1799,7 @@ test("restores queued compaction from durable pending input", async () => {
"message-compaction-later",
])
await wait(() => rows.filter((row) => row.type === "compaction-queued").length === 2)
expect(rows.filter((row) => row.type === "compaction-queued")).toMatchObject([
expect(rows.filter((row) => row.type === "compaction-queued")).toEqual([
{ type: "compaction-queued", inboxID: "message-compaction-queued" },
{ type: "compaction-queued", inboxID: "message-compaction-later" },
])
+3 -16
View File
@@ -1,6 +1,5 @@
import { createTestRenderer } from "@opentui/core/testing"
import { Effect, FileSystem } from "effect"
import path from "node:path"
import { Global } from "@opencode/util/global"
import type { TuiInput } from "../../src/app"
import type { Config } from "../../src/config"
@@ -18,7 +17,7 @@ export async function createAppFixture(
} = {},
) {
const { run } = await import("../../src/app")
const root = await tmpdir()
const state = input.state ? undefined : await tmpdir()
const setup = await createTestRenderer({
width: input.width ?? 100,
height: input.height ?? 30,
@@ -40,19 +39,7 @@ export async function createAppFixture(
args: input.args ?? {},
log: () => {},
}).pipe(
Effect.provide(
Global.layerWith({
home: path.join(root.path, "home"),
data: path.join(root.path, "data"),
cache: path.join(root.path, "cache"),
config: path.join(root.path, "config"),
state: input.state ?? path.join(root.path, "state"),
bin: path.join(root.path, "cache", "bin"),
log: path.join(root.path, "data", "log"),
repos: path.join(root.path, "data", "repos"),
tmp: path.join(root.path, "tmp"),
}),
),
Effect.provide(Global.layerWith({ state: input.state ?? state?.path })),
Effect.provide(FileSystem.layerNoop({})),
),
)
@@ -66,7 +53,7 @@ export async function createAppFixture(
await task
} finally {
await server.stop()
await root[Symbol.asyncDispose]()
await state?.[Symbol.asyncDispose]()
}
},
}
@@ -1,72 +0,0 @@
import { Plugin } from "@opencode/plugin/tui"
import { batch, createSignal } from "solid-js"
const generation = "generation 1"
export default Plugin.define({
id: "fixture.setup-keymap",
async setup(context) {
const [runs, setRuns] = createSignal(0)
let syncs = 0
context.keymap.layer(() => ({
commands: [
{
id: "fixture.sync",
title: "Sync setup check",
bind: "f9",
run: () => context.ui.toast.show({ message: `Sync keymap ran ${++syncs}` }),
},
],
}))
await Promise.resolve()
if (context.options.fail) {
setTimeout(
() =>
context.keymap.layer(() => ({
commands: [
{
id: "fixture.late",
title: "Late setup check",
bind: "f10",
run: () => context.ui.toast.show({ message: "Late keymap ran" }),
},
],
})),
20,
)
batch(() => context.keymap.layer(() => ({ commands: [{ title: "Invalid", palette: true, run: () => {} }] })))
}
context.keymap.layer(() => ({
mode: "global",
commands: [
{
id: "fixture.setup",
title: `Setup check ${runs()}`,
palette: true,
slash: { name: "setup-check" },
bind: "ctrl+g",
run() {
setRuns((count) => count + 1)
context.ui.toast.show({ message: `Setup keymap ran ${runs()}` })
},
},
{
id: "fixture.dialog",
title: "Open fixture dialog",
bind: "f8",
run: () => context.ui.dialog.show(() => <FixtureDialog context={context} />),
},
],
}))
context.ui.toast.show({ message: `Fixture ready ${generation}` })
},
})
function FixtureDialog(props: { context: Plugin.Context }) {
let presses = 0
props.context.keymap.layer(() => ({
mode: "global",
commands: [{ bind: "f11", run: () => props.context.ui.toast.show({ message: `Dialog keymap ran ${++presses}` }) }],
}))
return <text>Fixture dialog</text>
}
-150
View File
@@ -1,150 +0,0 @@
import { InputRenderable } from "@opentui/core"
import { expect, test } from "bun:test"
import { mkdir, symlink } from "node:fs/promises"
import path from "node:path"
import { createAppFixture } from "./fixture/app"
import { tmpdir } from "./fixture/fixture"
type Fixture = Awaited<ReturnType<typeof createAppFixture>>
test("setup layers before and after await reach keyboard, palette, and slash across disable and enable", async () => {
await using plugin = await copyPlugin()
await using setup = await createAppFixture({ config: { animations: false, plugins: [plugin.directory] } })
await setup.ready
await setup.waitForFrame((frame) => frame.includes("Fixture ready generation 1"))
await search(setup, "Setup check")
await setup.waitForFrame((frame) => frame.includes("Setup check 0"))
setup.mockInput.pressEnter()
await setup.waitForFrame((frame) => frame.includes("Setup keymap ran 1"))
await search(setup, "Setup check")
await setup.waitForFrame((frame) => frame.includes("Setup check 1"))
setup.mockInput.pressEscape()
setup.mockInput.pressKey("F9")
await setup.waitForFrame((frame) => frame.includes("Sync keymap ran 1"))
setup.mockInput.pressKey("g", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes("Setup keymap ran 2"))
await setup.mockInput.typeText("/setup-check")
await setup.waitForFrame((frame) => frame.includes("/setup-check"))
setup.mockInput.pressEnter()
await setup.waitForFrame((frame) => frame.includes("Setup keymap ran 3"))
await openPlugin(setup)
await setup.waitForFrame((frame) => frame.includes("disable"))
setup.mockInput.pressEnter()
await setup.waitForFrame((frame) => frame.includes("inactive"))
setup.mockInput.pressEscape()
setup.mockInput.pressKey("g", { ctrl: true })
setup.mockInput.pressKey("F9")
await search(setup, "Setup check")
await setup.waitForFrame((frame) => frame.includes("No results found"))
expect(setup.captureCharFrame()).not.toContain("Setup keymap ran 4")
expect(setup.captureCharFrame()).not.toContain("Sync keymap ran 2")
setup.mockInput.pressEscape()
await openPlugin(setup)
await setup.waitForFrame((frame) => frame.includes("inactive"))
setup.mockInput.pressEnter()
await setup.waitForFrame((frame) => frame.includes("Fixture ready generation 1"))
setup.mockInput.pressEscape()
setup.mockInput.pressKey("g", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes("Setup keymap ran 1"))
setup.mockInput.pressKey("F9")
await setup.waitForFrame((frame) => frame.includes("Sync keymap ran 1"))
})
test("a component layer ends when its component unmounts while the plugin stays active", async () => {
await using plugin = await copyPlugin()
await using setup = await createAppFixture({ config: { animations: false, plugins: [plugin.directory] } })
await setup.ready
await setup.waitForFrame((frame) => frame.includes("Fixture ready generation 1"))
setup.mockInput.pressKey("F8")
await setup.waitForFrame((frame) => frame.includes("Fixture dialog"))
setup.mockInput.pressKey("F11")
await setup.waitForFrame((frame) => frame.includes("Dialog keymap ran 1"))
setup.mockInput.pressEscape()
await setup.waitForFrame((frame) => !frame.includes("Fixture dialog"))
setup.mockInput.pressKey("g", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes("Setup keymap ran 1"))
setup.mockInput.pressKey("F11")
await Bun.sleep(100)
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain("Setup keymap ran 1")
expect(setup.captureCharFrame()).not.toContain("Dialog keymap ran 2")
})
test("a component layer ends with its activation on reload while the component stays mounted", async () => {
await using plugin = await copyPlugin()
await using setup = await createAppFixture({ config: { animations: false, plugins: [plugin.directory] } })
await setup.ready
await setup.waitForFrame((frame) => frame.includes("Fixture ready generation 1"))
setup.mockInput.pressKey("F8")
await setup.waitForFrame((frame) => frame.includes("Fixture dialog"))
setup.mockInput.pressKey("F11")
await setup.waitForFrame((frame) => frame.includes("Dialog keymap ran 1"))
await Bun.write(plugin.entry, (await Bun.file(plugin.entry).text()).replace("generation 1", "generation 2"))
await setup.waitForFrame((frame) => frame.includes("Fixture ready generation 2"))
setup.mockInput.pressKey("g", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes("Setup keymap ran 1"))
setup.mockInput.pressKey("F11")
await Bun.sleep(100)
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain("Setup keymap ran 1")
expect(setup.captureCharFrame()).toContain("Fixture dialog")
expect(setup.captureCharFrame()).not.toContain("Dialog keymap ran 2")
})
test("failed setup reports batched layer errors and disposes earlier and later setup layers", async () => {
await using plugin = await copyPlugin()
await using setup = await createAppFixture({
config: { animations: false, plugins: [{ package: plugin.directory, options: { fail: true } }] },
})
await setup.ready
await setup.waitForFrame((frame) => frame.includes("ctrl+p commands"))
await openPlugin(setup)
await setup.waitForFrame((frame) => frame.includes("view error"))
setup.mockInput.pressKey(" ")
await setup.waitForFrame((frame) => frame.includes("Palette commands require an ID"))
setup.mockInput.pressEscape()
setup.mockInput.pressEscape()
await Bun.sleep(100)
setup.mockInput.pressKey("F9")
setup.mockInput.pressKey("F10")
await Bun.sleep(100)
await setup.renderOnce()
expect(setup.captureCharFrame()).not.toContain("Sync keymap ran")
expect(setup.captureCharFrame()).not.toContain("Late keymap ran")
})
async function copyPlugin() {
const root = await tmpdir()
const directory = path.join(root.path, "setup-keymap")
const entry = path.join(directory, "tui.tsx")
await mkdir(directory)
await symlink(path.join(import.meta.dir, "../node_modules"), path.join(directory, "node_modules"))
await Bun.write(entry, Bun.file(path.join(import.meta.dir, "fixture/plugin/setup-keymap/tui.tsx")))
return { directory, entry, [Symbol.asyncDispose]: root[Symbol.asyncDispose] }
}
async function search(setup: Fixture, query: string) {
setup.mockInput.pressKey("p", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes("Commands"))
await setup.waitFor(() => setup.renderer.currentFocusedEditor instanceof InputRenderable)
await setup.mockInput.typeText(query)
}
async function openPlugin(setup: Fixture) {
await setup.mockInput.typeText("/plugins")
await setup.waitForFrame((frame) => frame.includes("/plugins"))
setup.mockInput.pressEnter()
await setup.waitForFrame((frame) => frame.includes("fixture.setup-keymap"))
await setup.waitFor(() => setup.renderer.currentFocusedEditor instanceof InputRenderable)
await setup.mockInput.typeText("setup-keymap")
}
@@ -265,23 +265,6 @@ context.keymap.layer(() => ({
}))
```
Call `layer` from `setup`, including after an `await`, or from a component. Each layer is removed at the first of these:
- The plugin is disabled, reloaded, or fails setup.
- The component or reactive computation that created it, such as a slot, route, or dialog, is disposed.
The layer factory must be pure. OpenCode evaluates it once without dependency tracking to validate the initial command shape, then reactively. Initial command-shape errors, such as a palette command without an ID, throw from `layer()`. OpenTUI handles binding-parse errors. Errors from later reactive updates follow Solid's error handling.
```ts
async setup(context) {
const settings = await loadSettings()
context.keymap.layer(() => ({
mode: "global",
commands: [{ id: "acme.sync", title: "Sync Acme", palette: true, run: () => sync(settings) }],
}))
}
```
A layer may target one OpenTUI renderable and can return `false` from a command to continue keyboard dispatch.
```ts
@@ -110,7 +110,6 @@ You can also access the models directly through the following API endpoints.
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Space Bunny Free | space-bunny-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| LongCat 2.5 Preview Free | longcat-2.5-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Fledge Alpha Free | fledge-alpha-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| MiMo-V2.6-Flash Free | mimo-v2.6-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Ling 3.1 Flash Free | ling-3.1-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
@@ -202,7 +201,6 @@ Console uses pay-as-you-go pricing. Below are the prices **per 1M tokens**.
| Big Pickle | Free | Free | Free | - |
| Space Bunny Free | Free | Free | Free | - |
| LongCat 2.5 Preview Free | Free | Free | Free | - |
| Fledge Alpha Free | Free | Free | Free | - |
| MiMo-V2.6-Flash Free | Free | Free | Free | - |
| MiMo-V2.5 Free | Free | Free | Free | - |
| Ling 3.1 Flash Free | Free | Free | Free | - |
@@ -311,7 +309,6 @@ models to generate session titles.
### Free models
- Fledge Alpha Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
- MiMo-V2.6-Flash Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
- MiMo-V2.5 Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
- Ling 3.1 Flash Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
@@ -366,7 +363,6 @@ you might still be charged more than $20 when your balance goes below $5.
All our models are hosted in the US. Our providers follow a zero-retention policy and do not use your data for model training, with the following exceptions:
- Big Pickle: During its free period, collected data may be used to improve the model.
- Fledge Alpha Free: During its free period, collected data may be used to improve the model.
- MiMo-V2.6-Flash Free: During its free period, collected data may be used to improve the model.
- MiMo-V2.5 Free: During its free period, collected data may be used to improve the model.
- Ling 3.1 Flash Free: During its free period, collected data may be used to improve the model.