Compare commits

...
Author SHA1 Message Date
opencode-agent[bot] d8a3962923 chore(core): refresh bundled models.dev snapshot 2026-10-03 13:46:03 +00:00
Simon Klee 40679546d4 fix(tui): keep transcript rows stable when history is prepended (#52824)
Session rows have no id, so reconcile matched them by position. Each page of older history shifted every row into a different store object and remounted the visible transcript. Newly mounted markdown is blank until highlighting finishes, so the transcript blinked once per page. This was most visible with Low verbosity, where opening a session loads several pages to complete the oldest group.
2026-10-03 06:09:37 +00:00
Daniel Chen 12810e84d3 docs(www): add Fledge Alpha Free to Console models (#52896) 2026-10-02 21:59:24 -07:00
Aiden Cline 3e1aa1e58c fix(server): return 404 when a location folder is missing (#52668) 2026-10-02 23:43:56 -05:00
Aiden Cline 7ced9326e6 fix(ai): preserve Mistral and Cohere system parts (#52890) 2026-10-02 23:36:36 -05:00
Aiden Cline d1ff6f90cf fix(ai): preserve Gemini system text parts (#52888) 2026-10-02 23:04:49 -05:00
James Long d9d094a543 fix(tui): keep question form highlights on the raised surface (#52872) 2026-10-02 20:34:12 -04:00
46 changed files with 479 additions and 200 deletions

No files matched your search

+9 -1
View File
@@ -124,7 +124,15 @@ 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: ProviderShared.joinText(request.system) }]
? [
{
role: "system",
content:
request.system.length === 1
? request.system[0].text
: request.system.map((part) => ({ type: "text", text: part.text })),
},
]
: []
for (const message of flattened.request.messages) {
if (message.role === "system") {
+1 -1
View File
@@ -475,7 +475,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
safetySettings: options.safetySettings,
serviceTier: options.serviceTier,
systemInstruction:
request.system.length === 0 ? undefined : { parts: [{ text: ProviderShared.joinText(request.system) }] },
request.system.length === 0 ? undefined : { parts: request.system.map((part) => ({ text: part.text })) },
tools: hasTools
? [
{
+15 -2
View File
@@ -68,7 +68,10 @@ const MistralAssistantToolCall = Schema.Struct({
type MistralAssistantToolCall = Schema.Schema.Type<typeof MistralAssistantToolCall>
const MistralMessage = Schema.Union([
Schema.Struct({ role: Schema.Literal("system"), content: Schema.String }),
Schema.Struct({
role: Schema.Literal("system"),
content: Schema.Union([Schema.String, Schema.Array(MistralTextContent)]),
}),
Schema.Struct({
role: Schema.Literal("user"),
content: Schema.Union([Schema.String, Schema.Array(MistralUserContent)]),
@@ -335,7 +338,17 @@ 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: ProviderShared.joinText(request.system) }]
request.system.length === 0
? []
: [
{
role: "system",
content:
request.system.length === 1
? request.system[0].text
: request.system.map((part) => ({ type: "text", text: part.text })),
},
]
for (const message of request.messages) {
if (message.role === "system") {
const update = yield* ProviderShared.wrappedSystemUpdate("Mistral Chat", message)
@@ -6,7 +6,7 @@
"provider:cohere"
],
"name": "cohere/continues-a-native-tool-call",
"recordedAt": "2026-10-02T02:36:16.143Z"
"recordedAt": "2026-10-03T04:09:51.600Z"
},
"interactions": [
{
@@ -17,14 +17,14 @@
"headers": {
"content-type": "application/json"
},
"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}"
"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}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"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
"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
}
},
{
@@ -35,14 +35,14 @@
"headers": {
"content-type": "application/json"
},
"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}"
"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}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"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
"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
}
}
]
@@ -2,9 +2,16 @@
"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-08-30T17:18:49.552Z"
"recordedAt": "2026-10-03T04:09:49.878Z"
},
"interactions": [
{
@@ -15,14 +22,14 @@
"headers": {
"content-type": "application/json"
},
"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\"}"
"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\"}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"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"
"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"
}
},
{
@@ -33,14 +40,14 @@
"headers": {
"content-type": "application/json"
},
"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\"}"
"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\"}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"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"
"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"
}
}
]
@@ -1,6 +1,6 @@
import { expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent } from "../../src/index.js"
import { LLM, LLMEvent, LLMRequest, SystemPart } 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,12 +55,20 @@ recorded.effect(
() =>
Effect.gen(function* () {
const events = yield* runWeatherToolLoop(
goldenWeatherToolLoopRequest({
id: "cohere-tool-loop",
model: cohere.model("command-a-plus-05-2026"),
maxTokens: 2048,
temperature: false,
}),
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."),
],
},
),
)
expectWeatherToolLoop(events)
expect(events.some(LLMEvent.is.toolInputDelta)).toBe(true)
+9 -3
View File
@@ -1,6 +1,6 @@
import { expect, test } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMClient, LLMEvent, Media, Message, isRetryable } from "../../src/index.js"
import { LLM, LLMClient, LLMEvent, Media, Message, SystemPart, 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: "Be concise.",
system: [SystemPart.make("Be concise.\nKeep this newline."), SystemPart.make("Second instructions.")],
messages: [
Message.user("Lookup Paris"),
Message.assistant([{ type: "tool-call", id: "lookup-1", name: "lookup", input: { city: "Paris" } }]),
@@ -44,7 +44,13 @@ it.effect("Cohere lowers native history, thinking, tools, and sampling", () =>
thinking: { type: "enabled", token_budget: 128 },
tool_choice: "REQUIRED",
messages: [
{ role: "system", content: "Be concise." },
{
role: "system",
content: [
{ type: "text", text: "Be concise.\nKeep this newline." },
{ type: "text", text: "Second instructions." },
],
},
{ 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, ToolDefinition, Media } from "../../src/index.js"
import { LLM, LLMEvent, Message, SystemPart, 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: "Initial",
system: [SystemPart.make("Initial\nKeep this newline."), SystemPart.make("Second instructions.")],
messages: [
Message.system("Updated"),
Message.user([
@@ -105,7 +105,13 @@ describe("Mistral Chat", () => {
reasoning_effort: "high",
})
expect(prepared.body.messages.slice(0, 4)).toMatchObject([
{ role: "system", content: "Initial" },
{
role: "system",
content: [
{ type: "text", text: "Initial\nKeep this newline." },
{ type: "text", text: "Second instructions." },
],
},
{ 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, ToolChoice, ToolDefinition } from "../../src/index.js"
import { LLM, LLMEvent, LLMRequest, Message, SystemPart, 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,7 +97,10 @@ describe("Mistral recorded", () => {
const model = configure({ apiKey, providerOptions: { reasoningEffort: "none" } }).model("mistral-small-latest")
const firstRequest = LLM.request({
model,
system: "Call lookup_weather exactly once with Paris.",
system: [
SystemPart.make("Call lookup_weather exactly once with Paris."),
SystemPart.make("After the tool result, describe the weather briefly."),
],
prompt: "What is the weather?",
tools: [weather],
toolChoice: weather,
+51 -45
View File
@@ -449,7 +449,7 @@ export function make(options: ClientOptions) {
path: `/api/location`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -474,7 +474,7 @@ export function make(options: ClientOptions) {
path: `/api/agent`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -500,7 +500,7 @@ export function make(options: ClientOptions) {
path: `/api/plugin`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
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],
declaredStatuses: [400, 401, 404],
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, 503],
declaredStatuses: [400, 401, 404, 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, 503],
declaredStatuses: [400, 401, 404, 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, 503],
declaredStatuses: [400, 401, 404, 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, 503],
declaredStatuses: [400, 401, 404, 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, 503],
declaredStatuses: [400, 401, 404, 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],
declaredStatuses: [400, 401, 404],
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],
declaredStatuses: [400, 401, 404],
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],
declaredStatuses: [400, 401, 404],
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],
declaredStatuses: [400, 401, 404],
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],
declaredStatuses: [400, 401, 404],
empty: true,
},
requestOptions,
@@ -1329,7 +1329,7 @@ export function make(options: ClientOptions) {
path: `/api/mcp`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
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],
declaredStatuses: [400, 401, 404],
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],
declaredStatuses: [400, 401, 404],
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], empty: false },
{ method: "GET", path: `/api/project`, successStatus: 200, declaredStatuses: [400, 401, 404], 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],
declaredStatuses: [400, 401, 404],
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],
declaredStatuses: [400, 401, 404],
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],
declaredStatuses: [400, 401, 404],
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],
declaredStatuses: [400, 401, 404],
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],
declaredStatuses: [400, 401, 404],
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],
declaredStatuses: [400, 401, 404],
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],
declaredStatuses: [400, 401, 404],
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],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -1665,7 +1665,7 @@ export function make(options: ClientOptions) {
path: `/api/skill`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
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, 500],
declaredStatuses: [400, 401, 404, 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],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -1720,7 +1720,7 @@ export function make(options: ClientOptions) {
env: input?.["env"],
},
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -1911,7 +1911,7 @@ export function make(options: ClientOptions) {
path: `/api/shell`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -1929,7 +1929,7 @@ export function make(options: ClientOptions) {
metadata: input["metadata"],
},
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
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],
declaredStatuses: [400, 401, 404],
empty: true,
},
requestOptions,
@@ -1979,7 +1979,7 @@ export function make(options: ClientOptions) {
path: `/api/reference`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
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, 409, 501, 503],
declaredStatuses: [400, 401, 404, 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],
declaredStatuses: [400, 401, 404],
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, 503],
declaredStatuses: [400, 401, 404, 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],
declaredStatuses: [400, 401, 404],
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],
declaredStatuses: [400, 401, 404],
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, 503],
declaredStatuses: [400, 401, 404, 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, 503],
declaredStatuses: [400, 401, 404, 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, 503],
declaredStatuses: [400, 401, 404, 503],
empty: false,
},
requestOptions,
@@ -2194,14 +2194,20 @@ export function make(options: ClientOptions) {
path: `/api/config`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
),
shells: (requestOptions?: RequestOptions) =>
request<ConfigShellsOutput>(
{ method: "GET", path: `/api/config/shell`, successStatus: 200, declaredStatuses: [400, 401], empty: false },
{
method: "GET",
path: `/api/config/shell`,
successStatus: 200,
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
),
update: (input: ConfigUpdateInput, requestOptions?: RequestOptions) =>
@@ -2211,7 +2217,7 @@ export function make(options: ClientOptions) {
path: `/api/experimental/config`,
body: { shell: input["shell"] },
successStatus: 204,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: true,
},
requestOptions,
@@ -2531,6 +2531,14 @@ 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
+14 -4
View File
@@ -114,7 +114,7 @@ const nodes = [
export const graph = LayerNode.group(nodes)
export type Services = LayerNode.Output<typeof graph>
export type Error = Layer.Error<ReturnType<typeof layer>>
export type Error = FileSystem.DirectoryNotFoundError
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> {
export function layer(ref: Location.Ref, options: Options = {}): Layer.Layer<Services, Error> {
const startedAt = performance.now()
// Ordered: vanilla defaults, then caller replacements (which win over the
// defaults), then instance bindings (which win over everything).
@@ -157,8 +157,18 @@ export function layer(ref: Location.Ref, options: Options = {}): Layer.Layer<Ser
]
return LayerNode.compile(graph, { replacements, shared: Node.tags.values.global }).pipe(
// Instance boot failures are defects; provided operations retain their typed errors.
Layer.orDie,
// 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),
),
),
),
),
Layer.tap((context) => Effect.addFinalizer(() => Context.get(context, LocationLifecycle.Service).shutdown)),
Layer.tap(() =>
Effect.logInfo("location services booted", {
+2 -2
View File
@@ -5,13 +5,13 @@ import { Context, type Effect } from "effect"
import type { Session } from "@opencode/schema/session"
import { Node } from "@opencode/util/effect/app-node"
import { LayerNode } from "@opencode/util/effect/layer-node"
import type { Services } from "../instance.js"
import type { Services, Error } 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, Exclude<R, Services>>
) => <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E | Error, Exclude<R, Services>>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Instance") {}
+1 -1
View File
@@ -17,7 +17,7 @@ export function buildLocationServiceMap(
Effect.gen(function* () {
const owner = yield* Effect.scope
const builds = MutableHashMap.empty<Location.Ref, { close?: Effect.Effect<void> }>()
const inner: LayerMap.LayerMap<Location.Ref, LocationServices> = yield* LayerMap.make(
const inner: LayerMap.LayerMap<Location.Ref, LocationServices, Instance.Error> = yield* LayerMap.make(
(ref: Location.Ref) => {
const build: { close?: Effect.Effect<void> } = {}
MutableHashMap.set(builds, ref, build)
+1 -1
View File
@@ -1 +1 @@
{"deepinfra":{"id":"deepinfra","env":["DEEPINFRA_API_KEY"],"npm":"@ai-sdk/deepinfra","name":"Deep Infra","doc":"https://deepinfra.com/models","models":{"tencent/Hy3":{"id":"tencent/Hy3","name":"Hy3","description":"Tencent Hy reasoning model for coding, instruction following, and agent tasks","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-07-06","last_updated":"2026-07-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":262144,"input":192000,"output":128000},"cost":{"input":0.13,"output":0.53,"cache_read":0.033},"canonical_model_id":"tencent/hy3"},"tencent/Hy4-preview":{"id":"tencent/Hy4-preview","name":"Hy4 preview","description":"A next-generation productivity model with significantly enhanced Agent and complex task execution capabilities.","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","high"]}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-08-28","last_updated":"2026-08-28","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":64000},"cost":{"input":0.834,"output":2.501,"cache_read":0.042},"canonical_model_id":"tencent/hy4-preview"},"meta-llama/Llama-3.3-70B-Instruct-Turbo":{"id":"meta-llama/Llama-3.3-70B-Instruct-Turbo","name":"Llama 3.3 70B Turbo","description":"Compact Llama instruction model for fast chat and local deployment","family":"llama","attachment":false,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2024-12-06","last_updated":"2024-12-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":131072,"output":16384},"cost":{"input":0.1,"output":0.32}},"meta-llama/Llama-4-Scout-17B-16E-Instruct":{"id":"meta-llama/Llama-4-Scout-17B-16E-Instruct","name":"Llama 4 Scout 17B","description":"Open multimodal Llama model for long-context analysis and efficient agents","family":"llama","attachment":true,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":327680,"output":16384},"cost":{"input":0.1,"output":0.3}},"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8":{"id":"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8","name":"Llama 4 Maverick 17B FP8","description":"Open multimodal Llama model for strong reasoning and fast responses","family":"llama","attachment":true,"reasoning":false,"tool_call":false,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":0.2,"output":0.8}},"XiaomiMiMo/MiMo-V2.6-Pro":{"id":"XiaomiMiMo/MiMo-V2.6-Pro","name":"MiMo-V2.6-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.43,"output":0.87,"cache_read":0.0036},"canonical_model_id":"xiaomi/mimo-v2.6-pro"},"XiaomiMiMo/MiMo-V2.5-Pro":{"id":"XiaomiMiMo/MiMo-V2.5-Pro","name":"MiMo-V2.5-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"interleaved":{"field":"reasoning_content"},"structured_output":true,"temperature":true,"knowledge":"2024-12","release_date":"2026-04-22","last_updated":"2026-04-22","modalities":{"input":["text","audio"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":1,"output":3,"cache_read":0.2},"canonical_model_id":"xiaomi/mimo-v2.5-pro"},"XiaomiMiMo/MiMo-V2.6-Flash":{"id":"XiaomiMiMo/MiMo-V2.6-Flash","name":"MiMo-V2.6-Flash","description":"MiMo Flash model for multimodal coding agents and long-context automation","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.14,"output":0.28,"cache_read":0.0028},"canonical_model_id":"xiaomi/mimo-v2.6-flash"},"XiaomiMiMo/MiMo-V2.5":{"id":"XiaomiMiMo/MiMo-V2.5","name":"MiMo-V2.5","description":"Open MiMo model for multimodal coding agents and long-coLine truncated
{"deepinfra":{"id":"deepinfra","env":["DEEPINFRA_API_KEY"],"npm":"@ai-sdk/deepinfra","name":"Deep Infra","doc":"https://deepinfra.com/models","models":{"tencent/Hy3":{"id":"tencent/Hy3","name":"Hy3","description":"Tencent Hy reasoning model for coding, instruction following, and agent tasks","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-07-06","last_updated":"2026-07-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":262144,"input":192000,"output":128000},"cost":{"input":0.13,"output":0.53,"cache_read":0.033},"canonical_model_id":"tencent/hy3"},"tencent/Hy4-preview":{"id":"tencent/Hy4-preview","name":"Hy4 preview","description":"A next-generation productivity model with significantly enhanced Agent and complex task execution capabilities.","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","high"]}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-08-28","last_updated":"2026-08-28","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":64000},"cost":{"input":0.834,"output":2.501,"cache_read":0.042},"canonical_model_id":"tencent/hy4-preview"},"meta-llama/Llama-3.3-70B-Instruct-Turbo":{"id":"meta-llama/Llama-3.3-70B-Instruct-Turbo","name":"Llama 3.3 70B Turbo","description":"Compact Llama instruction model for fast chat and local deployment","family":"llama","attachment":false,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2024-12-06","last_updated":"2024-12-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":131072,"output":16384},"cost":{"input":0.1,"output":0.32}},"meta-llama/Llama-4-Scout-17B-16E-Instruct":{"id":"meta-llama/Llama-4-Scout-17B-16E-Instruct","name":"Llama 4 Scout 17B","description":"Open multimodal Llama model for long-context analysis and efficient agents","family":"llama","attachment":true,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":327680,"output":16384},"cost":{"input":0.1,"output":0.3}},"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8":{"id":"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8","name":"Llama 4 Maverick 17B FP8","description":"Open multimodal Llama model for strong reasoning and fast responses","family":"llama","attachment":true,"reasoning":false,"tool_call":false,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":0.2,"output":0.8}},"XiaomiMiMo/MiMo-V2.6-Pro":{"id":"XiaomiMiMo/MiMo-V2.6-Pro","name":"MiMo-V2.6-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.43,"output":0.87,"cache_read":0.0036},"canonical_model_id":"xiaomi/mimo-v2.6-pro"},"XiaomiMiMo/MiMo-V2.5-Pro":{"id":"XiaomiMiMo/MiMo-V2.5-Pro","name":"MiMo-V2.5-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"interleaved":{"field":"reasoning_content"},"structured_output":true,"temperature":true,"knowledge":"2024-12","release_date":"2026-04-22","last_updated":"2026-04-22","modalities":{"input":["text","audio"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":1,"output":3,"cache_read":0.2},"canonical_model_id":"xiaomi/mimo-v2.5-pro"},"XiaomiMiMo/MiMo-V2.6-Flash":{"id":"XiaomiMiMo/MiMo-V2.6-Flash","name":"MiMo-V2.6-Flash","description":"MiMo Flash model for multimodal coding agents and long-context automation","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.14,"output":0.28,"cache_read":0.0028},"canonical_model_id":"xiaomi/mimo-v2.6-flash"},"XiaomiMiMo/MiMo-V2.5":{"id":"XiaomiMiMo/MiMo-V2.5","name":"MiMo-V2.5","description":"Open MiMo model for multimodal coding agents and long-coLine truncated
+16 -4
View File
@@ -8,6 +8,7 @@ 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"
@@ -145,7 +146,10 @@ export interface Interface {
readonly from?: SessionMessage.ID
readonly to?: SessionMessage.ID
readonly context?: number
}) => Effect.Effect<readonly FileDiff.Info[], NotFoundError | MessageNotFoundError | TurnRangeError | Snapshot.Error>
}) => Effect.Effect<
readonly FileDiff.Info[],
NotFoundError | MessageNotFoundError | TurnRangeError | Snapshot.Error | FileSystem.DirectoryNotFoundError
>
/**
* Durable admitted session work not yet visible in projected history,
* ordered by admission. Includes unpromoted user and synthetic inputs and
@@ -195,7 +199,10 @@ export interface Interface {
agents?: PromptInput.Prompt["agents"]
skills?: PromptInput.Prompt["skills"]
delivery?: SessionInbox.Delivery
}) => Effect.Effect<void, NotFoundError | Command.NotFoundError | Command.ExecutionError>
}) => Effect.Effect<
void,
NotFoundError | Command.NotFoundError | Command.ExecutionError | FileSystem.DirectoryNotFoundError
>
readonly shell: (
input: Parameters<Session.Handle["shell"]>[0] & { sessionID: SessionSchema.ID },
) => ReturnType<Session.Handle["shell"]>
@@ -218,8 +225,13 @@ export interface Interface {
sessionID: SessionSchema.ID
messageID: SessionMessage.ID
files?: boolean
}) => Effect.Effect<SessionSchema.Revert, NotFoundError | MessageNotFoundError | BusyError | Snapshot.Error>
readonly clear: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | BusyError | Snapshot.Error>
}) => 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>
readonly commit: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | BusyError>
}
}
+7 -1
View File
@@ -1,5 +1,6 @@
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"
@@ -14,7 +15,12 @@ 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
export type Error =
| AgentNotFoundError
| Instructions.InitializationBlocked
| SessionRunnerModel.Error
| AIError
| FileSystem.DirectoryNotFoundError
/** Generates text from current Session context without mutating the Session. */
export const generate = Effect.fn("SessionGenerate.generate")(function* (input: {
+7 -2
View File
@@ -91,7 +91,8 @@ const layer = Layer.effect(
yield* locations.contextEffect(destination.location).pipe(
Effect.scoped,
Effect.catchCause((cause) => {
if (Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause)
if (Cause.hasInterruptsOnly(cause))
return Effect.failCause(Cause.fromReasons<never>(cause.reasons.filter(Cause.isInterruptReason)))
return Effect.logWarning("session move destination unavailable", { directory, cause }).pipe(
Effect.andThen(Effect.fail(new DestinationUnavailableError({ directory }))),
)
@@ -106,7 +107,11 @@ const layer = Layer.effect(
return yield* SessionRunner.Service.pipe(
instances.provide(session),
Effect.as(false),
Effect.catchCause((cause) => (Cause.hasInterrupts(cause) ? Effect.failCause(cause) : Effect.succeed(true))),
Effect.catchCause((cause) =>
Cause.hasInterrupts(cause)
? Effect.failCause(Cause.fromReasons<never>(cause.reasons.filter(Cause.isInterruptReason)))
: Effect.succeed(true),
),
)
})
@@ -1,5 +1,6 @@
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"
@@ -15,6 +16,7 @@ export type RunError =
| AgentNotFoundError
| StepFailedError
| Instructions.InitializationBlocked
| FileSystem.DirectoryNotFoundError
export type Continuation = { readonly step: number }
+2
View File
@@ -12,6 +12,7 @@ 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"
@@ -67,6 +68,7 @@ export class StrategyUnavailableError extends Schema.TaggedError<StrategyUnavail
) {}
export type Error =
| FileSystem.DirectoryNotFoundError
| Project.NotFoundError
| SourceDirectoryNotFoundError
| DestinationExistsError
@@ -1,3 +1,4 @@
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"
@@ -24,7 +25,7 @@ export const promptLocationNode = makeGlobalNode({
Bus.node.replace(Layer.succeed(Bus.Service, bus)),
Plugin.node.replace(Layer.mock(Plugin.Service, { awaitActivation: Effect.void })),
],
}) as Layer.Layer<LocationServices>,
}) as unknown as Layer.Layer<LocationServices, FileSystem.DirectoryNotFoundError>,
)
}),
),
+2 -1
View File
@@ -1,3 +1,4 @@
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"
@@ -65,7 +66,7 @@ const locations = Layer.effect(
Layer.provideMerge(Form.layer),
Layer.provide(Layer.succeed(Bus.Service, bus)),
Layer.fresh,
) as unknown as Layer.Layer<LocationServices>,
) as unknown as Layer.Layer<LocationServices, FileSystem.DirectoryNotFoundError>,
{ idleTimeToLive: Duration.infinity },
)
return {
+2 -1
View File
@@ -1,3 +1,4 @@
import type { FileSystem } from "@opencode/core/filesystem"
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
@@ -67,7 +68,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>,
) as unknown as Layer.Layer<LocationServices, FileSystem.DirectoryNotFoundError>,
{ idleTimeToLive: Duration.infinity },
),
)
+2 -1
View File
@@ -1,3 +1,4 @@
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"
@@ -58,7 +59,7 @@ const locations = Layer.effect(
Layer.provide(client),
Layer.provide(config),
Layer.provide(models),
) as unknown as Layer.Layer<LocationServices>,
) as unknown as Layer.Layer<LocationServices, FileSystem.DirectoryNotFoundError>,
),
)
const it = testEffect(
+2 -1
View File
@@ -1,3 +1,4 @@
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"
@@ -1371,7 +1372,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>,
runner as unknown as Layer.Layer<LocationServices, FileSystem.DirectoryNotFoundError>,
),
)
return yield* Layer.buildWithScope(
+7 -2
View File
@@ -1,3 +1,4 @@
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"
@@ -58,7 +59,7 @@ const itWithActiveExecution = testEffect(
replacements: [Project.node.replace(globalProjectNode), offlineModels],
}),
Layer.succeed(SessionRunner.Service, { drain: () => Effect.never }),
) as unknown as Layer.Layer<LocationServices>,
) as unknown as Layer.Layer<LocationServices, FileSystem.DirectoryNotFoundError>,
),
),
),
@@ -68,7 +69,11 @@ const itWithActiveExecution = testEffect(
const unavailableLocations = Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
() => Layer.effectDiscard(Effect.fail(new Error("broken location"))) as unknown as Layer.Layer<LocationServices>,
() =>
Layer.effectDiscard(Effect.die(new Error("broken location"))) as unknown as Layer.Layer<
LocationServices,
FileSystem.DirectoryNotFoundError
>,
),
)
const itWithUnavailableDestination = testEffect(
+2 -1
View File
@@ -1,4 +1,5 @@
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"
@@ -83,7 +84,7 @@ const locations = makeGlobalNode({
restore: () => Effect.void,
}),
Layer.mock(Plugin.Service, { awaitActivation: Effect.void }),
).pipe(Layer.fresh) as unknown as Layer.Layer<LocationServices>,
).pipe(Layer.fresh) as unknown as Layer.Layer<LocationServices, FileSystem.DirectoryNotFoundError>,
)
}),
),
+2 -1
View File
@@ -1,3 +1,4 @@
import type { FileSystem } from "@opencode/core/filesystem"
import path from "path"
import { describe, expect } from "bun:test"
import { Effect, Layer, LayerMap } from "effect"
@@ -49,7 +50,7 @@ const locations = makeGlobalNode({
list: () => Effect.succeed([info]),
}),
Layer.mock(Plugin.Service, { awaitActivation: Effect.void }),
) as unknown as Layer.Layer<LocationServices>,
) as unknown as Layer.Layer<LocationServices, FileSystem.DirectoryNotFoundError>,
),
),
deps: [],
+3 -2
View File
@@ -1,4 +1,4 @@
import { InvalidRequestError, SessionNotFoundError } from "./errors.js"
import { LocationNotFoundError, InvalidRequestError, SessionNotFoundError } from "./errors.js"
import { makeDefaultApi } from "./api.js"
import type { Api } from "./api.js"
import type { Context } from "effect"
@@ -7,11 +7,12 @@ 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] },
{ error: [InvalidRequestError, SessionNotFoundError, LocationNotFoundError] },
) {}
type ClientApiShape = Api<
+7
View File
@@ -1,5 +1,12 @@
import { Schema } from "effect"
import { Skill } from "@opencode/schema/skill"
import { Location } from "@opencode/schema/location"
export class LocationNotFoundError extends Schema.TaggedError<LocationNotFoundError>()(
"LocationNotFoundError",
{ location: Location.PublicRef, message: Schema.String },
{ httpApiStatus: 404 },
) {}
export class InvalidRequestError extends Schema.TaggedError<InvalidRequestError>()(
"InvalidRequestError",
+2 -2
View File
@@ -1,7 +1,7 @@
import { Model } from "@opencode/schema/model"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { InvalidRequestError, ServiceUnavailableError } from "../errors.js"
import { LocationNotFoundError, 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],
error: [InvalidRequestError, ServiceUnavailableError, LocationNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.generate.text",
+2 -2
View File
@@ -6,7 +6,7 @@ import { Project } from "@opencode/schema/project"
import { Session } from "@opencode/schema/session"
import { Context, Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { PermissionNotFoundError, SessionNotFoundError } from "../errors.js"
import { LocationNotFoundError, 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,
error: [SessionNotFoundError, LocationNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.permission.list",
+7 -10
View File
@@ -18,6 +18,7 @@ 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,
@@ -170,12 +171,10 @@ 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", {
@@ -562,9 +561,7 @@ export const makeSessionGroup = <
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", {
@@ -594,7 +591,7 @@ export const makeSessionGroup = <
}),
}),
success: Schema.Struct({ data: Schema.Array(FileDiff.Info) }),
error: [InvalidRequestError, MessageNotFoundError, SessionNotFoundError, UnknownError],
error: [InvalidRequestError, MessageNotFoundError, SessionNotFoundError, UnknownError, LocationNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.diff",
+5 -1
View File
@@ -57,7 +57,11 @@ 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> = yield* LayerMap.make(
const instances: LayerMap.LayerMap<
ReturnType<typeof key>,
Instance.Services,
Instance.Error
> = yield* LayerMap.make(
(input: ReturnType<typeof key>) =>
Layer.unwrap(
Effect.gen(function* () {
+18 -13
View File
@@ -7,6 +7,7 @@ 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* () {
@@ -15,19 +16,23 @@ 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)),
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,
),
)
}),
)
+1 -1
View File
@@ -24,7 +24,7 @@ export const LocationHandler = HttpApiBuilder.group(Api, "server.location", (han
Effect.provideService(LocationServiceMap.Service, locations),
Effect.catchCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.failCause(cause)
? Effect.failCause(Cause.fromReasons<never>(cause.reasons.filter(Cause.isInterruptReason)))
: Effect.fail(new ServiceUnavailableError({ message: Cause.pretty(cause), service: "location" })),
),
),
+2 -2
View File
@@ -7,7 +7,7 @@ import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
import { PermissionNotFoundError } from "@opencode/protocol/errors"
import { response, sessionInfo } from "../location"
import { locationErrors, 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))
).pipe(instances.provide(session), locationErrors)
return { data: requests }
}),
)
+7
View File
@@ -23,6 +23,7 @@ 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
@@ -335,6 +336,7 @@ 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,
),
}
}),
@@ -370,6 +372,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
}),
),
),
locationErrors,
)
return HttpApiSchema.NoContent.make()
}),
@@ -388,6 +391,7 @@ 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()
}),
@@ -471,6 +475,7 @@ 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,
),
}
}),
@@ -485,6 +490,7 @@ 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()
}),
@@ -527,6 +533,7 @@ 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,
),
}
}),
+20 -2
View File
@@ -1,8 +1,9 @@
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 { InvalidRequestError } from "@opencode/protocol/errors"
import { LocationNotFoundError, InvalidRequestError } from "@opencode/protocol/errors"
import { Effect, Layer, Schema } from "effect"
import { HttpServerRequest } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
@@ -12,8 +13,25 @@ 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
@@ -61,7 +79,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))))
return yield* effect.pipe(Effect.provide(locations.get(requestRef(request))), locationErrors)
}),
)
}),
@@ -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 { InvalidRequestError, SessionNotFoundError } from "@opencode/protocol/errors"
import { LocationNotFoundError, 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 { requestRef, sessionInfo, type LocationServices } from "../location"
import { locationErrors, requestRef, sessionInfo, type LocationServices } from "../location"
export class FormLocationMiddleware extends HttpApiMiddleware.Service<
FormLocationMiddleware,
{ provides: LocationServices }
>()("@opencode/HttpApiFormLocation", {
error: [InvalidRequestError, SessionNotFoundError],
error: [InvalidRequestError, SessionNotFoundError, LocationNotFoundError],
}) {}
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))))
return yield* effect.pipe(Effect.provide(locations.get(requestRef(request))), locationErrors)
}
const session = yield* sessionInfo(sessions, route.params.sessionID)
return yield* effect.pipe(instances.provide(session))
return yield* effect.pipe(instances.provide(session), locationErrors)
}),
)
}),
@@ -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 { InvalidRequestError, SessionNotFoundError } from "@opencode/protocol/errors"
import { sessionInfo, type LocationServices } from "../location"
import { LocationNotFoundError, InvalidRequestError, SessionNotFoundError } from "@opencode/protocol/errors"
import { locationErrors, sessionInfo, type LocationServices } from "../location"
export class SessionLocationMiddleware extends HttpApiMiddleware.Service<
SessionLocationMiddleware,
{ provides: LocationServices }
>()("@opencode/HttpApiSessionLocation", {
error: [InvalidRequestError, SessionNotFoundError],
error: [InvalidRequestError, SessionNotFoundError, LocationNotFoundError],
}) {}
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))
return yield* effect.pipe(instances.provide(session), locationErrors)
}),
)
}),
+58 -1
View File
@@ -6,6 +6,7 @@ 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"
@@ -18,6 +19,60 @@ 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) {
@@ -105,7 +160,9 @@ 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")
+6 -18
View File
@@ -806,13 +806,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
return (
<box
paddingRight={2}
backgroundColor={
isTab()
? theme.background.formfield.selected
: tabHover() === index()
? theme.background.formfield.focused
: theme.background.raised.base
}
backgroundColor={theme.background.raised.base}
onMouseOver={() => setTabHover(index())}
onMouseOut={() => setTabHover(null)}
onMouseUp={() => {
@@ -828,13 +822,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
}}
</For>
<box
backgroundColor={
confirm()
? theme.background.formfield.selected
: tabHover() === "confirm"
? theme.background.formfield.focused
: theme.background.raised.base
}
backgroundColor={theme.background.raised.base}
onMouseOver={() => setTabHover("confirm")}
onMouseOut={() => setTabHover(null)}
onMouseUp={() => {
@@ -941,7 +929,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
>
<box flexDirection="row">
<box
backgroundColor={active() ? theme.background.formfield.focused : theme.background.raised.base}
backgroundColor={theme.background.raised.base}
paddingRight={1}
>
<text
@@ -949,7 +937,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
>{`${i() + 1}.`}</text>
</box>
<box
backgroundColor={active() ? theme.background.formfield.focused : theme.background.raised.base}
backgroundColor={theme.background.raised.base}
flexDirection="row"
>
<Show when={multi()}>
@@ -995,7 +983,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
>
<box flexDirection="row">
<box
backgroundColor={other() ? theme.background.formfield.focused : theme.background.raised.base}
backgroundColor={theme.background.raised.base}
paddingRight={1}
>
<text fg={other() ? theme.text.formfield.focused : theme.text.muted}>
@@ -1005,7 +993,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
<box
flexDirection="row"
flexGrow={1}
backgroundColor={other() ? theme.background.formfield.focused : theme.background.raised.base}
backgroundColor={theme.background.raised.base}
>
<Show when={multi()}>
<text
+47 -17
View File
@@ -21,6 +21,7 @@ import {
type Verbosity,
defaultVerbosity,
} from "./grouping/session"
import { groupID } from "./anchors"
export type { CacheUsage, PartRef, SessionRow } from "./grouping/session"
/**
@@ -47,7 +48,7 @@ export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessi
const data = useData()
const client = useClient()
const config = useConfig()
const [rows, setRows] = createStore<SessionRow[]>([])
const [rows, setRows] = createStore<(SessionRow & { key?: string })[]>([])
const revertBoundary = () => data.session.get(sessionID())?.revert?.messageID
const turnTokens = () => Boolean(config.data.debug?.turn_tokens)
const verbosity = () => config.data.session?.verbosity ?? defaultVerbosity
@@ -79,6 +80,25 @@ 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) =>
@@ -99,13 +119,13 @@ export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessi
createEffect(
on([sessionID, () => client.connection.status()], ([id, status]) => {
if (status !== "connected") return
setRows(reconcile(reduce()))
rebuild()
void data.session.pending.sync(id).catch(() => undefined)
void data.session.message
.sync(id)
.then(async () => {
if (sessionID() !== id) return
setRows(reconcile(reduce()))
rebuild()
// Restoration waits for complete boundary groups so saved group IDs resolve.
await completeGroupBoundary({
rows,
@@ -122,15 +142,7 @@ 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,
() => {
setRows(reconcile(reduce()))
},
{ defer: true },
),
)
createEffect(on(revertBoundary, rebuild, { defer: true }))
createEffect(
on(
@@ -140,7 +152,7 @@ export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessi
if (item.type === "user" && item.delivery === "queue") return [`${item.id}:queue`]
return []
}),
() => setRows(reconcile(reduce())),
rebuild,
{ defer: true },
),
)
@@ -166,12 +178,12 @@ export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessi
]
: [],
),
() => setRows(reconcile(reduce())),
rebuild,
{ defer: true },
),
)
createEffect(on([turnTokens, verbosity], () => setRows(reconcile(reduce())), { defer: true }))
createEffect(on([turnTokens, verbosity], rebuild, { defer: true }))
const appendMessage = (messageID: string) =>
setRows(
@@ -297,12 +309,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()) setRows(reconcile(reduce()))
if (turnTokens()) rebuild()
}),
data.on("session.step.failed", (event) => {
if (event.data.sessionID !== sessionID()) return
appendFooter(event.data.assistantMessageID)
if (turnTokens()) setRows(reconcile(reduce()))
if (turnTokens()) rebuild()
}),
]
onCleanup(() => subscriptions.forEach((unsubscribe) => unsubscribe()))
@@ -310,6 +322,24 @@ 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>(),
+68 -21
View File
@@ -850,7 +850,7 @@ test("reconnects the event stream and resyncs active data", async () => {
}
})
test("completes exploration when a queued prompt is promoted", async () => {
test("completes exploration and keeps live rows when a queued prompt is promoted", async () => {
const events = createEventStream()
const sessionID = "session-promotion"
const calls = createFetch((url) => {
@@ -858,10 +858,16 @@ test("completes exploration when a queued prompt is promoted", async () => {
}, events)
let rows!: ReturnType<typeof createSessionRows>
let client!: ReturnType<typeof useClient>
let data!: ReturnType<typeof useData>
let synced = false
function Probe() {
client = useClient()
rows = createSessionRows(() => sessionID)
data = useData()
rows = createSessionRows(
() => sessionID,
() => (synced = true),
)
return <box />
}
@@ -879,6 +885,9 @@ test("completes exploration when a queued prompt is promoted", async () => {
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,
@@ -892,11 +901,25 @@ test("completes exploration when a queued prompt is promoted", async () => {
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, 1),
durable: durable(sessionID, 2),
data: {
sessionID,
assistantMessageID: "message-assistant",
@@ -905,12 +928,13 @@ test("completes exploration when a queued prompt is promoted", async () => {
},
})
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, 2),
durable: durable(sessionID, 3),
data: {
sessionID,
inboxID: "message-user",
@@ -924,14 +948,17 @@ test("completes exploration when a queued prompt is promoted", async () => {
id: "evt_prompt_promoted",
created: 4,
type: "session.inbox.delivered",
durable: durable(sessionID, 3),
durable: durable(sessionID, 4),
data: {
sessionID,
inboxID: "message-user",
},
})
await wait(() => rows.find((row) => row.type === "group")?.completed === true)
expect(rows.at(-1)).toEqual({ type: "message", messageID: "message-user" })
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)
} finally {
app.renderer.destroy()
}
@@ -980,7 +1007,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({ type: "message", messageID: "message-queued" })
expect(rows).not.toContainEqual(expect.objectContaining({ type: "message", messageID: "message-queued" }))
emitEvent(events, {
id: "evt_queue_steered",
@@ -994,7 +1021,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({ type: "message", messageID: "message-queued" })
expect(rows).toContainEqual(expect.objectContaining({ type: "message", messageID: "message-queued" }))
emitEvent(events, {
id: "evt_queue_restored",
@@ -1008,7 +1035,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({ type: "message", messageID: "message-queued" })
expect(rows).not.toContainEqual(expect.objectContaining({ type: "message", messageID: "message-queued" }))
emitEvent(events, {
id: "evt_cancel_admitted",
@@ -1080,17 +1107,19 @@ test("classifies live tool rows independently of their call ID", async () => {
})
await wait(() => rows.length > 0)
expect(rows).toEqual([{ type: "part", ref: { messageID: "message-assistant", partID: "reasoning:0" } }])
expect(unwrap(rows)).toMatchObject([
{ 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", async () => {
test("loads older pages until the oldest exploration group is complete before reporting sync and keeps existing rows", async () => {
const events = createEventStream()
const sessionID = "session-boundary"
const model = { id: "model", providerID: "provider" }
// One prompt, then 50 single-read steps: the 20-message first page cuts the group.
// One prompt, 50 single-read steps, then an answer: 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) => ({
@@ -1110,11 +1139,23 @@ 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 calls = createFetch((url) => {
const older = Promise.withResolvers<void>()
const calls = createFetch(async (url) => {
if (url.pathname !== `/api/session/${sessionID}/message`) return
const end = Number(url.searchParams.get("cursor") ?? history.length)
const cursor = url.searchParams.get("cursor")
if (cursor) await older.promise
const end = Number(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) } : {} })
@@ -1146,14 +1187,20 @@ 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(["31-51", "11-31", "0-11"])
expect(pages).toEqual(["32-52", "12-32", "0-12"])
// Sync is reported only once the group's true first read is loaded.
expect(synced[0]).toEqual({ type: "message", messageID: "msg_000" })
expect(synced[0]).toMatchObject({ 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()
}
@@ -1603,7 +1650,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")).toEqual([
expect(manualRows.filter((row) => row.type === "message")).toMatchObject([
{ type: "message", messageID: "message-compaction" },
])
expect(manualRows.find((row) => row.type === "message" && row.messageID === "message-compaction")).toBe(
@@ -1706,7 +1753,7 @@ test.each(["before", "between", "after"])("shows compaction admitted %s steers i
}),
)
await wait(() => rows.length === 3)
expect(rows).toEqual([
expect(unwrap(rows)).toMatchObject([
{ type: "compaction-queued", inboxID: "compact" },
{ type: "message", messageID: "a" },
{ type: "message", messageID: "b" },
@@ -1719,7 +1766,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(rows).toEqual(["compact", "a", "b"].map((messageID) => ({ type: "message", messageID })))
expect(unwrap(rows)).toMatchObject(["compact", "a", "b"].map((messageID) => ({ type: "message", messageID })))
emitEvent(events, {
id: "evt_compaction_ended",
created: 5,
@@ -1737,7 +1784,7 @@ test.each(["before", "between", "after"])("shows compaction admitted %s steers i
})
}
await app.renderOnce()
expect(rows).toEqual(["compact", "a", "b"].map((messageID) => ({ type: "message", messageID })))
expect(unwrap(rows)).toMatchObject(["compact", "a", "b"].map((messageID) => ({ type: "message", messageID })))
} finally {
app.renderer.destroy()
}
@@ -1799,7 +1846,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")).toEqual([
expect(rows.filter((row) => row.type === "compaction-queued")).toMatchObject([
{ type: "compaction-queued", inboxID: "message-compaction-queued" },
{ type: "compaction-queued", inboxID: "message-compaction-later" },
])
@@ -110,6 +110,7 @@ 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` |
@@ -201,6 +202,7 @@ 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 | - |
@@ -309,6 +311,7 @@ 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.
@@ -363,6 +366,7 @@ 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.