mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-03 22:16:22 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea15a0d025 |
No files matched your search
@@ -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
@@ -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="
|
||||
}
|
||||
}
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
+5
-5
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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") {}
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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: {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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,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,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,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,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(
|
||||
|
||||
@@ -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,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,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: [],
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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* () {
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
}),
|
||||
)
|
||||
@@ -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" })),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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 }
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -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))
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -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,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)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>(),
|
||||
|
||||
@@ -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" },
|
||||
])
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user