Compare commits

...

11 Commits

Author SHA1 Message Date
Ryan Vogel 705c3ab746 feat(core): let agents create recurring and one-shot schedules
Adds schedule_create, schedule_list and schedule_cancel tools in the
opencode namespace so the agent can schedule prompts back into its own
session. schedule_create accepts `every` (30s/15m/2h/1d), `at` (ISO 8601
with offset) for a single run, or both (`at` sets the first run).

SessionSchedule now stores multiple schedules per session, keyed by a
new `sch_` ID, and persists the absolute next fire time instead of only
the interval. This replaces the one-schedule-per-session model and fixes
restarts resetting the countdown: one-shots fire at their original time
after a restart, and overdue recurring schedules fire once then resume
their cadence rather than bursting. `/schedule off` now cancels every
schedule in the session. Past `at` times, missing timing and invalid
intervals return tool errors.

Verified with an isolated managed-mode server and a scripted
OpenAI-compatible mock model driving `execute`: create every 5s fired
every 5s; `at` fired at 15:55:42.013 for a 15:55:42 target; a one-shot
created before a restart fired on time after it (15:56:23.013); list
returned both schedules with server time; cancel stopped firing; invalid
inputs returned the three expected errors. core and server typecheck.
2026-09-29 11:57:09 -04:00
Ryan Vogel e9f17d5ebe chore(core): format session schedule service
Prettier wrapped the long catchCause line; no behavior change.
2026-09-29 11:51:17 -04:00
Ryan Vogel a5ceab882e feat(core): run session schedules as global background jobs
Moves schedule timers out of the per-Location plugin into a new global
SessionSchedule service. Previously timers lived in the Location's plugin
scope, so they stopped on server restart until something reloaded the
Location, and stopped whenever the Location was evicted after 60 minutes
of inactivity. Timers now live in the global service and call
Session.prompt, which loads the Location on demand, so schedules fire
even when the session is not open.

- SessionSchedule persists entries in KV (session.schedule/<id>) and
  exposes set/remove/resume. A deleted session ends its schedule.
- The managed server calls resume at boot next to restart continuity;
  unmanaged servers sharing the database do not restore schedules.
- The opencode.schedule plugin now only registers /schedule and
  delegates to the service; the per-Location restore logic is removed.

Verified: core and server typecheck; against an isolated managed-mode
server, a 5s schedule fired every 5s, resumed after a restart with no
client touching the session, and `/schedule off` stopped the
boot-restored timer. Server tests: 3 OpenAI OAuth port tests fail
identically without this change.
2026-09-29 11:43:20 -04:00
Ryan Vogel e0709f8de0 feat(core): add /schedule command for recurring session prompts
Adds a minimal built-in plugin (opencode.schedule) that registers
`/schedule <30s|15m|2h> <prompt>` and `/schedule off`. Each schedule is
saved in plugin storage keyed by session ID and re-queues the prompt with
delivery "queue" on a fixed interval, modeled on the warming plugin loop.
When a location loads, the plugin restarts only schedules whose session
belongs to that location, so a session is never double-scheduled.

Known gaps (prototype): schedules resume after a server restart only once
something loads the session's location, and intervals longer than the
60-minute location inactivity TTL stop when the location is evicted.

Verified: core typecheck passes; against an isolated `serve` instance, a
5s schedule admitted prompts every 5s, stopped across a restart, and
resumed after the session's location was reloaded by a manual prompt.
2026-09-29 11:35:53 -04:00
opencode-agent[bot] 948047a8ca fix(tui): use disclosure icon for low activity errors (#52097)
Co-authored-by: thdxr <826656+thdxr@users.noreply.github.com>
2026-09-29 11:10:32 -04:00
opencode-agent[bot] 0f931d3939 chore(core): refresh bundled models.dev snapshot 2026-09-29 12:22:53 +00:00
Kit Langton 7ef4a1a56e feat(tui): show recently closed tabs in new session menu 2026-09-29 03:35:31 -07:00
Victor Navarro b29dab3231 fix(core): drop SSO link from Console sign-in error (#52062) 2026-09-29 12:04:27 +02:00
Victor Navarro d76f968c60 feat(core): tell users how to sign back in to OpenCode Console (#51847) 2026-09-29 10:58:45 +02:00
opencode-agent[bot] b30c4d00d1 fix(ai): enable explicit caching on more message protocol routes (#51981)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-29 00:50:08 -05:00
Aiden Cline 6712cc4c2c fix(core): turn off Workers AI thinking through the chat template (#52017) 2026-09-29 00:45:17 -05:00
68 changed files with 1294 additions and 207 deletions
+6
View File
@@ -38,9 +38,15 @@ const resolve = (policy: CachePolicy | undefined): CachePolicyObject => {
// prefix caching, Gemini's implicit + out-of-band CachedContent). Skip the
// whole policy pass for these — emitting hints would be harmless but pointless.
const RESPECTS_INLINE_HINTS = new Set([
"alibaba-messages",
"anthropic-messages",
"anthropic-compatible-messages",
"cloudflare-ai-gateway-messages",
"google-vertex-messages",
"meta-messages",
"minimax-messages",
"moonshot-messages",
"zai-coding-messages",
"bedrock-converse",
"openrouter",
])
+11 -6
View File
@@ -10,14 +10,19 @@ const WebSearch = Schema.Struct({
name: Schema.Literal("web_search"),
user_location: MetaResponses.WebSearch.fields.user_location,
})
const MetaCacheControl = Schema.Struct({
type: Schema.tag("ephemeral"),
ttl: Schema.optional(Schema.Literals(["5m", "1h"])),
})
const FunctionTool = Schema.Struct({
name: Schema.String,
description: Schema.String,
input_schema: JsonObject,
cache_control: Schema.optional(MetaCacheControl),
})
const Body = Schema.Struct({
...AnthropicMessages.AnthropicMessagesBody.fields,
tools: optionalArray(
Schema.Union([
Schema.Struct({ name: Schema.String, description: Schema.String, input_schema: JsonObject }),
WebSearch,
]),
),
tools: optionalArray(Schema.Union([FunctionTool, WebSearch])),
})
const fromRequest = Effect.fn("MetaMessages.fromRequest")(function* (request: LLMRequest) {
+39 -1
View File
@@ -3,7 +3,17 @@ import { Effect } from "effect"
import { CacheHint, LLM, Message } from "../src/index.js"
import { Auth } from "../src/route.js"
import { compileRequest } from "../src/route/client.js"
import { AmazonBedrock, AnthropicCompatible, GoogleVertexMessages } from "../src/providers.js"
import {
Alibaba,
AmazonBedrock,
AnthropicCompatible,
CloudflareAIGateway,
GoogleVertexMessages,
Meta,
MiniMax,
Moonshot,
ZAICodingPlan,
} from "../src/providers.js"
import * as AnthropicMessages from "../src/protocols/anthropic-messages.js"
import * as Gemini from "../src/protocols/gemini.js"
import * as OpenAIChat from "../src/protocols/openai-chat.js"
@@ -127,6 +137,34 @@ describe("applyCachePolicy", () => {
}),
)
const messagesModels = [
["alibaba-messages", Alibaba.configure({ region: "ap-southeast-1", apiKey: "test" }).messages("qwen3.8-max")],
[
"cloudflare-ai-gateway-messages",
CloudflareAIGateway.configure({ accountId: "test", gatewayId: "test", apiKey: "test" }).model(
"anthropic/claude-sonnet-4-6",
),
],
["meta-messages", Meta.configure({ apiKey: "test" }).messages("muse-spark-1.3")],
["minimax-messages", MiniMax.configure({ apiKey: "test" }).model("MiniMax-M3")],
["moonshot-messages", Moonshot.configure({ apiKey: "test" }).messages("kimi-k3")],
["zai-coding-messages", ZAICodingPlan.configure({ apiKey: "test" }).messages("glm-5.3")],
] as const
messagesModels.forEach(([route, model]) =>
it.effect(`'auto' emits cache markers on ${route}`, () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(LLM.request({ model, system: "Sys", prompt: "hi" }))
expect(prepared.route).toBe(route)
expect(prepared.body).toMatchObject({
system: [{ type: "text", text: "Sys", cache_control: { type: "ephemeral" } }],
messages: [{ role: "user", content: [{ type: "text", text: "hi", cache_control: { type: "ephemeral" } }] }],
})
}),
),
)
it.effect("'auto' is a no-op on OpenAI (implicit caching protocol)", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -10,7 +10,7 @@
"usage"
],
"name": "alibaba-messages/qwen-3-7-plus-streams-thinking-disabled",
"recordedAt": "2026-09-08T03:10:57.819Z"
"recordedAt": "2026-09-29T05:26:24.450Z"
},
"interactions": [
{
@@ -21,14 +21,14 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"qwen3.7-plus\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is 173 multiplied by 219? Reply with only the final integer.\"}]}],\"stream\":true,\"max_tokens\":4096,\"thinking\":{\"type\":\"disabled\"}}"
"body": "{\"model\":\"qwen3.7-plus\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is 173 multiplied by 219? Reply with only the final integer.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"stream\":true,\"max_tokens\":4096,\"thinking\":{\"type\":\"disabled\"}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "event:ping\ndata:{\"type\":\"ping\"}\n\nevent:message_start\ndata:{\"message\":{\"model\":\"qwen3.7-plus\",\"id\":\"msg_c4d58b4f-a61d-9d0c-a9e4-cb45d34b1120\",\"role\":\"assistant\",\"type\":\"message\",\"content\":[],\"usage\":{\"input_tokens\":20,\"output_tokens\":0}},\"type\":\"message_start\"}\n\nevent:content_block_start\ndata:{\"type\":\"content_block_start\",\"content_block\":{\"type\":\"text\",\"text\":\"\"},\"index\":0}\n\nevent:content_block_delta\ndata:{\"delta\":{\"type\":\"text_delta\",\"text\":\"3\"},\"type\":\"content_block_delta\",\"index\":0}\n\nevent:content_block_delta\ndata:{\"delta\":{\"type\":\"text_delta\",\"text\":\"7887\"},\"type\":\"content_block_delta\",\"index\":0}\n\nevent:content_block_stop\ndata:{\"type\":\"content_block_stop\",\"index\":0}\n\nevent:message_delta\ndata:{\"delta\":{\"stop_reason\":\"end_turn\"},\"type\":\"message_delta\",\"usage\":{\"output_tokens\":5,\"cache_creation_input_tokens\":0,\"input_tokens\":32,\"cache_read_input_tokens\":0,\"prompt_tokens_details\":{\"cached_tokens\":0}}}\n\nevent:message_stop\ndata:{\"type\":\"message_stop\"}\n\n"
"body": "event:ping\ndata:{\"type\":\"ping\"}\n\nevent:message_start\ndata:{\"message\":{\"model\":\"qwen3.7-plus\",\"id\":\"msg_39067759-5f01-9b3a-b2c1-09111797f7b5\",\"role\":\"assistant\",\"type\":\"message\",\"content\":[],\"usage\":{\"input_tokens\":20,\"output_tokens\":0}},\"type\":\"message_start\"}\n\nevent:content_block_start\ndata:{\"type\":\"content_block_start\",\"content_block\":{\"type\":\"text\",\"text\":\"\"},\"index\":0}\n\nevent:content_block_delta\ndata:{\"delta\":{\"type\":\"text_delta\",\"text\":\"3\"},\"type\":\"content_block_delta\",\"index\":0}\n\nevent:content_block_delta\ndata:{\"delta\":{\"type\":\"text_delta\",\"text\":\"788\"},\"type\":\"content_block_delta\",\"index\":0}\n\nevent:content_block_delta\ndata:{\"delta\":{\"type\":\"text_delta\",\"text\":\"7\"},\"type\":\"content_block_delta\",\"index\":0}\n\nevent:content_block_stop\ndata:{\"type\":\"content_block_stop\",\"index\":0}\n\nevent:message_delta\ndata:{\"delta\":{\"stop_reason\":\"end_turn\"},\"type\":\"message_delta\",\"usage\":{\"cache_creation\":{\"ephemeral_5m_input_tokens\":0},\"output_tokens\":5,\"cache_creation_input_tokens\":0,\"input_tokens\":32,\"cache_read_input_tokens\":0,\"prompt_tokens_details\":{\"cached_tokens\":0}}}\n\nevent:message_stop\ndata:{\"type\":\"message_stop\"}\n\n"
}
}
]
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -9,7 +9,7 @@
"structured-output"
],
"name": "alibaba-messages/qwen-3-8-max-follows-a-json-schema",
"recordedAt": "2026-09-08T03:11:30.530Z"
"recordedAt": "2026-09-29T05:26:41.466Z"
},
"interactions": [
{
@@ -20,14 +20,14 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"qwen3.8-max\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Return a JSON object with one key \\\"city\\\" set to the capital city of France.\"}]}],\"stream\":true,\"max_tokens\":1024,\"thinking\":{\"type\":\"disabled\"},\"output_config\":{\"format\":{\"type\":\"json_schema\",\"schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}}"
"body": "{\"model\":\"qwen3.8-max\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Return a JSON object with one key \\\"city\\\" set to the capital city of France.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"stream\":true,\"max_tokens\":1024,\"thinking\":{\"type\":\"disabled\"},\"output_config\":{\"format\":{\"type\":\"json_schema\",\"schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "event:ping\ndata:{\"type\":\"ping\"}\n\nevent:message_start\ndata:{\"message\":{\"model\":\"qwen3.8-max\",\"id\":\"msg_16e067c1-984b-94d3-9abb-11792d794271\",\"role\":\"assistant\",\"type\":\"message\",\"content\":[],\"usage\":{\"input_tokens\":18,\"output_tokens\":0}},\"type\":\"message_start\"}\n\nevent:content_block_start\ndata:{\"type\":\"content_block_start\",\"content_block\":{\"type\":\"text\",\"text\":\"\"},\"index\":0}\n\nevent:content_block_delta\ndata:{\"delta\":{\"type\":\"text_delta\",\"text\":\"{\\\"\"},\"type\":\"content_block_delta\",\"index\":0}\n\nevent:content_block_delta\ndata:{\"delta\":{\"type\":\"text_delta\",\"text\":\"city\\\":\"},\"type\":\"content_block_delta\",\"index\":0}\n\nevent:content_block_delta\ndata:{\"delta\":{\"type\":\"text_delta\",\"text\":\" \\\"Paris\\\"}\"},\"type\":\"content_block_delta\",\"index\":0}\n\nevent:content_block_stop\ndata:{\"type\":\"content_block_stop\",\"index\":0}\n\nevent:message_delta\ndata:{\"delta\":{\"stop_reason\":\"end_turn\"},\"type\":\"message_delta\",\"usage\":{\"output_tokens\":6,\"cache_creation_input_tokens\":0,\"input_tokens\":32,\"cache_read_input_tokens\":0,\"prompt_tokens_details\":{\"cached_tokens\":0}}}\n\nevent:message_stop\ndata:{\"type\":\"message_stop\"}\n\n"
"body": "event:ping\ndata:{\"type\":\"ping\"}\n\nevent:message_start\ndata:{\"message\":{\"model\":\"qwen3.8-max\",\"id\":\"msg_61ee9b9d-a224-9b69-98f7-9f9ea3937b41\",\"role\":\"assistant\",\"type\":\"message\",\"content\":[],\"usage\":{\"input_tokens\":18,\"output_tokens\":0}},\"type\":\"message_start\"}\n\nevent:content_block_start\ndata:{\"type\":\"content_block_start\",\"content_block\":{\"type\":\"text\",\"text\":\"\"},\"index\":0}\n\nevent:content_block_delta\ndata:{\"delta\":{\"type\":\"text_delta\",\"text\":\"{\\\"\"},\"type\":\"content_block_delta\",\"index\":0}\n\nevent:content_block_delta\ndata:{\"delta\":{\"type\":\"text_delta\",\"text\":\"city\\\":\"},\"type\":\"content_block_delta\",\"index\":0}\n\nevent:content_block_delta\ndata:{\"delta\":{\"type\":\"text_delta\",\"text\":\" \\\"Paris\\\"}\"},\"type\":\"content_block_delta\",\"index\":0}\n\nevent:content_block_stop\ndata:{\"type\":\"content_block_stop\",\"index\":0}\n\nevent:message_delta\ndata:{\"delta\":{\"stop_reason\":\"end_turn\"},\"type\":\"message_delta\",\"usage\":{\"cache_creation\":{\"ephemeral_5m_input_tokens\":0},\"output_tokens\":6,\"cache_creation_input_tokens\":0,\"input_tokens\":32,\"cache_read_input_tokens\":0,\"prompt_tokens_details\":{\"cached_tokens\":0}}}\n\nevent:message_stop\ndata:{\"type\":\"message_stop\"}\n\n"
}
}
]
@@ -10,7 +10,7 @@
"tool-choice"
],
"name": "alibaba-messages/qwen-3-8-max-obeys-named-tool-choice",
"recordedAt": "2026-09-08T03:11:06.590Z"
"recordedAt": "2026-09-29T05:26:40.212Z"
},
"interactions": [
{
@@ -21,14 +21,14 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"qwen3.8-max\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Find the current weather in Paris.\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get weather in a city\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"]}}],\"tool_choice\":{\"type\":\"tool\",\"name\":\"get_weather\"},\"stream\":true,\"max_tokens\":4096,\"thinking\":{\"type\":\"disabled\"}}"
"body": "{\"model\":\"qwen3.8-max\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Find the current weather in Paris.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get weather in a city\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"]},\"cache_control\":{\"type\":\"ephemeral\"}}],\"tool_choice\":{\"type\":\"tool\",\"name\":\"get_weather\"},\"stream\":true,\"max_tokens\":4096,\"thinking\":{\"type\":\"disabled\"}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "event:ping\ndata:{\"type\":\"ping\"}\n\nevent:message_start\ndata:{\"message\":{\"model\":\"qwen3.8-max\",\"id\":\"msg_0e8f1abf-f2bc-9a86-a6a7-14804ac17eac\",\"role\":\"assistant\",\"type\":\"message\",\"content\":[],\"usage\":{\"input_tokens\":45,\"output_tokens\":0}},\"type\":\"message_start\"}\n\nevent:content_block_start\ndata:{\"type\":\"content_block_start\",\"content_block\":{\"name\":\"get_weather\",\"input\":{},\"id\":\"toolu_cf9cab33261f4709ae096d8a\",\"type\":\"tool_use\"},\"index\":0}\n\nevent:content_block_delta\ndata:{\"delta\":{\"partial_json\":\"\",\"type\":\"input_json_delta\"},\"type\":\"content_block_delta\",\"index\":0}\n\nevent:content_block_delta\ndata:{\"delta\":{\"partial_json\":\"{\\\"city\\\": \\\"Paris\",\"type\":\"input_json_delta\"},\"type\":\"content_block_delta\",\"index\":0}\n\nevent:content_block_delta\ndata:{\"delta\":{\"partial_json\":\"\\\"\",\"type\":\"input_json_delta\"},\"type\":\"content_block_delta\",\"index\":0}\n\nevent:content_block_delta\ndata:{\"delta\":{\"partial_json\":\"}\",\"type\":\"input_json_delta\"},\"type\":\"content_block_delta\",\"index\":0}\n\nevent:content_block_stop\ndata:{\"type\":\"content_block_stop\",\"index\":0}\n\nevent:message_delta\ndata:{\"delta\":{\"stop_reason\":\"end_turn\"},\"type\":\"message_delta\",\"usage\":{\"output_tokens\":19,\"cache_creation_input_tokens\":0,\"input_tokens\":288,\"cache_read_input_tokens\":0,\"prompt_tokens_details\":{\"cached_tokens\":0}}}\n\nevent:message_stop\ndata:{\"type\":\"message_stop\"}\n\n"
"body": "event:ping\ndata:{\"type\":\"ping\"}\n\nevent:message_start\ndata:{\"message\":{\"model\":\"qwen3.8-max\",\"id\":\"msg_dab3e1c5-e75e-978d-902e-1b0a82918cff\",\"role\":\"assistant\",\"type\":\"message\",\"content\":[],\"usage\":{\"input_tokens\":45,\"output_tokens\":0}},\"type\":\"message_start\"}\n\nevent:content_block_start\ndata:{\"type\":\"content_block_start\",\"content_block\":{\"name\":\"get_weather\",\"input\":{},\"id\":\"toolu_9a1c3c06a04744cc97df33a2\",\"type\":\"tool_use\"},\"index\":0}\n\nevent:content_block_delta\ndata:{\"delta\":{\"partial_json\":\"\",\"type\":\"input_json_delta\"},\"type\":\"content_block_delta\",\"index\":0}\n\nevent:content_block_delta\ndata:{\"delta\":{\"partial_json\":\"{\\\"city\\\": \\\"Paris\",\"type\":\"input_json_delta\"},\"type\":\"content_block_delta\",\"index\":0}\n\nevent:content_block_delta\ndata:{\"delta\":{\"partial_json\":\"\\\"\",\"type\":\"input_json_delta\"},\"type\":\"content_block_delta\",\"index\":0}\n\nevent:content_block_delta\ndata:{\"delta\":{\"partial_json\":\"}\",\"type\":\"input_json_delta\"},\"type\":\"content_block_delta\",\"index\":0}\n\nevent:content_block_stop\ndata:{\"type\":\"content_block_stop\",\"index\":0}\n\nevent:message_delta\ndata:{\"delta\":{\"stop_reason\":\"end_turn\"},\"type\":\"message_delta\",\"usage\":{\"cache_creation\":{\"ephemeral_5m_input_tokens\":0},\"output_tokens\":19,\"cache_creation_input_tokens\":0,\"input_tokens\":288,\"cache_read_input_tokens\":0,\"prompt_tokens_details\":{\"cached_tokens\":0}}}\n\nevent:message_stop\ndata:{\"type\":\"message_stop\"}\n\n"
}
}
]
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -2,9 +2,16 @@
"version": 1,
"metadata": {
"model": "muse-spark-1.3",
"tags": ["prefix:meta-messages", "provider:meta", "protocol:meta-messages", "tool", "tool-loop", "reasoning"],
"tags": [
"prefix:meta-messages",
"provider:meta",
"protocol:meta-messages",
"tool",
"tool-loop",
"reasoning"
],
"name": "meta-messages/replays-encrypted-thinking-through-a-tool-loop",
"recordedAt": "2026-09-07T17:27:03.540Z"
"recordedAt": "2026-09-29T05:29:52.700Z"
},
"interactions": [
{
@@ -15,14 +22,14 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"muse-spark-1.3\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Look up the current weather in Paris using lookup_weather. After receiving the result, report Paris's weather in one short sentence.\"}]}],\"tools\":[{\"name\":\"lookup_weather\",\"description\":\"Look up current weather\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"tool_choice\":{\"type\":\"auto\"},\"stream\":true,\"max_tokens\":1024,\"thinking\":{\"type\":\"adaptive\",\"display\":\"omitted\"},\"output_config\":{\"effort\":\"low\"}}"
"body": "{\"model\":\"muse-spark-1.3\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Look up the current weather in Paris using lookup_weather. After receiving the result, report Paris's weather in one short sentence.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"tools\":[{\"name\":\"lookup_weather\",\"description\":\"Look up current weather\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"cache_control\":{\"type\":\"ephemeral\"}}],\"tool_choice\":{\"type\":\"auto\"},\"stream\":true,\"max_tokens\":1024,\"thinking\":{\"type\":\"adaptive\",\"display\":\"omitted\"},\"output_config\":{\"effort\":\"low\"}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "event: message_start\ndata: {\"message\":{\"content\":[],\"id\":\"msg_6a9ef3e5a96d9a155dd4463b\",\"model\":\"muse-spark-1.3\",\"role\":\"assistant\",\"stop_reason\":null,\"stop_sequence\":null,\"type\":\"message\",\"usage\":{\"input_tokens\":0,\"output_tokens\":0}},\"type\":\"message_start\"}\n\nevent: content_block_start\ndata: {\"content_block\":{\"data\":\"Q-PaDgGyJ4hIKk41uslnSV0PGvrTkwJ5D-t5skfjtkIt-ABXsehMcLKJJ8RJHRKW2-XhkpPrex0aOkIdqWl99vpCgtOHIFFaSc4b3oGxA8XDx4T_2aKANfYrR1DYwYzGe6ZZ-DQnU0bnpVUzCcXkghkLdyTcJr2p8cvVo1rymFpB0wZsbQRBhxCMR6PrY4i0aOTe8_waq_Po1a4l3YzKnzZIIvYP090o5kltv7MAwqChXjQeTJlKq7ECFa6HVeoTa43oiLmoD2Bzih0VfvBvQfbSh2uDypRM7-f65sp35-VuZfwsM33ZTvefzDbd8zd0D50I6wcybsj8gulDhWWpY7cxoN1Xasy-ImvisACGeppIN66maIFq5dMT2s8_CQ8a2EQ0hw9kwJaIiygZCdRofs-T1yaGWvnQxL6MpNh6SXux9T1ZfuexNrXOhz5cR3_s1euhq3-hI3GDCUDLAtkjiigSE3w6PExrwHTHnvkuAOTP0Lw7DH4NtKv-RFFeZaQOk_0Bi2EV43tR0Hq_chtEDiSpoezDjexPWfdZvhRIaO6PwyJaQ8jVOaLUr6bh6jQ3IM19lv6R7lY4Neno8fLxMwTbf7v7q02lfhOaZe3jR1fqOFUq8e4VXV76_rJBgzThqPNqO8OI_betgX5d2KMcwWAkaZjmrP3rxA7g5xp_Q7bkFiBaaTd3HbYIk_cKbtLOS6TIIphb4SX6ADA2qJ0zoo98P2NOceZODT2WrLjKCHl5dT6b9l2feH9m21pW576ULfKhVSMzuy0cmmWI7rv2P4q-2Fw2klsDVJAc6q22bFjfDgzhybKuhuM_p1SYb8aswNrgggV-cqHWDpF1FdVpL5fHMO74l1uD8Sj_9wuuD0asMQustuvsnYq2EdI0lLvONFMApWCU0s3QA8_P0Iyf0YoCZOm5QGnA7l3O5nAPkFF4kxLSsgRe9zuP6A4oKBDLN8EHwZ3pB4WZGWpUT12cJxplmT3_n8xKaMgaz13qs27uvUm3wMI1seyfpkMwUPHmt1ftpk9f_1OAg3fvQgIkWPTp6K9NzVzEry6SP7zbPfls8yn529Qrbki7ZM_oz3xEvDg367ZCe97eVnjiqRrYsYM2MmDsQq9lUwkgXb84kpK6a4pEzX9NDvhTqhw7RI6RL9E-2Ki4ciUfHv5LVTZ0rNweCo6SYXC11f8FCeObIW_Esr2mqYpORFS4SDMU-4I1HQfD9z6cPjL22APpv9a7Pp1phI8x_aQZkPU7LkyBWyVNDeusI7wq_LC0UgJ0vCAguKcAvN7gxwpCo5MEEZivRuYBldEMwRo4he2WQQke9KEokqREdlPF3Lvaav0fqPfXqhJyHAq8bYBHWdzMjDJ_dJwhdbLQ-iNLmrK5JNg2vPDglWSznBXbrSX4iyMFk1Ni2Y1SQUIOOxS_InIwCg\",\"type\":\"redacted_thinking\"},\"index\":0,\"type\":\"content_block_start\"}\n\nevent: content_block_stop\ndata: {\"index\":0,\"type\":\"content_block_stop\"}\n\nevent: content_block_start\ndata: {\"content_block\":{\"text\":\"\",\"type\":\"text\"},\"index\":1,\"type\":\"content_block_start\"}\n\nevent: content_block_delta\ndata: {\"delta\":{\"text\":\"I'll look up the current weather in Paris.\",\"type\":\"text_delta\"},\"index\":1,\"type\":\"content_block_delta\"}\n\nevent: content_block_stop\ndata: {\"index\":1,\"type\":\"content_block_stop\"}\n\nevent: content_block_start\ndata: {\"content_block\":{\"id\":\"call_01a07ce8bb767c109c12f0a202e2ac19\",\"input\":{},\"name\":\"lookup_weather\",\"type\":\"tool_use\"},\"index\":2,\"type\":\"content_block_start\"}\n\nevent: content_block_delta\ndata: {\"delta\":{\"partial_json\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"type\":\"input_json_delta\"},\"index\":2,\"type\":\"content_block_delta\"}\n\nevent: content_block_stop\ndata: {\"index\":2,\"type\":\"content_block_stop\"}\n\nevent: message_delta\ndata: {\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null},\"type\":\"message_delta\",\"usage\":{\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":113,\"input_tokens\":451,\"output_tokens\":155,\"output_tokens_details\":{\"thinking_tokens\":87}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
"body": "event: message_start\ndata: {\"message\":{\"content\":[],\"id\":\"msg_6abb4ccec3772e65cd44417d\",\"model\":\"muse-spark-1.3\",\"role\":\"assistant\",\"stop_reason\":null,\"stop_sequence\":null,\"type\":\"message\",\"usage\":{\"input_tokens\":0,\"output_tokens\":0}},\"type\":\"message_start\"}\n\nevent: content_block_start\ndata: {\"content_block\":{\"data\":\"Q-PaDgFT_IXY3RXUUduHqe1_WSgTF5_gMh29lCBV6vhfXW34eU6nJO7gUfRPczMK4nZfQhrqQewRujwjtNKR69DHHqTP35Q0jDnuZ4GZX9qBZT1OH9JhcMl1FxvccpatEcuH2Nju9Rf12KFHAOm9eKK6wFiaZgEHcT1wPbRYVZVZ8VxLeKk-ED3wkmWFhUFJRLSgJguWBMvEMbtIUlAKsJ6qkUxClfQKMm3eft1yf9ajseT32eloGzBE48IhyXu6tpeUDs9UatFgZOHKtHmvtKsPqRw-ddbimLnMtdkexwa8xR7EVTVuwBezunqhssfYAdR4c4t6VDpACh2UL3LcVUF9b0reiwpUfdpYrwjpB3bTCWyzGjwvIwGeuoR6VrWHz5nqVbt3bBprJXqJ4IRLKAXFFlMhClkz0MFNPBufcXSdh9i9NjbNp5c8lXCSkhMfhYX9B9KbQKl3DxUCjEY_VZk0nQJwt9zQVwTzWPzl9fTkBWrdebpXCg-z4lPmU5GLyGY3MlF24eDoSfJfqibF1HHqztluSHi4XEulrwMh4dpZmLhlLxlFxwnC8LjY_Rg8uOyzeoCzsMj-R9WrZ6n9jLZrxJPVo17nFjsuSwt6zMRS9Cna6adGFso0jIKF4BNTcZXlVt0b_TWA9AY4xpGSaAQXKYRBdAZNzTK5hj-bvyfWVTBepTBKm7pAtJW2e05mVBFDqRwEr3kcKkMTRMEj8vztSX4sNjV7Sw6bDbPjb_uFeoaNkq1ua9N7ZkKsyLn80ysWQ6IL-B_AVM1toAI0bVVYQmHbRSz1MgybcT5vjK2TWfCgxBuqw1ARU3oEM1iWTvpuHkFDNGeZnT4IWc_z_vLtQVnqSlqXPtnAmJ0WJgvQGSBhojSa9QMxRz_l4LZHmXUtfdMdUHTbrZwbfoL6eIyZf43ah-sXILJWTX9wXuYqnaclwvuO3EFiDUioSIBxWvAj2HNe5HpzayuvJqneRAukiVynt9SOo79VcBKAFsHW_KhUz58KjndfvOFP7byXv_W6Xa00ve_3GEaCANjFIl7ratBEuFYtpbVTT7TGIyGmuPQTVnGe7CAcFiAvGSwoaMwqvdGheKxeR7_cZvSYREeUnL5mXev8-MKY6_AtddOanQhf9pshxAXrIUtVFqin5MGhL2j787niURU9-o_VhLgzaIqfjbfMKkzdZ3-ESyDloOeD3CwoAsD_AVIrQZQHYbpYzFYasH0VrItar07ItxWZt_sCehTOi7Jtk5G8e7fd1HtCWmrp2iArioSIp8aLOphI9lazM7taIpfF_y0EMt3x5ENSvhtEmSk4kCJQnNyRv21uHT4uD0XjdQJbHeoRhzHDxoLHUOqAX9fGBzMFO8ZsWJxl8ebiCJeJ9Jzzr9bXeUKH5cP2VymoXPs3AWBrTpw1WfADM5sjHDJLzw\",\"type\":\"redacted_thinking\"},\"index\":0,\"type\":\"content_block_start\"}\n\nevent: content_block_stop\ndata: {\"index\":0,\"type\":\"content_block_stop\"}\n\nevent: content_block_start\ndata: {\"content_block\":{\"text\":\"\",\"type\":\"text\"},\"index\":1,\"type\":\"content_block_start\"}\n\nevent: content_block_delta\ndata: {\"delta\":{\"text\":\"I'll look up the current weather in Paris.\",\"type\":\"text_delta\"},\"index\":1,\"type\":\"content_block_delta\"}\n\nevent: content_block_stop\ndata: {\"index\":1,\"type\":\"content_block_stop\"}\n\nevent: content_block_start\ndata: {\"content_block\":{\"id\":\"call_01a0eba40810714cbd0a8a84496badc7\",\"input\":{},\"name\":\"lookup_weather\",\"type\":\"tool_use\"},\"index\":2,\"type\":\"content_block_start\"}\n\nevent: content_block_delta\ndata: {\"delta\":{\"partial_json\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"type\":\"input_json_delta\"},\"index\":2,\"type\":\"content_block_delta\"}\n\nevent: content_block_stop\ndata: {\"index\":2,\"type\":\"content_block_stop\"}\n\nevent: message_delta\ndata: {\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null},\"type\":\"message_delta\",\"usage\":{\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"input_tokens\":564,\"output_tokens\":97,\"output_tokens_details\":{\"thinking_tokens\":29}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
}
},
{
@@ -33,14 +40,14 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"muse-spark-1.3\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Look up the current weather in Paris using lookup_weather. After receiving the result, report Paris's weather in one short sentence.\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"redacted_thinking\",\"data\":\"Q-PaDgGyJ4hIKk41uslnSV0PGvrTkwJ5D-t5skfjtkIt-ABXsehMcLKJJ8RJHRKW2-XhkpPrex0aOkIdqWl99vpCgtOHIFFaSc4b3oGxA8XDx4T_2aKANfYrR1DYwYzGe6ZZ-DQnU0bnpVUzCcXkghkLdyTcJr2p8cvVo1rymFpB0wZsbQRBhxCMR6PrY4i0aOTe8_waq_Po1a4l3YzKnzZIIvYP090o5kltv7MAwqChXjQeTJlKq7ECFa6HVeoTa43oiLmoD2Bzih0VfvBvQfbSh2uDypRM7-f65sp35-VuZfwsM33ZTvefzDbd8zd0D50I6wcybsj8gulDhWWpY7cxoN1Xasy-ImvisACGeppIN66maIFq5dMT2s8_CQ8a2EQ0hw9kwJaIiygZCdRofs-T1yaGWvnQxL6MpNh6SXux9T1ZfuexNrXOhz5cR3_s1euhq3-hI3GDCUDLAtkjiigSE3w6PExrwHTHnvkuAOTP0Lw7DH4NtKv-RFFeZaQOk_0Bi2EV43tR0Hq_chtEDiSpoezDjexPWfdZvhRIaO6PwyJaQ8jVOaLUr6bh6jQ3IM19lv6R7lY4Neno8fLxMwTbf7v7q02lfhOaZe3jR1fqOFUq8e4VXV76_rJBgzThqPNqO8OI_betgX5d2KMcwWAkaZjmrP3rxA7g5xp_Q7bkFiBaaTd3HbYIk_cKbtLOS6TIIphb4SX6ADA2qJ0zoo98P2NOceZODT2WrLjKCHl5dT6b9l2feH9m21pW576ULfKhVSMzuy0cmmWI7rv2P4q-2Fw2klsDVJAc6q22bFjfDgzhybKuhuM_p1SYb8aswNrgggV-cqHWDpF1FdVpL5fHMO74l1uD8Sj_9wuuD0asMQustuvsnYq2EdI0lLvONFMApWCU0s3QA8_P0Iyf0YoCZOm5QGnA7l3O5nAPkFF4kxLSsgRe9zuP6A4oKBDLN8EHwZ3pB4WZGWpUT12cJxplmT3_n8xKaMgaz13qs27uvUm3wMI1seyfpkMwUPHmt1ftpk9f_1OAg3fvQgIkWPTp6K9NzVzEry6SP7zbPfls8yn529Qrbki7ZM_oz3xEvDg367ZCe97eVnjiqRrYsYM2MmDsQq9lUwkgXb84kpK6a4pEzX9NDvhTqhw7RI6RL9E-2Ki4ciUfHv5LVTZ0rNweCo6SYXC11f8FCeObIW_Esr2mqYpORFS4SDMU-4I1HQfD9z6cPjL22APpv9a7Pp1phI8x_aQZkPU7LkyBWyVNDeusI7wq_LC0UgJ0vCAguKcAvN7gxwpCo5MEEZivRuYBldEMwRo4he2WQQke9KEokqREdlPF3Lvaav0fqPfXqhJyHAq8bYBHWdzMjDJ_dJwhdbLQ-iNLmrK5JNg2vPDglWSznBXbrSX4iyMFk1Ni2Y1SQUIOOxS_InIwCg\"},{\"type\":\"text\",\"text\":\"I'll look up the current weather in Paris.\"},{\"type\":\"tool_use\",\"id\":\"call_01a07ce8bb767c109c12f0a202e2ac19\",\"name\":\"lookup_weather\",\"input\":{\"city\":\"Paris\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_01a07ce8bb767c109c12f0a202e2ac19\",\"content\":\"{\\\"condition\\\":\\\"sunny\\\"}\"}]}],\"tools\":[{\"name\":\"lookup_weather\",\"description\":\"Look up current weather\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"tool_choice\":{\"type\":\"none\"},\"stream\":true,\"max_tokens\":1024,\"thinking\":{\"type\":\"adaptive\",\"display\":\"omitted\"},\"output_config\":{\"effort\":\"low\"}}"
"body": "{\"model\":\"muse-spark-1.3\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Look up the current weather in Paris using lookup_weather. After receiving the result, report Paris's weather in one short sentence.\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"redacted_thinking\",\"data\":\"Q-PaDgFT_IXY3RXUUduHqe1_WSgTF5_gMh29lCBV6vhfXW34eU6nJO7gUfRPczMK4nZfQhrqQewRujwjtNKR69DHHqTP35Q0jDnuZ4GZX9qBZT1OH9JhcMl1FxvccpatEcuH2Nju9Rf12KFHAOm9eKK6wFiaZgEHcT1wPbRYVZVZ8VxLeKk-ED3wkmWFhUFJRLSgJguWBMvEMbtIUlAKsJ6qkUxClfQKMm3eft1yf9ajseT32eloGzBE48IhyXu6tpeUDs9UatFgZOHKtHmvtKsPqRw-ddbimLnMtdkexwa8xR7EVTVuwBezunqhssfYAdR4c4t6VDpACh2UL3LcVUF9b0reiwpUfdpYrwjpB3bTCWyzGjwvIwGeuoR6VrWHz5nqVbt3bBprJXqJ4IRLKAXFFlMhClkz0MFNPBufcXSdh9i9NjbNp5c8lXCSkhMfhYX9B9KbQKl3DxUCjEY_VZk0nQJwt9zQVwTzWPzl9fTkBWrdebpXCg-z4lPmU5GLyGY3MlF24eDoSfJfqibF1HHqztluSHi4XEulrwMh4dpZmLhlLxlFxwnC8LjY_Rg8uOyzeoCzsMj-R9WrZ6n9jLZrxJPVo17nFjsuSwt6zMRS9Cna6adGFso0jIKF4BNTcZXlVt0b_TWA9AY4xpGSaAQXKYRBdAZNzTK5hj-bvyfWVTBepTBKm7pAtJW2e05mVBFDqRwEr3kcKkMTRMEj8vztSX4sNjV7Sw6bDbPjb_uFeoaNkq1ua9N7ZkKsyLn80ysWQ6IL-B_AVM1toAI0bVVYQmHbRSz1MgybcT5vjK2TWfCgxBuqw1ARU3oEM1iWTvpuHkFDNGeZnT4IWc_z_vLtQVnqSlqXPtnAmJ0WJgvQGSBhojSa9QMxRz_l4LZHmXUtfdMdUHTbrZwbfoL6eIyZf43ah-sXILJWTX9wXuYqnaclwvuO3EFiDUioSIBxWvAj2HNe5HpzayuvJqneRAukiVynt9SOo79VcBKAFsHW_KhUz58KjndfvOFP7byXv_W6Xa00ve_3GEaCANjFIl7ratBEuFYtpbVTT7TGIyGmuPQTVnGe7CAcFiAvGSwoaMwqvdGheKxeR7_cZvSYREeUnL5mXev8-MKY6_AtddOanQhf9pshxAXrIUtVFqin5MGhL2j787niURU9-o_VhLgzaIqfjbfMKkzdZ3-ESyDloOeD3CwoAsD_AVIrQZQHYbpYzFYasH0VrItar07ItxWZt_sCehTOi7Jtk5G8e7fd1HtCWmrp2iArioSIp8aLOphI9lazM7taIpfF_y0EMt3x5ENSvhtEmSk4kCJQnNyRv21uHT4uD0XjdQJbHeoRhzHDxoLHUOqAX9fGBzMFO8ZsWJxl8ebiCJeJ9Jzzr9bXeUKH5cP2VymoXPs3AWBrTpw1WfADM5sjHDJLzw\"},{\"type\":\"text\",\"text\":\"I'll look up the current weather in Paris.\"},{\"type\":\"tool_use\",\"id\":\"call_01a0eba40810714cbd0a8a84496badc7\",\"name\":\"lookup_weather\",\"input\":{\"city\":\"Paris\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_01a0eba40810714cbd0a8a84496badc7\",\"content\":\"{\\\"condition\\\":\\\"sunny\\\"}\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"tools\":[{\"name\":\"lookup_weather\",\"description\":\"Look up current weather\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"cache_control\":{\"type\":\"ephemeral\"}}],\"tool_choice\":{\"type\":\"none\"},\"stream\":true,\"max_tokens\":1024,\"thinking\":{\"type\":\"adaptive\",\"display\":\"omitted\"},\"output_config\":{\"effort\":\"low\"}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "event: message_start\ndata: {\"message\":{\"content\":[],\"id\":\"msg_6a9ef3e77f6ef36fdd8f4925\",\"model\":\"muse-spark-1.3\",\"role\":\"assistant\",\"stop_reason\":null,\"stop_sequence\":null,\"type\":\"message\",\"usage\":{\"input_tokens\":0,\"output_tokens\":0}},\"type\":\"message_start\"}\n\nevent: content_block_start\ndata: {\"content_block\":{\"data\":\"Q-PaDgH-GUZkiqnb_seFaP70it_GxAEIUjt2BLllpyzfckiBOUVwr72xGQsSnj0y9aOhoaQ24A_HB0MHh4XWyYziVdRgq6lDPLHkJlmMzbusZxUZUx7D7B8kD8HrBbiTeIFbFlHaM9MiZW_PpYY9fpkdc7aeuX0mORdK8XTT3JPjvW1HPp4Qqp_iEOl9G2iM2Y6xno8SqeTcbc4EQs1LePaKrq86dBPmXBjkgQbYfvIw57o0SBEyelcudtnzJnaluOyOQdV2Ytk_r_xrYSCoBwgbf2KBCOFjdeQyruuwVZZ32JJdVOpWLw8eopUwAo2xYOZP0g8S8hTmOFvDKuzXipm1OoAXwD8Swm6ED3IIo0hHA5xSfMygDCee47nd-EpShNPamkCKodfX1QvePEJsIQK2iTgkh8IGUeEEne5dxgLuXvEAbeqGDvEy6T7IoSgZnc-KPtH6SoWM_kgc_eF_oN73Nxg2prMyCqTUNg5Qs2WLPjA9wSLmmnCoiDr1bYNIuQyn6adgv0-nZZXETJoRAHJBj65Asa8kbLyCYesb192178xCj2aBSdwyj-jc0i328sa9STS7mUSI7KvOt0Yi3kllLs1aSnHW-ogsUJUM7tTf83VO9fRRU_aW4H6qr4OAr8jbBKSD3bxE0AekDd24ZS-8YIjiP1tMOAPZ2JGQrNqbxnaeqhCZzD2nl-E8TMXaAJIk4L3oZtV80xJiUW7mLEf_jQBPAWhph4ujbkDaufGvNrl4FXyBJ-XpKMIA5z0jAYU2b3ul5-Qn5Km7Oc8fPu1M_0XGAOKG-pF9ppr4y-an4B4mKDYoAiSHp3cjv-fW57D87wBfQPaJRjOzWhzqENO-5MlB0CEsnDtrLveE2ui8wSECszParUNk5SEaeSYvrXLY0QkaE7CTK9ljwTKjJM34r1a8o16KFxGuzMrloNlxIYIaO_suM9CDuf8wNwe8KkAV2Kt28g9LwkL5b3mtI7ZSjTdHYOOhFFMzs-sjXboQoGAJGOhMnCQNc3E5-goSzvOzEIXehSTs8Mzyq8d3C2k5F9PSgdO6ZouHVURDgbEXTqWqoqjQ8huk5AbrUX16QwhJambd6P-3i--Idz9CbIVuWIqiMm80TvYlMttobYxTyFDIJrKJTrVA0PkAOFvOMfYt27d7z2ZMrTf6Vtm_DNniuhVbvVU3ZcWbb4LO-XkwH3bfHB-sckpwURP34KzOMvogaqWaMAZlabdd-lQKCc7tMhmo9BrYwHHvLxAELD2qQnyym43Yo1iJNAyrqoUAFk6K029fDon7h9ybmKZdBxRtS3arUesY_91Xep0tA0Y1UYUKo8zKjK3Op_DbVyC5OoR8rxpumKiqk2PtG7RPf-WjEXGkleGqZtxix7ILSkQXxW6McY1r_6LAh2Nv0nUzqS019tTb7mx2OLyt2g\",\"type\":\"redacted_thinking\"},\"index\":0,\"type\":\"content_block_start\"}\n\nevent: content_block_stop\ndata: {\"index\":0,\"type\":\"content_block_stop\"}\n\nevent: content_block_start\ndata: {\"content_block\":{\"text\":\"\",\"type\":\"text\"},\"index\":1,\"type\":\"content_block_start\"}\n\nevent: content_block_delta\ndata: {\"delta\":{\"text\":\"It's sunny in Paris.\",\"type\":\"text_delta\"},\"index\":1,\"type\":\"content_block_delta\"}\n\nevent: content_block_stop\ndata: {\"index\":1,\"type\":\"content_block_stop\"}\n\nevent: message_delta\ndata: {\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"type\":\"message_delta\",\"usage\":{\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"input_tokens\":210,\"output_tokens\":50,\"output_tokens_details\":{\"thinking_tokens\":35}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
"body": "event: message_start\ndata: {\"message\":{\"content\":[],\"id\":\"msg_6abb4cd0b3f15768e1ae4287\",\"model\":\"muse-spark-1.3\",\"role\":\"assistant\",\"stop_reason\":null,\"stop_sequence\":null,\"type\":\"message\",\"usage\":{\"input_tokens\":0,\"output_tokens\":0}},\"type\":\"message_start\"}\n\nevent: content_block_start\ndata: {\"content_block\":{\"data\":\"Q-PaDgF70ZwJMdvZQh6urOFUjgHa2IrdJbBv8RFXDpivwOudmj9QoCi81uRWeArjn_7V2nNk6i6jlHMjN7ACeZUE-faBnl4bTI10Fs0w0SSgcdkMaume2hpXBrZB85wlEttQviz-Pc5EHhAzU5QFIrK83CQkhJENqcfY3NJW1-55JFqlXmWSFV0GtNp3clG7V0LR1Zaq54hqIUY5iwf1pB8VIXLxPca3LwXMGbQPgjOgj_WNKHEBGl6mIafeca1OlGuDrJPN9tWBhlfxJCkcRU0zl14NA8chEyr96N40KrPewjVAlWO3jSejy9V7PnglilNewhUXOEQ75w2tYiZDajBfz6iG_n27zD3uvgD3QQthdyeUhJqlBaX81zMIe39Ctfo-cX-62cKwE3_EHy_7SxkwY5Olbf_bwL4GGzR39MObZoyv8-ZU1kwwVuSLXuDFvIx6iaAMZ50U4xXYbOphAaw2rylql0_kiPlFS9V3Fr_Jvx4vplj-8xh9JI7Uu9t9O7ltECpN9Qfu32fqxzWy9-ZVIh15gzhyegNeICBOcz6b9s3CIFHnzRcU69Ru9-lbydKv7scII_2xxLd-wlHQDCXHXyTeTeZjbTRiul0u5J2m8gGefF-j0lVChJOh8F4vBltzYTFTiQoSP8KtL61VJOt6dLsxOQA0Rq4PR5Lcs2fAfJbKb7jYB23bmXvQQ1d76keuvq_yoSIhrSyxLvXRCxouoWttkI8QC0u7O5pZUIPVfiYMI-j3bbc63BFOu4wAGbckm8Txo-gAY8UYAAwMBX17SD5EcTR82njKmd1R0Y8XOJLiwZRlN0ou-syq8xhahVgd0VcyB1HKr1f9mbhJdqFkKGpWh9-SR6jT6fKCCFxeMA3caczpG5wP_q5SGgyUiK0GWYpHabjSeJWac-04TDOfqL4QwLEKuL4PjlINe9OZY1n1lPJa2_MS9YGt6qlgLe3uZSEBwHk-ul5_yRetU8bd7lYcwDtUHT6puEkdjqeYwYQ8XWkUjoYwvSIMB_2v_owa5p9gPzf5Fr_zWqG7pizrStNNHq1MjXQX8Pl4Z6e2LZ2NjItfjR1GKdErNEJWXyh0oHCQKD1fIOXl64lpQorAHUcKV2HZm_SkwV7kxvzAfhdeT9Fe3AXZmtPBS1m0ZMMYNMYsNKb813JnWu8LvNg_zgXLBWsiB-hl4kCk1NKVAsCoA0i8JUFnGGm_uQp6t06a5NQYEXvi5A0akZTacle4UTBflNdsIVR6tSaO_m49nnr-XYSIh3tEzfjOsXpFz5L_A9SBDdPSGpcMJrs00nl1wDpF78OKC8NhwkIx8ydwF5A-ecXf91SbgqzVgv2T-wzuziA1wjJbPsGzTxP_wULUc2P5-ftFAMpsiRHB_-7hq6ZGo3HwNWjK2DTTN_89u7RCjuErEVPXUv-2fQ\",\"type\":\"redacted_thinking\"},\"index\":0,\"type\":\"content_block_start\"}\n\nevent: content_block_stop\ndata: {\"index\":0,\"type\":\"content_block_stop\"}\n\nevent: content_block_start\ndata: {\"content_block\":{\"text\":\"\",\"type\":\"text\"},\"index\":1,\"type\":\"content_block_start\"}\n\nevent: content_block_delta\ndata: {\"delta\":{\"text\":\"It's sunny in Paris right now.\",\"type\":\"text_delta\"},\"index\":1,\"type\":\"content_block_delta\"}\n\nevent: content_block_stop\ndata: {\"index\":1,\"type\":\"content_block_stop\"}\n\nevent: message_delta\ndata: {\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"type\":\"message_delta\",\"usage\":{\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"input_tokens\":152,\"output_tokens\":55,\"output_tokens_details\":{\"thinking_tokens\":38}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
}
}
]
@@ -2,9 +2,16 @@
"version": 1,
"metadata": {
"model": "muse-spark-1.3",
"tags": ["prefix:meta-messages", "provider:meta", "protocol:meta-messages", "text", "reasoning", "adaptive"],
"tags": [
"prefix:meta-messages",
"provider:meta",
"protocol:meta-messages",
"text",
"reasoning",
"adaptive"
],
"name": "meta-messages/streams-text-with-adaptive-thinking",
"recordedAt": "2026-09-07T17:26:18.565Z"
"recordedAt": "2026-09-29T05:29:46.263Z"
},
"interactions": [
{
@@ -15,14 +22,14 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"muse-spark-1.3\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is 173 multiplied by 219? Reply with only the final integer.\"}]}],\"stream\":true,\"max_tokens\":2048,\"thinking\":{\"type\":\"adaptive\",\"display\":\"omitted\"},\"output_config\":{\"effort\":\"low\"}}"
"body": "{\"model\":\"muse-spark-1.3\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is 173 multiplied by 219? Reply with only the final integer.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"stream\":true,\"max_tokens\":2048,\"thinking\":{\"type\":\"adaptive\",\"display\":\"omitted\"},\"output_config\":{\"effort\":\"low\"}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "event: message_start\ndata: {\"message\":{\"content\":[],\"id\":\"msg_6a9ef3b8e46a777cfdc94a93\",\"model\":\"muse-spark-1.3\",\"role\":\"assistant\",\"stop_reason\":null,\"stop_sequence\":null,\"type\":\"message\",\"usage\":{\"input_tokens\":0,\"output_tokens\":0}},\"type\":\"message_start\"}\n\nevent: content_block_start\ndata: {\"content_block\":{\"data\":\"Q-PaDgFtwwNGqSqKxPk9Zj-j9xogYigpDsDHrLKPV-DxmXcbhtXDPrGPuj5qB4l1FslhuEQFCA8zIRyNGaCmzM0JG4GOZe3LpFYM2iegBbi24l4V7souryAwUs_7PrInxObuguMwh2S66ML6PwlfVRVTAwPsgDSCl6Y9tkoDHr2KBRY2fzJhmjkotuFRK2LYvNp9pKi89JxnoIKK8B_UJc_B2r4vU_Txq9_rWTmo2eXFoGOSMZNaHpVrvACRQKC1jc6-8Pj5JkJN11_Cs2fHqlaCwOoPDVJxHXUXP1AMHU1mG0jR4qf9wUaz8kMmyCBBijlfQJ4G7nsQs7LJO9gS2ropmynDoO5IEIAFf7ekxLw54Fx3JigC0_qz4Jx4a04Ey8JBzWRlZNdqggpq2_I_VoT3S0zhCYDp8ZAJQs2JqvvF-ZmLcMPH1PmpkgRySqXl5TDxERIpdL3aOUzTl-PTHVMIGEcSAz84fFwU4p1kS5llGEGC6jHlClscEEto1Y-qthL6BoeDALzk7GHEMVQD0bfC4E5yGCh0_xlWqsG3XPj0wpYKBc0hfh7vjO1NpzPxCtaDjnRwR6TzSo6x_HrEL3VELArHhpYAXl30fNO-EepduZ3Hd_kWqWj_GhdIM4dRh2zoytbhIesVD7OvBqU47urKFnpXw4kswJ-NoVVNk00ysMMHwobT9BdJL9UCoXlNzyErPe8LkA9f0VrKkuLCg5FDr_DCEYXHXr7fdRmC3JTKuzvxffvPYppUt3hs5gbdXWgT2iZzunt4bSfFne7BPkj9OGREyfLcukvGAh_oqPLXuu0lert9wbTAqRUTyM5Y1nSjw3h2B3jv91mH78mGvhy8LeVNLkrSMxEa4GtrrUvlzkPnZayc_LE2Sr7JnQjhwylVNWvkcIAjstOZShjPKe4pUJwj-cUXmXZB7Zwr-Q9WLtprFgQoBvadd5DHD4aynsYEabKZVcOqhV8l2zyaUCGobUzdPqBC5kk5-VPmNTqm6Qs91mb1y_5ydmHjTWmSW6qtN_op-Xfwqr_hllvOjhnAvvDJWUOKH-YIgZw_k_db2mzW1hO093jSDMYv6wpiy_5XWMNJBjcH8hvR_pFjXIqScUnKPranEZvCyGfHR-UBNJ89s_JEE49chcIt8LiukBeXQ_TOe29kzWnT1tYtfGQXP8FY2Wukw5yhvP3coGp1-LPZRuw3BLP2tWmiijmyj5lMikp8MIeqOztN8zgsArZ9jq21EtDSNfpO_0C4GEOtCZQYvZHRpDeY966XDnN7Blenn_8FZ95M-M-1My8hsmF__JrnY4ZFZr2274f_FbyJaSqGj6H-YRuzawO2TVLWHxAr6v2LCiB_QX01PwUI1yaJmP0mI-Qz5zXgSHzfVzhF7l1zEnM8qIYcSXzD1Gp3rjpn2sq-rR-JLqeHq080Hl0X9c8lzozw3qCpvcTfA-4ZTdXU9ax3nt7emcPVFo9N_JBjYashC7KvjkIWjMhQoTbIRlO18gjEa-Dvtsmo4_xE1ApYRoylbhxg3_fQJjKLfCBv32V6BM8qWKINL7d1nYP7PtAKnglCdnT_dK0_pCK6ejGfmdWZgVj0DhVZUCXf2kN530Y0hoCb1jBgwJ16a5gUqJMtnWScarIGSVrsYMA_uEt7DwQzGYAGESVIgi8JVATkxoq4EbyOLCHX7MIKmAPGV2WWTjWAWLnE2ITjlXtBkUAwjq2v957BMgIqxSNxEjG75Gd0i1XjZAujCSCXMrywZ72-VdQy4cD6kzH9mv04fZ7_dqn9eKPHBY7_T7V6myRDj_ImkffXcygmd7F-_-kHMExI6ABas1WtfDjkYgbRM0jMtIHYL-W9xT8vCSLSdvHcb5Y8XSXT5GTwI7eIlBHnCvFrgunbCo9bJJlT2Gc1RcgeZ4rg3yu1MNF4Mfk6DBwDhduGh5l7nypL00B0HokLfF4H7N1oSKL9jdVE3oMyJuA9NV1SJiyqcneSVHBzqjFZ5sszFXPlBKEz31izxFToAF4tDYMR507zYLcxyLA4AmHlkmn0qeozPb5qk-qZB6yLywtewv8O1KqxieAEJnEmyiqGnoKBdkjpeYmplErSxJyzoRX4fXb9mOdb-izJgvqSQ5uLm0_EXf3OklpMoAJ2CPJ6cxpg_5bhiwbgEtafCuDyXVL9Zzi872yUb_Z-ATlqU1M2Ai67f1piHIUDzMn4kulXQhPMb_R4eznMbX_aRigQh92-t1USoFtX2vmyCUQageFcrHHK524gPQ7rkOfDvZ8GHPXi3YZFnzGkSKjQD0jumFtzDiRqYkkMECep0zgY8MjuWLMjlMuigpkzSm8GPZ2aFeIMxJCYR8iMf7fH9NyHAJnicx4wdW_UqqgY5jfVtDvPSL0SRScyyK0jNdmofDHjWp4Tehih5OOw81E3BYLCiIv2p2zTMRSTZxFyG3HI91tIEaJu_RnIMFxzCilaP1LcbMrjyFzBtLJiXXJ--eFYyIMYKgfbWIOTsjLLJGC8NS2wuzaXB-C6GLsuSEHci5a8f5KGqaXK99LvHtY4k36WhEoVUjQEGY7HNJrtdjBUlqDgnT9iO06gGd2QCGvlP4us-Z3q2Bcyhf0LzOgn_Bpjc6YBk4TNF1AMxG15qmhnsrWcGElkpk3Vh9npC8--vkvhh7pOPfTZlNjQQh900OemY9gwxHyCBbt1096g4qkYOntGJmw-wVjyWxOt9K9CaM9WxY3wFyOmwqiFL1PK4CMeKMZk54iZ1ZiyXDOvsO7qUa9XQGDy3W8ic6R3DUzdkxEEBFYHAjDy3qE\",\"type\":\"redacted_thinking\"},\"index\":0,\"type\":\"content_block_start\"}\n\nevent: content_block_stop\ndata: {\"index\":0,\"type\":\"content_block_stop\"}\n\nevent: content_block_start\ndata: {\"content_block\":{\"text\":\"\",\"type\":\"text\"},\"index\":1,\"type\":\"content_block_start\"}\n\nevent: content_block_delta\ndata: {\"delta\":{\"text\":\"37887\",\"type\":\"text_delta\"},\"index\":1,\"type\":\"content_block_delta\"}\n\nevent: content_block_stop\ndata: {\"index\":1,\"type\":\"content_block_stop\"}\n\nevent: message_delta\ndata: {\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"type\":\"message_delta\",\"usage\":{\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"input_tokens\":23,\"output_tokens\":266,\"output_tokens_details\":{\"thinking_tokens\":254}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
"body": "event: message_start\ndata: {\"message\":{\"content\":[],\"id\":\"msg_6abb4cc8199fc8fa6763470e\",\"model\":\"muse-spark-1.3\",\"role\":\"assistant\",\"stop_reason\":null,\"stop_sequence\":null,\"type\":\"message\",\"usage\":{\"input_tokens\":0,\"output_tokens\":0}},\"type\":\"message_start\"}\n\nevent: content_block_start\ndata: {\"content_block\":{\"data\":\"Q-PaDgEdq_xGJ7Hl5i3AUq-IGolHfh7VsPu8914OD3BSVMiPCf1QVZ4O553OKpiejFGIkjBEfR2gGxcXlTsKuK78wfAiGAft2yQIHPeCJt4Csoxc3UUX405gUHqO9M06WeU118pTrlITW4g4dElALKmE2zi_pkQzEvU07jPqsF4n-3-9FkEnJ3D5Ag0UsU4LI1Mn5-EwPyPkitspoB-ytXsugMGQTMMfInzNq0OKDDMAO88aRQv24EslYBviairfM5c-k6dqOdGfsg_SntRiZJVNoo1yowGhQuiMKXreeQiQj_hrSVEXo8PVybmaCXtfW0I_2rI7JKR1uO-fxUHZckcCvLKMS3Wa2cwPUM9MPAnq05l3lbD1LpqZ_UPlf534p8_0BmF4hVGI2pamDWkL--7UaWrGFYtwBc3r9GVW0aIZC1ArnGjXsInKCnJuJ-elKu8H0Wp8-lxrsEfqtmxCamrsil8jOkIMezwSMUGtawhcOSdOnyQRje2b3OXiRx7Y-XZjrBlErbS13tdMJhXGlVqp9tazXlKdIt9CpMfMxjYW-Z5VkrJNymsBcjW1r2x5DC2VzGcqcha91rBkskrhKGWix95cdaEqUkP66DW9jThk4RS__E_oyt8DMTpvCxoZ5pe_mLkuS-ZpYKFeZbRegegIQErP_yuhvPWCwTYZymW4uUTVEmxdJ1otAST4ICF4ChsbHLLzJc4XHO7TA5GcwEc69m-poLTFrJvuYRErYhHHONOGWZGoyaPXApsmytUW6-lH2OiVpViVrLg_qvDw1NwyTgguQfP1d6JJYTmnjgN-Pa9170lqGzMI1fVjsC9zY5e6qe6NiyOwpxbFoV-jfdzyfeKX5LMVacpj2xV6dk6dYW7zcIZ3rJJvCpnwJROCjKbSRa4RvBFWNW2xjiWi1tYltzsz1YR1RHZJEN8szlfJjdMrfJdR5GbOiiDMcwGgGNOF5kbQqLgkggzR-B4lUNGLvSyBjAd_AdLpab0r9j1XdKBPWLFiXfAtX8lPSfeCSV41LFxKopB-MIVOpgtrwW6dsEayU7_w4Tc1nBTtYOGf2cGUV2XYzEpt5vxzCDC-og8WZ2lr3hOczp543-_I8n2ZOQEEk1k3suykPiQen_wgcpRccm18Aqsjy2t3uitZ-p1p4g0s6tG6Y_DCS4ku713v9Uw2ezHosl4Qk5JVk-KNvi9ZCYb9Jmc7PqHubP6Ua_UTyIeTiBB60zK5EgatIglUv3_NudfsplEH8YJkMvoAPJk-HW7c2WwzoymfA4TFoCg8vt3YHGAtuiyqF17-S3sfhl9J6oX-7FFpGn6bRqgwguC6sHUQw3bqWtyG4LjUy3_qzWfCI5zSm1hxjd6YRaxkdlv37Ol3Ua-65b3gRmQoUlA7omPbHuC8r2TNnG_ltsp9EMJlz7HN-Gyggw\",\"type\":\"redacted_thinking\"},\"index\":0,\"type\":\"content_block_start\"}\n\nevent: content_block_stop\ndata: {\"index\":0,\"type\":\"content_block_stop\"}\n\nevent: content_block_start\ndata: {\"content_block\":{\"text\":\"\",\"type\":\"text\"},\"index\":1,\"type\":\"content_block_start\"}\n\nevent: content_block_delta\ndata: {\"delta\":{\"text\":\"37887\",\"type\":\"text_delta\"},\"index\":1,\"type\":\"content_block_delta\"}\n\nevent: content_block_stop\ndata: {\"index\":1,\"type\":\"content_block_stop\"}\n\nevent: message_delta\ndata: {\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"type\":\"message_delta\",\"usage\":{\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"input_tokens\":23,\"output_tokens\":161,\"output_tokens_details\":{\"thinking_tokens\":149}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
}
}
]
@@ -2,9 +2,16 @@
"version": 1,
"metadata": {
"model": "muse-spark-1.3",
"tags": ["prefix:meta-messages", "provider:meta", "protocol:meta-messages", "text", "reasoning", "enabled"],
"tags": [
"prefix:meta-messages",
"provider:meta",
"protocol:meta-messages",
"text",
"reasoning",
"enabled"
],
"name": "meta-messages/streams-text-with-enabled-thinking",
"recordedAt": "2026-09-07T17:26:20.464Z"
"recordedAt": "2026-09-29T05:29:50.023Z"
},
"interactions": [
{
@@ -15,14 +22,14 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"muse-spark-1.3\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is 173 multiplied by 219? Reply with only the final integer.\"}]}],\"stream\":true,\"max_tokens\":2048,\"thinking\":{\"type\":\"enabled\",\"budget_tokens\":1024,\"display\":\"omitted\"},\"output_config\":{\"effort\":\"low\"}}"
"body": "{\"model\":\"muse-spark-1.3\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is 173 multiplied by 219? Reply with only the final integer.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"stream\":true,\"max_tokens\":2048,\"thinking\":{\"type\":\"enabled\",\"budget_tokens\":1024,\"display\":\"omitted\"},\"output_config\":{\"effort\":\"low\"}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "event: message_start\ndata: {\"message\":{\"content\":[],\"id\":\"msg_6a9ef3bbb7e691b3ea26427b\",\"model\":\"muse-spark-1.3\",\"role\":\"assistant\",\"stop_reason\":null,\"stop_sequence\":null,\"type\":\"message\",\"usage\":{\"input_tokens\":0,\"output_tokens\":0}},\"type\":\"message_start\"}\n\nevent: content_block_start\ndata: {\"content_block\":{\"data\":\"Q-PaDgEpowvGua1C8z2RSICyyeSLruZ1JBUhrhLhgqf_XauiaESRHyHgIPVF0Cfxp7eLB_ESNObq7vJNDLdffE-RaN5nEauXhRxH6fJErC-4H_KELZFGF-qNnGu2DWl-gEojkwwkq1HtTtT3rOEk5isGjdribnDvR7a1zkpG3I0BxE_UyseISDSbkev5tyV7DkiH4xyv2ZmwuO3HHGgmAztEek9vm0ZTElZ8PIPvHG6P38aYGTGKrTjyOhmN3LBa_NFvtQQkUMKRB3n7L_N5KB1NbYkPzLEJ6L5dL9DJZBSsZms0O62QZ2zs2oWZ-ekQWL4oIuiRozWVpmwzjtkJl9WXtyvdL-4MACucXMSN9V7hWzC50g-DgdeFPSslsA9gy619wTNPPIxPymGWeD9LYKR3iwTRLNYzuycHakNqikhiwz9MweMod5GLTdbtypZJcV7ourz58jmK4YKsqleVPVvXM6bIoNuT2yGj23-tAYxQszGXQMHxSTnaLAHl6sUdiwTqX4wbFYfX-ogkIZsrca9mv5E2gkH1wJl19GPCxFaSDCHxh3QAW0RIExKUJly9Ydpoegmojfo0F4Hjt13zDm03fLEQ0eY8GjxIIHDt_zVRQGTW8d4tgZpTt4K9H1TTefG34EerN7jl7L-WNUe_gKorN4aSYBS6S6ma4Vb91NlUQ4cEGBKVQvumYe16p9Xj6Te-tCuiBmTOCq6iDsTH-eMlip_Bc18MD2BCGr2NdF4PB2vqHGSPIvSHDRXBjoPNtm18opAsUnOBu7-tysb8-EKFd5z7JbWgegQWNs11FdX0uFtC-U9ROwbHLJ3IHk4m2UHc2USwi7v4Xe6FtnkJ0VCZTajFUTXW33lwO3UsHuqEbxVnU_Rzuj6Ryjb08bpXpJBjIEGf2wBS3_I78cuXKOChHu-evtnjezc77joRUP8_I8pWsQbQ6bHABD95p8q6ZdQpDr9XQALbVg3ZvD5_QgF59rIxkW9a2L78oQpu0O3UT7-jxNFe38-OXuEr3VTv7RzISu1zjvBZD0k9-2q-T9BAJmsK6oPajJQWKwHBYF3w6vbsQ8tRDdh2uerhlAIWU0YL92gPCmT3mR6HxSGjAx08t-gib_YrzvWn0oTfLNk_BNfAMpG7Mj2GYNbyTkd_tjNZ2fOwVKbygsoFIUEvh2B-xyHOYuhXfxzI8-Gt55FMGDLLKcHfgmOZAUz1xVkIdm6bcuRFuQppgTzYIr66GN6tsr9KxTN2GZ2NHCwexe0hSAGfKVyLr8QBxTDRlzubBqe-osgQJrJtS7uM1MuWK_8grkZrNsn6g3ami0JboEmM_Ct3gVhkXo6lhCCcjn1gnGX-HOLczT3Xty1wYkeayUpi9x326nrSmypxzbzCRS3bOaPvSoBCszri0uTfUnVQK5kTVuRD30fGxauPhA\",\"type\":\"redacted_thinking\"},\"index\":0,\"type\":\"content_block_start\"}\n\nevent: content_block_stop\ndata: {\"index\":0,\"type\":\"content_block_stop\"}\n\nevent: content_block_start\ndata: {\"content_block\":{\"text\":\"\",\"type\":\"text\"},\"index\":1,\"type\":\"content_block_start\"}\n\nevent: content_block_delta\ndata: {\"delta\":{\"text\":\"37887\",\"type\":\"text_delta\"},\"index\":1,\"type\":\"content_block_delta\"}\n\nevent: content_block_stop\ndata: {\"index\":1,\"type\":\"content_block_stop\"}\n\nevent: message_delta\ndata: {\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"type\":\"message_delta\",\"usage\":{\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"input_tokens\":23,\"output_tokens\":143,\"output_tokens_details\":{\"thinking_tokens\":131}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
"body": "event: message_start\ndata: {\"message\":{\"content\":[],\"id\":\"msg_6abb4ccc0beea22bb95a41de\",\"model\":\"muse-spark-1.3\",\"role\":\"assistant\",\"stop_reason\":null,\"stop_sequence\":null,\"type\":\"message\",\"usage\":{\"input_tokens\":0,\"output_tokens\":0}},\"type\":\"message_start\"}\n\nevent: content_block_start\ndata: {\"content_block\":{\"data\":\"Q-PaDgGoJiKtl-javJ6dY04Ws79OK_mVBNQgu19iuCSru9wUy0jS0-87v1Lr1jC3I6cI8gnBvTl3HcRgR8MqmTOddFg5f_QSqhUweOVsCzaNL18Bjkye_cK3jGmlc9IsGLJ8dGFvFMPwflkMcofmwZ1P0EsMivXgwsDsWuiHP3PAP6_DfKgpVnNoz1oxNXgoVMTaNylNHtKHXpeC4_doHgWONMjhca771bIh_x0sRT-mQlONkr6HHgzzpCftEGOgnELEC7DlmJ46CDe_VSuaiUk8VLeNV4xyNm4XBHoyFuEn2N4tmG6mxo3cQlN2er8lY39XmY5gqy4ZIJddoY4sVFxBfL-axfDDv89hAhTYgXxH4dj4MyAj5TPWdyVZwunw2dRwf9JobQW-HCGcPxW6rqE7znOAegWSLD-mKRGZXNp7-kTY5Wicv9toV-VsBIFc1YpHFRpw36pxZftd2JnbW38dNYH0fO_QYnCknHxE9RA68uFpu_JvvpHOO0sFAuqwOCMynQyqFZRY9vVJBMYnuB_RIY_WCEuZwt7hV9es1aPk2QJyAWFh-LnNDzp9ktLaNwnnJxRpEK1r0PMbUsIQJMuxcNH7lCstE2dGBf02O4lH4HlZSHNsLcZuUjb01HCISf7cQXzIjG6rO_47m52h6bMpYZITElM66XVlKRE5V3wF_7sQ2BaT4g8GLxxTkgh-C0x8ISPVaU773cDvJSuOI-5FlUbviG9oQG_0PxU5ulQDCMSEM4wZENu6ZRlngTloFSxHJfeQvShiy9pC9kv-DuOT1stUT4HqpV0bV7WGrJjlX9l17JbMgjMBwzhNWTJwGiuw2Ya2cCveYgSzxMZtcI_YH9mv0n53c2h7_1Lq2-kVDx_R5UngrMLu9YxjIwIgK_UtbtnTXJ-q5X4DexgovoBPjzc2GOQeK0DoOnfOczVLMALAkQMnyxCQ65ZuawVuJFmIKY9ooznuqWjl5mLxVCn5Fyhk2uTKjFlbW1JO-RdvNF9CMRD2y2bY2Igy4w9Gh1R_xAhM7rwF0dt_p2g8CUjqsEyIn9bSGuRuzXfkxNz7KYfyCD2rkZc1gw8oU7jiLEpNl5gCkcj7bNql7lcDYgJtGSDRwZlIzZcb7MaxFHyFw7dXGtrOIvLL5eF8TOidjaRaY27xkCCBkQXWeIiX0qV5AuojjrpRAJsVVvdT5s2dHd1IYHU0nnsePU1Dd7gyB59upU9W1AaFrXjx0pn4MsNuYDcyLyzihq1lCeK23OSoXkwH9UI-zaLkV6MGUf_K0jMJPr6FrMgYF1x_SpXYOKSXPHhY5UE3pGLIL5_vFCRkAkMcvpw-H3tyljgnCZwt3g-Yuy4Uj05MS3PseQxuUor-0trgRBaBW4AdB925t2D41RJ4xp3HnEpjEkNFJ8Hsqbs3QxyVoTAvElXOrQ\",\"type\":\"redacted_thinking\"},\"index\":0,\"type\":\"content_block_start\"}\n\nevent: content_block_stop\ndata: {\"index\":0,\"type\":\"content_block_stop\"}\n\nevent: content_block_start\ndata: {\"content_block\":{\"text\":\"\",\"type\":\"text\"},\"index\":1,\"type\":\"content_block_start\"}\n\nevent: content_block_delta\ndata: {\"delta\":{\"text\":\"37887\",\"type\":\"text_delta\"},\"index\":1,\"type\":\"content_block_delta\"}\n\nevent: content_block_stop\ndata: {\"index\":1,\"type\":\"content_block_stop\"}\n\nevent: message_delta\ndata: {\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"type\":\"message_delta\",\"usage\":{\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"input_tokens\":23,\"output_tokens\":198,\"output_tokens_details\":{\"thinking_tokens\":186}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
}
}
]
File diff suppressed because one or more lines are too long
@@ -11,7 +11,7 @@
"reasoning"
],
"name": "minimax-messages/m2-7-streams-default-thinking",
"recordedAt": "2026-09-07T16:53:20.721Z"
"recordedAt": "2026-09-29T05:27:50.902Z"
},
"interactions": [
{
@@ -22,14 +22,14 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"MiniMax-M2.7\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is 173 multiplied by 219? Reply with only the final integer.\"}]}],\"stream\":true,\"max_tokens\":1536}"
"body": "{\"model\":\"MiniMax-M2.7\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is 173 multiplied by 219? Reply with only the final integer.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"stream\":true,\"max_tokens\":1536}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"bd0c49bb4f5e6f26b77bab9b5a917484\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M2.7\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":57,\"output_tokens\":0}}}\n\nevent: ping\ndata: {\"type\":\"ping\"}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"The user\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" asks: \\\"What is 173 multiplied by 219? Reply with only the final integer.\\\" So we compute 173 * 219. Compute:\\n\\n173 * 219 = \"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"173 * (200 + 19) = 173*200 + 173*19 = 34600 + (173*19). 173*19 = 173*20\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" - 173 = 3460 - 173 = 3287. So total = 34600 + 3287 = 37887.\\n\\nAlternatively compute directly: 219 * \"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"173 = 219 * 173 = (219 * 100) + (219 * 70) + (219 * 3) = 21900 + 15330 + 657\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" = 37887.\\n\\nThus answer is 37887. We must reply with only the final integer: \\\"37887\\\". No extra text.\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"1625da2f905565076ee90b9c2919db39b1719731c0820eca2f2a1c9c28324e87\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"37887\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"input_tokens\":57,\"output_tokens\":187,\"output_tokens_details\":{\"thinking_tokens\":184}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"419c5ecf093decc95250bc4cc3afce52\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M2.7\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"cache_creation_input_tokens\":21,\"cache_read_input_tokens\":36}}}\n\nevent: ping\ndata: {\"type\":\"ping\"}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"The user\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" asks: \\\"What is 173 multiplied by 219? Reply with only the final integer.\\\"\\n\\nWe need to calculate 173 * 219 = ?\\n\\nLet's compute.\\n\\n173 *\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" 219:\\n\\nOption 1: compute 173 * 200 = 34,600 (actually 173*200 = 34,600\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"? Wait 173 * 200 = 34,600? Let's check: 173 * 2 = 346, then times 100 = 34,600. Yes\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\".)\\n\\n173 * 19 = 173 * 20 - 173 = 3,460 - 173 = 3\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\",287.\\n\\nAdd them: 34,600 + 3,287 = 37,887.\\n\\nThus product is 37,887.\\n\\nAlternatively, compute \"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"219 * 173: 219*100 = 21,900; 219*70 = 15,330; 219*3 = 657\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"; sum = 21,900 + 15,330 = 37,230; +657 = 37,887. Same.\\n\\nThus answer: 378\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"87.\\n\\nThe user wants only the final integer. So reply \\\"37887\\\". No other text.\\n\\nThus final answer: 37887.\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"48b0f683a3fb6cfc2e060ff709c12a905c76d2e5bdddc709fb5b1cfc5aa600f7\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"37887\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"input_tokens\":0,\"output_tokens\":259,\"cache_creation_input_tokens\":21,\"cache_read_input_tokens\":36,\"output_tokens_details\":{\"thinking_tokens\":256}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
}
}
]
@@ -13,7 +13,7 @@
"usage"
],
"name": "minimax-messages/m3-continues-a-tool-loop-with-adaptive-thinking",
"recordedAt": "2026-09-07T16:53:25.185Z"
"recordedAt": "2026-09-29T05:27:53.421Z"
},
"interactions": [
{
@@ -24,14 +24,14 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"MiniMax-M3\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Look up the current weather in Paris using get_weather before answering. After receiving the result, report the weather in one short sentence.\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get the current weather in a city\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"tool_choice\":{\"type\":\"auto\"},\"stream\":true,\"max_tokens\":1536,\"thinking\":{\"type\":\"adaptive\"}}"
"body": "{\"model\":\"MiniMax-M3\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Look up the current weather in Paris using get_weather before answering. After receiving the result, report the weather in one short sentence.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get the current weather in a city\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"cache_control\":{\"type\":\"ephemeral\"}}],\"tool_choice\":{\"type\":\"auto\"},\"stream\":true,\"max_tokens\":1536,\"thinking\":{\"type\":\"adaptive\"}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"6a532037868639a6189e7c0b9e1c9aa2\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"service_tier\":\"standard\"},\"service_tier\":\"standard\"}}\n\nevent: ping\ndata: {\"type\":\"ping\"}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"The user wants me\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" to look up the\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" current weather in Paris\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" using the get_\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"weather tool, then\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" report it\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" in one short sentence\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\". Let\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" me call the tool\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\".\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"f05cb5f4873950f23d194289c41d79b96ebd37c9411a45b306ed44f135a910d4\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_01a07cc9f2d47d83a6424ff3\",\"name\":\"get_weather\",\"input\":{}}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"input_tokens\":239,\"output_tokens\":63,\"cache_read_input_tokens\":203,\"service_tier\":\"standard\",\"output_tokens_details\":{\"thinking_tokens\":33}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"6e8c4645c02dd05c2f08c22e5eba9a89\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"service_tier\":\"standard\"},\"service_tier\":\"standard\"}}\n\nevent: ping\ndata: {\"type\":\"ping\"}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"The user wants me\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" to look up the\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" current weather in Paris\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" using the get_\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"weather tool, then\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" report the weather in\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" one short sentence after\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" receiving the result.\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" Let\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" me call the tool\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\".\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"61f13458806572210eb8e1809b12f191297eea1a7f0104aa12f86595ef5eb1dc\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_01a0eba239b7718ba67e5aa3\",\"name\":\"get_weather\",\"input\":{}}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"input_tokens\":239,\"output_tokens\":68,\"cache_read_input_tokens\":203,\"service_tier\":\"standard\",\"output_tokens_details\":{\"thinking_tokens\":38}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
}
},
{
@@ -42,14 +42,14 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"MiniMax-M3\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Look up the current weather in Paris using get_weather before answering. After receiving the result, report the weather in one short sentence.\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"thinking\",\"thinking\":\"The user wants me to look up the current weather in Paris using the get_weather tool, then report it in one short sentence. Let me call the tool.\",\"signature\":\"f05cb5f4873950f23d194289c41d79b96ebd37c9411a45b306ed44f135a910d4\"},{\"type\":\"tool_use\",\"id\":\"call_01a07cc9f2d47d83a6424ff3\",\"name\":\"get_weather\",\"input\":{\"city\":\"Paris\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_01a07cc9f2d47d83a6424ff3\",\"content\":\"{\\\"condition\\\":\\\"sunny\\\",\\\"temperature\\\":\\\"18C\\\"}\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get the current weather in a city\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"tool_choice\":{\"type\":\"none\"},\"stream\":true,\"max_tokens\":1536,\"thinking\":{\"type\":\"adaptive\"}}"
"body": "{\"model\":\"MiniMax-M3\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Look up the current weather in Paris using get_weather before answering. After receiving the result, report the weather in one short sentence.\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"thinking\",\"thinking\":\"The user wants me to look up the current weather in Paris using the get_weather tool, then report the weather in one short sentence after receiving the result. Let me call the tool.\",\"signature\":\"61f13458806572210eb8e1809b12f191297eea1a7f0104aa12f86595ef5eb1dc\"},{\"type\":\"tool_use\",\"id\":\"call_01a0eba239b7718ba67e5aa3\",\"name\":\"get_weather\",\"input\":{\"city\":\"Paris\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_01a0eba239b7718ba67e5aa3\",\"content\":\"{\\\"condition\\\":\\\"sunny\\\",\\\"temperature\\\":\\\"18C\\\"}\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get the current weather in a city\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"cache_control\":{\"type\":\"ephemeral\"}}],\"tool_choice\":{\"type\":\"none\"},\"stream\":true,\"max_tokens\":1536,\"thinking\":{\"type\":\"adaptive\"}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"e9e7ea89f63eca1cd17a037aef28eed1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"service_tier\":\"standard\"},\"service_tier\":\"standard\"}}\n\nevent: ping\ndata: {\"type\":\"ping\"}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"The weather\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" in Paris is sunny\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" with\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" a\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" temperature of 18\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"°C.\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"input_tokens\":164,\"output_tokens\":16,\"cache_read_input_tokens\":128,\"service_tier\":\"standard\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"0f6a842fc54eecab0d6df9ffebd8dd3b\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"service_tier\":\"standard\"},\"service_tier\":\"standard\"}}\n\nevent: ping\ndata: {\"type\":\"ping\"}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"The weather\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" in Paris is sunny\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" with a\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" temperature of 18\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"°C. I'll\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" report this in one\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"short sentence.\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"fefe2c0e5f3f0a0edc24b21d37c8b7fb12ed0755bd1898f71acc2325f1b35958\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"It's\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\" currently\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\" sunny in\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\" Paris with a temperature\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\" of 18°C\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\".\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"input_tokens\":169,\"output_tokens\":38,\"cache_read_input_tokens\":128,\"service_tier\":\"standard\",\"output_tokens_details\":{\"thinking_tokens\":22}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
}
}
]
@@ -11,7 +11,7 @@
"usage"
],
"name": "minimax-messages/m3-generates-a-named-tool-call-with-default-thinking-off",
"recordedAt": "2026-09-07T16:53:24.366Z"
"recordedAt": "2026-09-29T05:27:51.660Z"
},
"interactions": [
{
@@ -22,14 +22,14 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"MiniMax-M3\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use get_weather to look up the current weather in Paris.\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get the current weather in a city\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"tool_choice\":{\"type\":\"tool\",\"name\":\"get_weather\"},\"stream\":true,\"max_tokens\":512}"
"body": "{\"model\":\"MiniMax-M3\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use get_weather to look up the current weather in Paris.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get the current weather in a city\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"cache_control\":{\"type\":\"ephemeral\"}}],\"tool_choice\":{\"type\":\"tool\",\"name\":\"get_weather\"},\"stream\":true,\"max_tokens\":512}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"a60a8adb2eff7545f809b9df2689f1b8\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"service_tier\":\"standard\"},\"service_tier\":\"standard\"}}\n\nevent: ping\ndata: {\"type\":\"ping\"}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"I'll\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" look\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" up the current weather\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" in Paris for you\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\".\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_b0246853f3c4432ea455cc99\",\"name\":\"get_weather\",\"input\":{}}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"input_tokens\":401,\"output_tokens\":39,\"service_tier\":\"standard\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"5f4a7ce22c5a82497fedc65028aa4825\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"service_tier\":\"standard\"},\"service_tier\":\"standard\"}}\n\nevent: ping\ndata: {\"type\":\"ping\"}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"I'll\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" look\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" up the current weather\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" in Paris for you\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\".\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_01a0eba235fb7281a0f2ea2c\",\"name\":\"get_weather\",\"input\":{}}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"input_tokens\":287,\"output_tokens\":39,\"cache_read_input_tokens\":128,\"service_tier\":\"standard\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
}
}
]
File diff suppressed because one or more lines are too long
@@ -11,7 +11,7 @@
"thinking-off"
],
"name": "minimax-messages/m3-streams-text-with-thinking-disabled",
"recordedAt": "2026-09-07T16:53:16.028Z"
"recordedAt": "2026-09-29T05:27:36.888Z"
},
"interactions": [
{
@@ -22,14 +22,14 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"MiniMax-M3\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is 173 multiplied by 219? Reply with only the final integer.\"}]}],\"stream\":true,\"max_tokens\":1536,\"thinking\":{\"type\":\"disabled\"}}"
"body": "{\"model\":\"MiniMax-M3\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is 173 multiplied by 219? Reply with only the final integer.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"stream\":true,\"max_tokens\":1536,\"thinking\":{\"type\":\"disabled\"}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"01664c047875a0f573c966467c8cccc1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"service_tier\":\"standard\"},\"service_tier\":\"standard\"}}\n\nevent: ping\ndata: {\"type\":\"ping\"}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"378\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"87\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"input_tokens\":51,\"output_tokens\":3,\"cache_read_input_tokens\":128,\"service_tier\":\"standard\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"444066751666f2071c999bfd0b4c16b0\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"service_tier\":\"standard\"},\"service_tier\":\"standard\"}}\n\nevent: ping\ndata: {\"type\":\"ping\"}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"378\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"87\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"input_tokens\":179,\"output_tokens\":3,\"service_tier\":\"standard\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
}
}
]
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -2,9 +2,16 @@
"version": 1,
"metadata": {
"model": "kimi-k3",
"tags": ["prefix:moonshot-messages", "provider:moonshot", "protocol:messages", "tool", "tool-choice", "usage"],
"tags": [
"prefix:moonshot-messages",
"provider:moonshot",
"protocol:messages",
"tool",
"tool-choice",
"usage"
],
"name": "moonshot-messages/k3-respects-tool-choice-required",
"recordedAt": "2026-09-07T20:37:07.770Z"
"recordedAt": "2026-09-29T05:28:21.798Z"
},
"interactions": [
{
@@ -15,14 +22,14 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"kimi-k3\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use get_weather to look up the current weather in Paris.\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get the current weather in a city\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"tool_choice\":{\"type\":\"any\"},\"stream\":true,\"max_tokens\":4096,\"output_config\":{\"effort\":\"low\"}}"
"body": "{\"model\":\"kimi-k3\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use get_weather to look up the current weather in Paris.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get the current weather in a city\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"cache_control\":{\"type\":\"ephemeral\"}}],\"tool_choice\":{\"type\":\"any\"},\"stream\":true,\"max_tokens\":4096,\"output_config\":{\"effort\":\"low\"}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"chatcmpl-2f2e02780e8a031b1b1307171\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"kimi-k3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":223,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":0,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\",\"prompt_tokens\":223,\"cached_tokens\":0}}}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\",\"signature\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"Need\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" use\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" get\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"_we\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"ather\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" for\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" Paris\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\".\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" Call\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" function\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\".\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" Then\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" final\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" summarize\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" result\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\".\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" Use\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" tool\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\".\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"tool_use\",\"id\":\"get_weather_0\",\"name\":\"get_weather\",\"input\":{}}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\"city\\\": \\\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"Paris\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"}\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":0,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":223,\"output_tokens\":69,\"output_tokens_details\":{\"thinking_tokens\":19},\"prompt_tokens\":223,\"completion_tokens\":69,\"total_tokens\":292,\"cached_tokens\":223}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"chatcmpl-c877bb3f-cb6b-9d0f-b91e-37849d7bff39\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"kimi-k3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":223,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"service_tier\":\"standard\",\"inference_geo\":\"not_available\",\"prompt_tokens\":223,\"cached_tokens\":0}}}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\",\"signature\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"The\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" user explicitly\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" asks to use\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" get_weather for\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" Paris. Need call\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" the\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" tool. Do\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" that.\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"tool_use\",\"id\":\"get_weather_0_7e180ea4\",\"name\":\"get_weather\",\"input\":{}}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\"city\\\":\\\"Paris\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"}\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":223,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":73,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens_details\":{\"thinking_tokens\":20},\"prompt_tokens\":223,\"completion_tokens\":73,\"total_tokens\":296,\"cached_tokens\":0}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
}
}
]
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -33,7 +33,12 @@ it.effect("Meta selects Messages and lowers native search alongside ordinary fun
output_config: { effort: "low" },
tools: [
{ type: "web_search", name: "web_search", user_location: { type: "approximate", country: "US" } },
{ name: "lookup", description: "Lookup", input_schema: { type: "object" } },
{
name: "lookup",
description: "Lookup",
input_schema: { type: "object" },
cache_control: { type: "ephemeral" },
},
],
})
const entrypoint = yield* Effect.promise(() => import("@opencode/ai/providers/meta/messages"))
+12 -4
View File
@@ -235,9 +235,7 @@ export type IntegrationCommandMethod = { id: string; type: "command"; label: str
export type IntegrationEnvMethod = { type: "env"; names: Array<string> }
export type ConnectionCredentialInfo = { type: "credential"; id: string; label: string; method: "key" | "oauth" }
export type ConnectionEnvInfo = { type: "env"; name: string }
export type ConnectionStatus = { status: "needs_auth"; message: string; url?: string }
export type IntegrationAttempt = {
attemptID: string
@@ -1469,7 +1467,15 @@ export type ModelCost = {
cache: { read: MoneyUSDPerMillionTokens; write: MoneyUSDPerMillionTokens }
}
export type ConnectionInfo = ConnectionCredentialInfo | ConnectionEnvInfo
export type ConnectionCredentialInfo = {
type: "credential"
id: string
label: string
method: "key" | "oauth"
status?: ConnectionStatus
}
export type ConnectionEnvInfo = { type: "env"; name: string; status?: ConnectionStatus }
export type McpServer = {
name: string
@@ -1884,6 +1890,8 @@ export type FormField =
export type FormState = { status: "pending" } | { status: "answered"; answer: FormAnswer } | { status: "cancelled" }
export type ConnectionInfo = ConnectionCredentialInfo | ConnectionEnvInfo
export type FormField1 =
| FormStringField1
| FormNumberField1
+1
View File
@@ -9,6 +9,7 @@ const prefixes = {
question: "que",
part: "prt",
pty: "pty",
schedule: "sch",
tool: "tool",
workspace: "wrk",
} as const
+22 -1
View File
@@ -7,6 +7,7 @@ import {
Context,
Duration,
Effect,
Equal,
Exit,
Fiber,
Layer,
@@ -188,6 +189,12 @@ export interface Interface extends State.Transformable<Editor> {
) => Effect.Effect<void>
/** Removes a stored credential connection. */
readonly remove: (credentialID: Credential.ID) => Effect.Effect<void>
/** Reports or clears a runtime problem with a connection; `get` and `list` project it onto the connection. */
readonly status: (input: {
readonly integrationID: ID
readonly connection: IntegrationConnection.Info
readonly status: IntegrationConnection.Status | undefined
}) => Effect.Effect<void>
}
readonly oauth: {
/** Starts a stateful OAuth attempt. */
@@ -279,6 +286,10 @@ const layer = Layer.effect(
const scope = yield* Scope.Scope
const attempts = SynchronizedRef.makeUnsafe(new Map<AttemptID, AttemptEntry>())
const commandAttempts = SynchronizedRef.makeUnsafe(new Map<AttemptID, CommandAttemptEntry>())
// Runtime-only: statuses describe the current process's view of a connection and are not persisted.
const statuses = new Map<string, IntegrationConnection.Status>()
const statusKey = (integrationID: ID, connection: IntegrationConnection.Info) =>
`${integrationID}:${IntegrationConnection.key(connection)}`
const state = State.create<Data, Editor>({
name: "integration",
initial: () => ({ integrations: new Map<ID, Entry>() }),
@@ -377,7 +388,10 @@ const layer = Layer.effect(
name: entry.ref.name,
...(entry.ref.metadata === undefined ? {} : { metadata: entry.ref.metadata }),
methods: entry.methods,
connections,
connections: connections.map((connection) => {
const status = statuses.get(statusKey(entry.ref.id, connection))
return status ? { ...connection, status } : connection
}),
})
const authorize = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
@@ -727,6 +741,13 @@ const layer = Layer.effect(
credentials.update(credentialID, updates),
),
remove: Effect.fn("Integration.connection.remove")((credentialID) => credentials.remove(credentialID)),
status: Effect.fn("Integration.connection.status")(function* (input) {
const key = statusKey(input.integrationID, input.connection)
if (Equal.equals(statuses.get(key), input.status)) return
if (input.status) statuses.set(key, input.status)
if (!input.status) statuses.delete(key)
yield* bus.publish(Integration.Event.Updated, {})
}),
},
oauth: {
connect: connectOAuth,
@@ -11,6 +11,9 @@ export type EnvInfo = Connection.EnvInfo
export const Info = Connection.Info
export type Info = Connection.Info
export const Status = Connection.Status
export type Status = Connection.Status
/** Identity of an access choice; labels and refreshed token values do not identify a new connection. */
export function key(
connection:
File diff suppressed because one or more lines are too long
+9
View File
@@ -345,6 +345,15 @@ export const make = Effect.fn("PluginHost.make")(function* (
integration.connection.resolve(
connection.type === "credential" ? { ...connection, id: Credential.ID.make(connection.id) } : connection,
),
status: (input) =>
integration.connection.status({
integrationID: Integration.ID.make(input.integrationID),
connection:
input.connection.type === "credential"
? { ...input.connection, id: Credential.ID.make(input.connection.id) }
: input.connection,
status: input.status,
}),
},
transform: (callback) =>
integration.transform((editor) => {
+5
View File
@@ -61,6 +61,7 @@ import { Ripgrep } from "../ripgrep.js"
import { Session } from "../session.js"
import { SessionCompaction } from "../session/compaction.js"
import { SessionInstructions } from "../session/instructions.js"
import { SessionSchedule } from "../session/schedule.js"
import { Shell } from "../shell.js"
import { ShellSelect } from "../shell/select.js"
import { Snapshot } from "../snapshot.js"
@@ -102,6 +103,7 @@ import { OptimizePlugin } from "./optimize.js"
import { VcsGitPlugin } from "./vcs/git.js"
import { VerbosityPlugin } from "./verbosity.js"
import { WarmingPlugin } from "./warming.js"
import { SchedulePlugin } from "./schedule.js"
import { WellKnownPlugin } from "../wellknown/plugin.js"
const services = [
@@ -142,6 +144,7 @@ const services = [
Session.Service,
SessionCompaction.Service,
SessionInstructions.Service,
SessionSchedule.Service,
Shell.Service,
ShellSelect.Service,
Snapshot.Service,
@@ -195,6 +198,7 @@ export const requirements = LayerNode.group([
Session.node,
SessionCompaction.node,
SessionInstructions.node,
SessionSchedule.node,
Shell.node,
ShellSelect.node,
Snapshot.node,
@@ -245,6 +249,7 @@ const pre = [
WebSearchTool.Plugin,
WriteTool.Plugin,
WarmingPlugin.Plugin,
SchedulePlugin.Plugin,
] as const satisfies readonly InternalPlugin[]
const post = [
+158 -31
View File
@@ -1,7 +1,7 @@
import { Duration, Effect, Equal, Schema, Semaphore, Stream } from "effect"
import type { Scope } from "effect"
import { Duration, Effect, Equal, Option, Schema, Scope, Semaphore, Stream } from "effect"
import type { IntegrationOAuthMethodRegistration } from "@opencode/plugin/effect/integration"
import { define } from "@opencode/plugin/effect/plugin"
import type { SessionHttpResponse } from "@opencode/plugin/effect/session"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { App } from "../../app.js"
import { Bus } from "../../bus.js"
@@ -55,6 +55,34 @@ const TokenPending = Schema.Struct({ error: Schema.String })
const DeviceToken = Schema.Union([Token, TokenPending])
const User = Schema.Struct({ id: Schema.String, email: Schema.String })
const Org = Schema.Struct({ id: Schema.String, name: Schema.String })
// Console's 403 when the organization enforces SSO and the user's SSO proof is missing or expired.
const SsoRequired = Schema.Struct({
_tag: Schema.Literal("SsoRequired"),
orgId: Schema.String,
connectionId: Schema.String,
})
const InvalidGrant = Schema.Struct({ error: Schema.Literal("invalid_grant") })
// Console's 401 when the session behind the access token was revoked or expired.
const Unauthorized = Schema.Struct({ _tag: Schema.Literal("Unauthorized") })
const signedOutMessage = "Reconnect OpenCode Console to continue"
const ssoMessage = (organization: string | undefined) =>
`Sign in with SSO again to use ${organization ?? "your OpenCode Console organization"}`
class SsoRequiredError extends Schema.TaggedError<SsoRequiredError>()("OpencodeConsole.SsoRequired", {
organization: Schema.optional(Schema.String),
url: Schema.String,
}) {
override get message() {
return `${ssoMessage(this.organization)}.`
}
}
// The Console session or refresh token was revoked or expired; only a new device login recovers.
class SignedOutError extends Schema.TaggedError<SignedOutError>()("OpencodeConsole.SignedOut", {}) {
override get message() {
return `${signedOutMessage}.`
}
}
function oauth(http: HttpClient.HttpClient) {
return {
@@ -100,11 +128,17 @@ function oauth(http: HttpClient.HttpClient) {
}),
refresh: (credential) =>
Effect.gen(function* () {
const token = yield* post(
http,
`${serverUrl(credential)}/auth/device/token`,
{ grant_type: "refresh_token", refresh_token: credential.refresh, client_id: clientID },
Token,
const response = yield* send(http, `${serverUrl(credential)}/auth/device/token`, {
grant_type: "refresh_token",
refresh_token: credential.refresh,
client_id: clientID,
})
if (response.status === 400) {
const body = yield* HttpClientResponse.schemaBodyJson(InvalidGrant)(response).pipe(Effect.option)
if (Option.isSome(body)) return yield* new SignedOutError()
}
const token = yield* HttpClientResponse.filterStatusOk(response).pipe(
Effect.flatMap(HttpClientResponse.schemaBodyJson(Token)),
)
// Persist rotated tokens without depending on discovery requests.
return {
@@ -138,6 +172,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Manag
// and whether it evaluates the policies it is being sent.
const http = HttpClient.mapRequest(client, HttpClientRequest.setHeader("User-Agent", App.useragent(ctx.app)))
const managed = yield* ManagedPolicy.Service
const scope = yield* Scope.Scope
const loading = Semaphore.makeUnsafe(1)
type ActiveConnection = Effect.Success<ReturnType<typeof ctx.integration.connection.active>>
let snapshot: {
@@ -149,10 +184,20 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Manag
| { servers: NonNullable<typeof RemoteResponse.Type.mcp>["servers"]; headers: Record<string, string> }
| undefined
} = { config: undefined, connection: undefined, organization: undefined, mcp: undefined }
// Status last reported for the active connection, so inference failures can name the fix.
let reported: IntegrationConnection.Status | undefined
let explainers: Effect.Success<ReturnType<typeof ctx.session.hook>>[] = []
const load = Effect.fn("OpencodePlugin.load")(function* () {
const connection = yield* ctx.integration.connection.active("opencode")
if (!connection) return { config: undefined, connection, organization: undefined, mcp: undefined }
if (!connection) {
reported = undefined
return { config: undefined, connection, organization: undefined, mcp: undefined }
}
const status = (status: IntegrationConnection.Status | undefined) => {
reported = status
return ctx.integration.connection.status({ integrationID: "opencode", connection, status })
}
return yield* ctx.integration.connection.resolve(connection).pipe(
Effect.flatMap((credential) => {
if (!credential)
@@ -161,21 +206,24 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Manag
Effect.map((config) => ({
config,
connection,
organization: typeof credential.metadata?.orgName === "string" ? credential.metadata.orgName : undefined,
organization: organizationName(credential),
mcp: config?.mcp && { servers: config.mcp.servers, headers: credentialHeaders(credential) },
})),
)
}),
Effect.tap(() => status(undefined)),
Effect.catch((cause) =>
Effect.logWarning("failed to load OpenCode provider config", { cause }).pipe(
Effect.gen(function* () {
// Only well-formed Console errors change the connection status; anything else may be transient.
const next = connectionStatus(cause)
if (next) yield* status(next)
yield* Effect.logWarning("failed to load OpenCode provider config", { cause })
// A load that fails for the connection already in place keeps its last config: dropping it
// would lift organization policy while personal credentials keep working.
Effect.as(
IntegrationConnection.key(connection) === IntegrationConnection.key(snapshot.connection)
? { config: snapshot.config, connection, organization: snapshot.organization, mcp: snapshot.mcp }
: { config: undefined, connection, organization: undefined, mcp: undefined },
),
),
return IntegrationConnection.key(connection) === IntegrationConnection.key(snapshot.connection)
? { config: snapshot.config, connection, organization: snapshot.organization, mcp: snapshot.mcp }
: { config: undefined, connection, organization: undefined, mcp: undefined }
}),
),
)
})
@@ -367,12 +415,47 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Manag
const apply = Effect.fn("OpencodePlugin.apply")(function* (next: typeof snapshot) {
snapshot = next
yield* publish(next)
yield* guard(next)
yield* Effect.all([ctx.provider.reload(), ctx.websearch.reload(), ctx.mcp.reload()], {
concurrency: 3,
discard: true,
})
})
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(apply)))
// Annotated: `apply` re-registers the hooks that call `check`, which would otherwise make inference circular.
const check = (): Effect.Effect<void> =>
loading.withPermit(
load().pipe(Effect.flatMap((next) => (Equal.equals(snapshot, next) ? Effect.void : apply(next)))),
)
// Console inference rejects a missing SSO sign-in with a generic 401/403. Re-check the Console config, which
// reports the reason, and replace the response with instructions when the connection needs the user.
const explain = (event: SessionHttpResponse) =>
Effect.gen(function* () {
if (event.response.status !== 401 && event.response.status !== 403) return
yield* check()
const status = reported
if (!status) return
void event.response.body?.cancel()
event.response = Response.json(
{
error: {
type: "authentication_error",
message: `${status.message}.`,
},
},
{ status: event.response.status },
)
})
// Hooks are scoped per provider so requests to unrelated providers skip the HTTP hook path.
const guard = Effect.fn("OpencodePlugin.guard")(function* (next: typeof snapshot) {
yield* Effect.forEach(explainers, (registration) => registration.dispose, { discard: true })
explainers = yield* Effect.forEach(
next.connection ? new Set([Provider.ID.opencode, ...Object.keys(next.config?.providers ?? {})]) : [],
(providerID) => ctx.session.hook("http.response", explain, { providerID }).pipe(Scope.provide(scope)),
)
})
yield* guard(snapshot)
yield* bus.subscribe(Credential.Event.Switched).pipe(
Stream.filter((event) => event.data.integrationID === Integration.ID.make("opencode")),
Stream.runForEach(refresh),
@@ -382,11 +465,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Manag
// Console config can change independently of local credential activity, so re-fetch
// periodically and only rebuild the catalog and search providers when the snapshot differs.
yield* Effect.sleep(Duration.minutes(1)).pipe(
Effect.andThen(
loading.withPermit(
load().pipe(Effect.flatMap((next) => (Equal.equals(snapshot, next) ? Effect.void : apply(next)))),
),
),
Effect.andThen(check()),
Effect.forever,
Effect.forkScoped,
)
@@ -402,15 +481,58 @@ function fetchConfig(http: HttpClient.HttpClient, value: Credential.Value) {
),
)
.pipe(
Effect.flatMap((response) => {
if (response.status === 404) return Effect.undefined
return HttpClientResponse.filterStatusOk(response).pipe(
Effect.flatMap(HttpClientResponse.schemaBodyJson(RemoteResponse)),
)
}),
Effect.flatMap((response) =>
Effect.gen(function* () {
if (response.status === 404) return undefined
if (response.status === 401) {
const body = yield* HttpClientResponse.schemaBodyJson(Unauthorized)(response).pipe(Effect.option)
if (Option.isSome(body)) return yield* new SignedOutError()
}
if (response.status === 403) {
const body = yield* HttpClientResponse.schemaBodyJson(SsoRequired)(response).pipe(Effect.option)
if (Option.isSome(body))
return yield* new SsoRequiredError({
organization: organizationName(value),
url: yield* ssoUrl(serverUrl(value), body.value.connectionId),
})
}
return yield* HttpClientResponse.filterStatusOk(response).pipe(
Effect.flatMap(HttpClientResponse.schemaBodyJson(RemoteResponse)),
)
}),
),
)
}
function ssoUrl(server: string, connectionID: string) {
return normalizeServer(server).pipe(
Effect.map((base) => {
// Console only redirects to paths under its own base path after SSO.
const redirectTo = `${new URL(base).pathname.replace(/\/+$/, "")}/`
return `${base}/auth/sso/${encodeURIComponent(connectionID)}/start?redirectTo=${encodeURIComponent(redirectTo)}`
}),
)
}
function connectionStatus(cause: unknown): IntegrationConnection.Status | undefined {
if (cause instanceof SsoRequiredError)
return {
status: "needs_auth",
message: ssoMessage(cause.organization),
url: cause.url,
}
if (
cause instanceof SignedOutError ||
(cause instanceof Integration.AuthorizationError && cause.cause instanceof SignedOutError)
)
return { status: "needs_auth", message: signedOutMessage }
return undefined
}
function organizationName(credential: Credential.Value) {
return typeof credential.metadata?.orgName === "string" ? credential.metadata.orgName : undefined
}
function credentialHeaders(value: Credential.Value): Record<string, string> {
const orgID = value.metadata?.orgID
return {
@@ -514,11 +636,16 @@ function post<S extends Schema.Top>(
schema: S,
statusOk = true,
) {
return HttpClientRequest.post(url).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(Schema.Record(Schema.String, Schema.Union([Schema.String, Schema.Boolean])))(body),
Effect.flatMap((request) => http.execute(request)),
return send(http, url, body).pipe(
Effect.flatMap((response) => (statusOk ? HttpClientResponse.filterStatusOk(response) : Effect.succeed(response))),
Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)),
)
}
function send(http: HttpClient.HttpClient, url: string, body: Record<string, string | boolean>) {
return HttpClientRequest.post(url).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(Schema.Record(Schema.String, Schema.Union([Schema.String, Schema.Boolean])))(body),
Effect.flatMap((request) => http.execute(request)),
)
}
+136
View File
@@ -0,0 +1,136 @@
export * as SchedulePlugin from "./schedule.js"
import { ToolFailure } from "@opencode/ai"
import { define } from "@opencode/plugin/effect/plugin"
import { Clock, Effect, Schema } from "effect"
import { SessionSchedule } from "../session/schedule.js"
const CreateInput = Schema.Struct({
prompt: Schema.String.check(Schema.isMinLength(1)).annotate({
description:
"Message delivered to this session as a new user message when the schedule fires. Write a complete, self-contained instruction.",
}),
every: Schema.optionalKey(Schema.String).annotate({
description: "Repeat interval such as 30s, 15m, 2h or 1d. Without `at`, the first run is one interval from now.",
}),
at: Schema.optionalKey(Schema.String).annotate({
description:
"ISO 8601 timestamp with a timezone offset, e.g. 2026-09-29T15:00:00-04:00. Alone it schedules a single run; with `every` it sets the first run.",
}),
})
const CancelInput = Schema.Struct({
id: Schema.String.annotate({ description: "Schedule ID returned by schedule_create or schedule_list." }),
})
const View = Schema.Struct({
id: Schema.String,
prompt: Schema.String,
next: Schema.String.annotate({ description: "Next run as an ISO 8601 timestamp." }),
every: Schema.optionalKey(Schema.String).annotate({ description: "Repeat interval; absent for a single run." }),
})
const ListOutput = Schema.Struct({
now: Schema.String.annotate({ description: "Current server time as an ISO 8601 timestamp." }),
schedules: Schema.Array(View),
})
const view = (info: SessionSchedule.Info) => ({
id: info.id,
prompt: info.text,
next: new Date(info.next).toISOString(),
...(info.every === undefined ? {} : { every: `${info.every / 1000}s` }),
})
export const Plugin = define({
id: "opencode.schedule",
effect: Effect.fn(function* (ctx) {
const schedule = yield* SessionSchedule.Service
yield* ctx.command.transform((editor) => {
editor.add({
name: "schedule",
description: "repeat a prompt in this session: <30s|15m|2h> <prompt>, or off",
execute: (input) =>
Effect.gen(function* () {
const text = input.prompt.text.trim()
if (text === "off") {
const existing = yield* schedule.list(input.sessionID)
return yield* Effect.forEach(existing, (info) => schedule.cancel(info.id), { discard: true })
}
const [interval = "", ...rest] = text.split(/\s+/)
const every = SessionSchedule.parseInterval(interval)
const prompt = rest.join(" ")
if (every === undefined || !prompt)
return yield* Effect.fail(new Error("Usage: /schedule <30s|15m|2h> <prompt>, or /schedule off"))
yield* schedule.create({ sessionID: input.sessionID, text: prompt, every })
}),
})
})
yield* ctx.tool
.transform((editor) => {
editor.add({
name: "schedule_create",
description:
"Schedule a prompt to be sent back to the current session later, even if no client has it open. Use `every` for recurring checks (e.g. every 15m), `at` for a single run at an absolute time, or both. Returns the schedule ID.",
input: CreateInput,
output: View,
options: { namespace: "opencode", codemode: true },
execute: (input, context) =>
Effect.gen(function* () {
if (input.every === undefined && input.at === undefined)
return yield* new ToolFailure({ message: "Provide `every`, `at`, or both" })
const every = input.every === undefined ? undefined : SessionSchedule.parseInterval(input.every)
if (input.every !== undefined && every === undefined)
return yield* new ToolFailure({ message: `Invalid interval ${input.every}; use e.g. 30s, 15m, 2h, 1d` })
const at = input.at === undefined ? undefined : Date.parse(input.at)
const now = yield* Clock.currentTimeMillis
if (at !== undefined && Number.isNaN(at))
return yield* new ToolFailure({ message: `Invalid timestamp ${input.at}` })
if (at !== undefined && at <= now)
return yield* new ToolFailure({
message: `${input.at} is in the past; the current time is ${new Date(now).toISOString()}`,
})
const info = yield* schedule.create({ sessionID: context.sessionID, text: input.prompt, at, every })
return {
output: view(info),
content: `Scheduled ${info.id}: next run ${view(info).next}${every ? `, then every ${input.every}` : ""}.`,
}
}),
})
editor.add({
name: "schedule_list",
description: "List the prompts scheduled for the current session, with the current server time.",
input: Schema.Struct({}),
output: ListOutput,
options: { namespace: "opencode", codemode: true },
execute: (_input, context) =>
Effect.gen(function* () {
const infos = yield* schedule.list(context.sessionID)
return {
output: {
now: new Date(yield* Clock.currentTimeMillis).toISOString(),
schedules: infos.map(view),
},
}
}),
})
editor.add({
name: "schedule_cancel",
description: "Cancel a scheduled prompt in the current session.",
input: CancelInput,
output: Schema.Struct({ id: Schema.String }),
options: { namespace: "opencode", codemode: true },
execute: (input, context) =>
Effect.gen(function* () {
const owned = (yield* schedule.list(context.sessionID)).some((info) => info.id === input.id)
if (!owned) return yield* new ToolFailure({ message: `No schedule ${input.id} in this session` })
yield* schedule.cancel(input.id)
return { output: { id: input.id }, content: `Cancelled ${input.id}.` }
}),
})
})
.pipe(Effect.orDie)
}),
})
+128
View File
@@ -0,0 +1,128 @@
export * as SessionSchedule from "./schedule.js"
import { Clock, Context, Duration, Effect, Fiber, Layer, Option, Schema } from "effect"
import { makeGlobalNode } from "@opencode/util/effect/app-node"
import { Identifier } from "../id/id.js"
import { KV } from "../kv.js"
import { Session } from "../session.js"
import { SessionSchema } from "./schema.js"
export const Info = Schema.Struct({
id: Schema.String,
sessionID: SessionSchema.ID,
text: Schema.String,
/** Next fire time in epoch milliseconds. Persisted so restarts keep the cadence. */
next: Schema.Number,
/** Repeat interval in milliseconds; absent for one-shot schedules. */
every: Schema.optionalKey(Schema.Number),
})
export type Info = typeof Info.Type
const decodeInfo = Schema.decodeUnknownOption(Info)
const prefix = "session.schedule/"
const units = { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 }
/** Parses an interval such as `30s`, `15m`, `2h` or `1d` into milliseconds. */
export function parseInterval(input: string) {
const match = /^(\d+)([smhd])$/.exec(input.trim())
if (!match) return
const value = Number(match[1]) * units[match[2] as keyof typeof units]
return value > 0 ? value : undefined
}
export interface Interface {
/** Saves a prompt that fires once at `at`, or every `every` ms starting one interval from now. */
readonly create: (input: {
sessionID: SessionSchema.ID
text: string
at?: number
every?: number
}) => Effect.Effect<Info>
readonly cancel: (id: string) => Effect.Effect<void>
readonly list: (sessionID: SessionSchema.ID) => Effect.Effect<readonly Info[]>
/**
* Starts timers for every saved schedule. Timers live in this global service rather than in a
* Location, so they keep firing while the Session's Location is unloaded; each prompt loads it on
* demand. Overdue schedules fire once immediately. Inert until called: the managed server calls it
* once at boot.
*/
readonly resume: Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionSchedule") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const kv = yield* KV.Service
const sessions = yield* Session.Service
const scope = yield* Effect.scope
const running = new Map<string, Fiber.Fiber<void>>()
const stop = Effect.fnUntraced(function* (id: string) {
const fiber = running.get(id)
running.delete(id)
if (fiber) yield* Fiber.interrupt(fiber)
})
const start = Effect.fnUntraced(function* (info: Info) {
yield* stop(info.id)
const loop = (current: Info): Effect.Effect<void> =>
Effect.gen(function* () {
const wait = current.next - (yield* Clock.currentTimeMillis)
if (wait > 0) yield* Effect.sleep(Duration.millis(wait))
const exists = yield* sessions
.prompt({ sessionID: current.sessionID, text: current.text, delivery: "queue" })
.pipe(
Effect.as(true),
Effect.catchTag("Session.NotFoundError", () => Effect.succeed(false)),
Effect.catchCause((cause) =>
Effect.logWarning("scheduled prompt failed", { id: current.id, cause }).pipe(Effect.as(true)),
),
)
// One-shot schedules and schedules of deleted Sessions end here.
if (!exists || current.every === undefined) {
running.delete(current.id)
return yield* kv.remove(prefix + current.id)
}
// Skip occurrences missed while the server was down instead of firing them in a burst.
const now = yield* Clock.currentTimeMillis
const next = { ...current, next: Math.max(current.next + current.every, now + current.every) }
yield* kv.set(prefix + current.id, next)
return yield* loop(next)
})
running.set(info.id, yield* Effect.forkIn(loop(info), scope))
})
const all = Effect.gen(function* () {
const saved = yield* kv.scan({ prefix, limit: 1000 })
return saved.entries.flatMap((item) => Option.toArray(decodeInfo(item.value)))
})
return Service.of({
create: Effect.fnUntraced(function* (input) {
const now = yield* Clock.currentTimeMillis
const info: Info = {
id: Identifier.ascending("schedule"),
sessionID: input.sessionID,
text: input.text,
next: input.at ?? now + (input.every ?? 0),
...(input.every === undefined ? {} : { every: input.every }),
}
yield* kv.set(prefix + info.id, info)
yield* start(info)
return info
}),
cancel: Effect.fnUntraced(function* (id) {
yield* stop(id)
yield* kv.remove(prefix + id)
}),
list: (sessionID) => all.pipe(Effect.map((infos) => infos.filter((info) => info.sessionID === sessionID))),
resume: Effect.gen(function* () {
yield* Effect.forEach(yield* all, start, { discard: true })
}),
})
}),
)
export const node = makeGlobalNode({ service: Service, layer, deps: [KV.node, Session.node] })
+20 -1
View File
@@ -160,6 +160,25 @@ const nvidiaChat: Protocol = (model, support) => {
}
}
// Workers AI ignores or rejects `reasoning_effort: "none"` on most models. Kimi's chat template reads
// `thinking`; the others read `enable_thinking`, so both are sent.
const workersAITemplate = (thinking: boolean): Overlay => ({
body: { chat_template_kwargs: { enable_thinking: thinking, thinking } },
})
const workersAIChat: Protocol = (_, support) => {
switch (support.type) {
case "effort":
return efforts(support.values ?? EFFORTS, (effort) =>
effort === "none" ? workersAITemplate(false) : { settings: { reasoningEffort: effort } },
)
case "toggle":
return toggle(workersAITemplate(false), workersAITemplate(true))
case "budget_tokens":
return []
}
}
const basetenTemplate = (enable_thinking: boolean): Overlay => ({
body: { chat_template_args: { enable_thinking } },
})
@@ -552,7 +571,7 @@ const PROTOCOLS: Readonly<Record<string, Protocol>> = {
"@opencode/ai/providers/alibaba/chat": alibabaChat,
"@opencode/ai/providers/baseten": basetenChat,
"@opencode/ai/providers/cerebras": openaiChat,
"@opencode/ai/providers/cloudflare-workers-ai": openaiChat,
"@opencode/ai/providers/cloudflare-workers-ai": workersAIChat,
"@opencode/ai/providers/deepinfra": deepinfraChat,
"@opencode/ai/providers/deepseek": deepseekChat,
"@opencode/ai/providers/fireworks": openaiChat,
+1
View File
@@ -35,6 +35,7 @@ const integrations = Layer.mock(Integration.Service, {
activate: () => Effect.die("unused"),
update: () => Effect.die("unused"),
remove: () => Effect.die("unused"),
status: () => Effect.die("unused"),
},
oauth: {
connect: () => Effect.die("unused"),
+1
View File
@@ -333,6 +333,7 @@ function resourceMcpLayer(
activate: unusedIntegration,
update: unusedIntegration,
remove: unusedIntegration,
status: unusedIntegration,
},
oauth: {
connect: unusedIntegration,
@@ -375,6 +375,7 @@ describe("ModelResolver", () => {
activate: () => Effect.die("unused"),
update: () => Effect.die("unused"),
remove: () => Effect.die("unused"),
status: () => Effect.die("unused"),
},
oauth: {
connect: () => Effect.die("unused"),
+10
View File
@@ -95,6 +95,7 @@ export function host(overrides: Overrides = {}): Plugin.Context {
connection: {
active: () => Effect.die("unused integration.connection.active"),
resolve: () => Effect.die("unused integration.connection.resolve"),
status: () => Effect.die("unused integration.connection.status"),
},
},
mcp: overrides.mcp ?? {
@@ -313,6 +314,15 @@ export function integrationHost(integration: Integration.Interface): Plugin.Cont
integration.connection.resolve(
connection.type === "credential" ? { ...connection, id: Credential.ID.make(connection.id) } : connection,
),
status: (input) =>
integration.connection.status({
integrationID: Integration.ID.make(input.integrationID),
connection:
input.connection.type === "credential"
? { ...input.connection, id: Credential.ID.make(input.connection.id) }
: input.connection,
status: input.status,
}),
},
transform: (callback) =>
integration.transform((editor) =>
@@ -6,6 +6,7 @@ import { Money } from "@opencode/schema/money"
import { Effect, Layer, Stream } from "effect"
import { TestClock } from "effect/testing"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Agent } from "@opencode/core/agent"
import { App } from "@opencode/core/app"
import { Config } from "@opencode/core/config"
import { ConfigPolicyPlugin } from "@opencode/core/config/plugin/policy"
@@ -16,9 +17,11 @@ import { Mcp } from "@opencode/core/mcp/index"
import { Model } from "@opencode/core/model"
import { ModelResolver } from "@opencode/core/model-resolver"
import { Plugin } from "@opencode/core/plugin"
import { PluginHooks } from "@opencode/core/plugin/hooks"
import { PluginHost } from "@opencode/core/plugin/host"
import { OpencodePlugin } from "@opencode/core/plugin/provider/opencode"
import { Provider } from "@opencode/core/provider"
import { Session } from "@opencode/core/session"
import { WebSearch } from "@opencode/core/websearch"
import { withEnv } from "../fixture/env"
import { emptyMcp } from "../fixture/mcp"
@@ -900,6 +903,266 @@ describe("OpencodePlugin", () => {
),
)
it.effect("reports Console SSO sign-in, keeps the last config, and clears once Console accepts again", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const state = { sso: false }
const server = Bun.serve({
port: 0,
fetch: () => {
if (state.sso)
return Response.json(
{ _tag: "SsoRequired", orgId: "org_acme", connectionId: "ssoconn/1" },
{ status: 403 },
)
return Response.json({
providers: {},
experimental: { policies: [{ action: "permission", resource: "shell:sudo *", effect: "deny" }] },
})
},
})
return { server, state }
}),
({ server, state }) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const integrations = yield* Integration.Service
const managed = yield* ManagedPolicy.Service
const status = () =>
integrations
.get(Integration.ID.make("opencode"))
.pipe(Effect.map((integration) => integration?.connections[0]?.status))
const sudo: ConfigPolicy.Info = { action: "permission", resource: "shell:sudo *", effect: "deny" }
yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.Key.make({
type: "key",
key: "secret",
metadata: { server: `${server.url.origin}/console`, orgID: "org_acme", orgName: "Acme" },
}),
})
yield* addPlugin()
yield* drain
expect(yield* status()).toBeUndefined()
state.sso = true
yield* TestClock.adjust("1 minute")
yield* drain
const url = `${server.url.origin}/console/auth/sso/ssoconn%2F1/start?redirectTo=%2Fconsole%2F`
expect(yield* status()).toEqual({ status: "needs_auth", message: "Sign in with SSO again to use Acme", url })
expect(managed.current()).toEqual({ statements: [sudo], organization: "Acme" })
state.sso = false
yield* TestClock.adjust("1 minute")
yield* drain
expect(yield* status()).toBeUndefined()
expect(managed.current()).toEqual({ statements: [sudo], organization: "Acme" })
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
)
it.live("explains rejected Console inference with the sign-in the connection needs", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const state = { sso: false }
const server = Bun.serve({
port: 0,
fetch: () => {
if (state.sso)
return Response.json(
{ _tag: "SsoRequired", orgId: "org_acme", connectionId: "ssoconn_1" },
{ status: 403 },
)
return Response.json({ providers: { remote: { canonical: "openai" } } })
},
})
return { server, state }
}),
({ server, state }) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const hooks = yield* PluginHooks.Service
yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.Key.make({
type: "key",
key: "secret",
metadata: { server: server.url.origin, orgID: "org_acme", orgName: "Acme" },
}),
})
yield* addPlugin()
const rejected = (providerID: Provider.ID) =>
hooks
.trigger("session", "http.response", {
sessionID: Session.ID.make("ses_console"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID, id: Model.ID.make("model") }),
kind: "primary",
request: new Request("https://inference.example/v1/responses"),
response: Response.json({ error: { message: "Workspace access denied" } }, { status: 403 }),
})
.pipe(
Effect.flatMap((event) =>
Effect.promise(async () => ({ status: event.response.status, body: await event.response.json() })),
),
)
const denied = { status: 403, body: { error: { message: "Workspace access denied" } } }
expect(yield* rejected(Provider.ID.make("remote"))).toEqual(denied)
state.sso = true
const explained = {
status: 403,
body: {
error: {
type: "authentication_error",
message: "Sign in with SSO again to use Acme.",
},
},
}
expect(yield* rejected(Provider.ID.make("remote"))).toEqual(explained)
expect(yield* rejected(Provider.ID.opencode)).toEqual(explained)
expect(yield* rejected(Provider.ID.anthropic)).toEqual(denied)
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
)
it.effect("ignores 403 responses that are not a Console SSO error", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const state: { body: Response | undefined } = { body: undefined }
const server = Bun.serve({
port: 0,
fetch: () =>
state.body ??
Response.json({
providers: {},
experimental: { policies: [{ action: "permission", resource: "shell:sudo *", effect: "deny" }] },
}),
})
return { server, state }
}),
({ server, state }) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const integrations = yield* Integration.Service
const managed = yield* ManagedPolicy.Service
const sudo: ConfigPolicy.Info = { action: "permission", resource: "shell:sudo *", effect: "deny" }
yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.Key.make({ type: "key", key: "secret", metadata: { server: server.url.origin } }),
})
yield* addPlugin()
yield* drain
for (const body of [
() => new Response("<html>Forbidden</html>", { status: 403, headers: { "content-type": "text/html" } }),
() => Response.json({ _tag: "Forbidden", orgId: "org", connectionId: "ssoconn" }, { status: 403 }),
]) {
state.body = body()
yield* TestClock.adjust("1 minute")
yield* drain
expect((yield* integrations.get(Integration.ID.make("opencode")))?.connections[0]?.status).toBeUndefined()
expect(managed.current().statements).toEqual([sudo])
}
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
)
it.effect("reports a rejected Console refresh token as signed out without removing the credential", () =>
Effect.acquireUseRelease(
Effect.sync(() =>
Bun.serve({
port: 0,
fetch: (request) => {
if (new URL(request.url).pathname === "/auth/device/token")
return Response.json(
{ error: "invalid_grant", error_description: "refresh token revoked" },
{ status: 400 },
)
return Response.json({ providers: {} })
},
}),
),
(server) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const integrations = yield* Integration.Service
const credential = yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("device"),
access: "expired",
refresh: "revoked",
expires: 0,
metadata: { server: server.url.origin, orgID: "org_acme", orgName: "Acme" },
}),
})
yield* addPlugin()
yield* drain
expect((yield* integrations.get(Integration.ID.make("opencode")))?.connections[0]?.status).toEqual({
status: "needs_auth",
message: "Reconnect OpenCode Console to continue",
})
expect((yield* credentials.get(credential.id))?.value).toMatchObject({ refresh: "revoked" })
}),
(server) => Effect.promise(() => server.stop(true)),
),
)
it.effect("reports a Console session rejected before its token expires as signed out", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const state = { revoked: true, refreshes: 0 }
const server = Bun.serve({
port: 0,
fetch: (request) => {
if (new URL(request.url).pathname === "/auth/device/token") state.refreshes++
if (state.revoked)
return Response.json({ _tag: "Unauthorized", message: "Invalid or expired session" }, { status: 401 })
return Response.json({ providers: {} })
},
})
return { server, state }
}),
({ server, state }) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const integrations = yield* Integration.Service
const credential = yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("device"),
access: "revoked",
refresh: "refresh",
expires: Number.MAX_SAFE_INTEGER,
metadata: { server: server.url.origin, orgID: "org_acme", orgName: "Acme" },
}),
})
const status = () =>
integrations.get(Integration.ID.make("opencode")).pipe(Effect.map((item) => item?.connections[0]?.status))
yield* addPlugin()
yield* drain
expect(yield* status()).toEqual({ status: "needs_auth", message: "Reconnect OpenCode Console to continue" })
expect((yield* credentials.get(credential.id))?.value).toMatchObject({ access: "revoked" })
state.revoked = false
yield* TestClock.adjust("1 minute")
yield* drain
expect(yield* status()).toBeUndefined()
expect(state.refreshes).toBe(0)
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
)
it.live("loads and executes hosted web search from the connected OpenCode server", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
+31
View File
@@ -264,6 +264,37 @@ test("caps Alibaba thinking budget variants at 64k", () => {
])
})
test("spells Workers AI thinking controls through the chat template", () => {
const pkg = "@opencode/ai/providers/cloudflare-workers-ai"
const off = { body: { chat_template_kwargs: { enable_thinking: false, thinking: false } } }
const on = { body: { chat_template_kwargs: { enable_thinking: true, thinking: true } } }
expect(
resolve(model(pkg, "@cf/deepseek-ai/deepseek-v4-flash-0731"), [
{ type: "effort", values: ["none", "low", "high", "max"] },
]),
).toEqual([
{ id: "none", ...off },
{ id: "low", settings: { reasoningEffort: "low" } },
{ id: "high", settings: { reasoningEffort: "high" } },
{ id: "max", settings: { reasoningEffort: "max" } },
])
expect(resolve(model(pkg, "@cf/zai-org/glm-4.7-flash"), [{ type: "toggle" }])).toEqual([
{ id: "none", ...off },
{ id: "thinking", ...on },
])
expect(
resolve(model(pkg, "@cf/qwen/qwen3.8-27b"), [
{ type: "toggle" },
{ type: "effort", values: ["low", "medium", "xhigh"] },
]),
).toEqual([
{ id: "none", ...off },
{ id: "low", settings: { reasoningEffort: "low" } },
{ id: "medium", settings: { reasoningEffort: "medium" } },
{ id: "xhigh", settings: { reasoningEffort: "xhigh" } },
])
})
test("spells Chat Completions variants for hosting providers", () => {
expect(
resolve(model("@opencode/ai/providers/openai-compatible", "deepseek-ai/deepseek-v4-pro", undefined, "nvidia"), [
@@ -1,5 +1,6 @@
import type { ConnectionInfo } from "@opencode/client"
import type { IntegrationApi } from "@opencode/client/effect/api"
import { Connection } from "@opencode/schema/connection"
import { Credential } from "@opencode/schema/credential"
import { Form } from "@opencode/schema/form"
import type { Effect, Scope } from "effect"
@@ -92,5 +93,11 @@ export interface IntegrationDomain extends Omit<IntegrationApi<unknown>, "wellkn
readonly connection: {
readonly active: (integrationID: string) => Effect.Effect<ConnectionInfo | undefined>
readonly resolve: (connection: ConnectionInfo) => Effect.Effect<Credential.Value | undefined, unknown>
/** Reports a problem with a connection, such as a required sign-in; `undefined` clears it. */
readonly status: (input: {
readonly integrationID: string
readonly connection: ConnectionInfo
readonly status: Connection.Status | undefined
}) => Effect.Effect<void>
}
}
+1
View File
@@ -427,6 +427,7 @@ export function fromPromise(plugin: Plugin) {
connection: {
active: (id) => Effect.runPromiseWith(runtime)(host.integration.connection.active(id)),
resolve: (connection) => Effect.runPromiseWith(runtime)(host.integration.connection.resolve(connection)),
status: (input) => run(host.integration.connection.status(input)),
},
},
mcp: {
@@ -1,5 +1,6 @@
import type { ConnectionInfo } from "@opencode/client"
import type { IntegrationApi } from "@opencode/client/promise/api"
import { Connection } from "@opencode/schema/connection"
import { Credential } from "@opencode/schema/credential"
import { Form } from "@opencode/schema/form"
import type { Transform } from "./registration.js"
@@ -87,5 +88,11 @@ export interface IntegrationDomain extends Omit<IntegrationApi, "wellknown"> {
readonly connection: {
readonly active: (integrationID: string) => Promise<ConnectionInfo | undefined>
readonly resolve: (connection: ConnectionInfo) => Promise<Credential.Value | undefined>
/** Reports a problem with a connection, such as a required sign-in; `undefined` clears it. */
readonly status: (input: {
readonly integrationID: string
readonly connection: ConnectionInfo
readonly status: Connection.Status | undefined
}) => Promise<void>
}
}
+14
View File
@@ -2,6 +2,18 @@ export * as Connection from "./connection.js"
import { Schema } from "effect"
import { Credential } from "./credential.js"
import { optional } from "./schema.js"
/**
* Runtime problem an integration reported for a connection. `needs_auth` asks the user to sign in again:
* at `url` when set (the credential itself stays valid), otherwise by reconnecting the integration.
*/
export interface Status extends Schema.Schema.Type<typeof Status> {}
export const Status = Schema.Struct({
status: Schema.Literals(["needs_auth"]),
message: Schema.String,
url: optional(Schema.String),
}).annotate({ identifier: "Connection.Status" })
export interface CredentialInfo extends Schema.Schema.Type<typeof CredentialInfo> {}
export const CredentialInfo = Schema.Struct({
@@ -10,12 +22,14 @@ export const CredentialInfo = Schema.Struct({
label: Schema.String,
/** How the credential was obtained: a stored key or an OAuth grant. */
method: Schema.Literals(["key", "oauth"]),
status: optional(Status),
}).annotate({ identifier: "Connection.CredentialInfo" })
export interface EnvInfo extends Schema.Schema.Type<typeof EnvInfo> {}
export const EnvInfo = Schema.Struct({
type: Schema.Literal("env"),
name: Schema.String,
status: optional(Status),
}).annotate({ identifier: "Connection.EnvInfo" })
export const Info = Schema.Union([CredentialInfo, EnvInfo])
+3
View File
@@ -3,6 +3,7 @@ export * as ServerProcess from "./process"
import { NodeHttpServer } from "@effect/platform-node"
import { Bus } from "@opencode/core/bus"
import { SessionRestart } from "@opencode/core/session/execution/restart"
import { SessionSchedule } from "@opencode/core/session/schedule"
import { InstallationEvent } from "@opencode/schema/installation-event"
import { hasPtyConnectTicketURL } from "@opencode/protocol/groups/pty"
import { hasPersistentPtyConnectTicketURL } from "@opencode/protocol/groups/persistent-pty"
@@ -105,6 +106,8 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
yield* installRestartContinuity(Context.get(context, SessionRestart.Service)).pipe(
Effect.provideService(Scope.Scope, applicationScope),
)
// Only the managed server restores saved schedules, so extra servers sharing the database never double-fire.
yield* Context.get(context, SessionSchedule.Service).resume
}
yield* Ref.set(application, Option.some(Context.get(context, HttpRouter.HttpRouter).asHttpEffect()))
yield* status.ready
+2
View File
@@ -27,6 +27,7 @@ import { LocationServiceMap } from "@opencode/core/location-service-map"
import { LocationActivity } from "@opencode/core/location-activity"
import { ModelsDev } from "@opencode/core/models-dev"
import { SessionRestart } from "@opencode/core/session/execution/restart"
import { SessionSchedule } from "@opencode/core/session/schedule"
import { PluginUpdate } from "@opencode/core/plugin/update"
import { SdkPlugins } from "@opencode/core/plugin/sdk"
import { WellKnown } from "@opencode/core/wellknown"
@@ -73,6 +74,7 @@ const applicationServiceNodes = [
LocationServiceMap.node,
LocationActivity.node,
SessionRestart.node,
SessionSchedule.node,
Workspace.node,
] as const
const applicationServices = LayerNode.group(applicationServiceNodes)
@@ -106,15 +106,19 @@ export function DialogIntegration(
let category = "Services"
if (integration.id in INTEGRATION_PRIORITY) category = "Popular"
if (integration.metadata?.source === "mcp") category = "MCP"
const status = integration.connections[0]?.status
return {
title: integration.name,
value: integration.id,
description: methods.length === 0 ? "Environment only" : undefined,
footer: connectionSummary(integration) || undefined,
footer: status ? "Sign in required →" : connectionSummary(integration) || undefined,
footerColor: status ? theme.text.feedback.warning.base : undefined,
category,
disabled: methods.length === 0 && credentials.length === 0,
gutter:
integration.connections.length > 0 ? () => <text fg={theme.text.feedback.success.base}>✓</text> : undefined,
integration.connections.length > 0 && !status
? () => <text fg={theme.text.feedback.success.base}>✓</text>
: undefined,
onSelect: () => {
if (credentials.length) return manageConnections(integration, methods, location, dialog, props.onConnected)
return selectMethod(integration, methods, location, dialog, props.onConnected)
@@ -189,10 +193,15 @@ function manageConnections(
? `Press ${shortcuts.get("dialog.integration.delete")} again to confirm`
: connection.label,
value: connection.id,
footer: connection.status ? "Sign in required →" : undefined,
footerColor: connection.status ? theme.text.feedback.warning.base : undefined,
category: "Connected accounts",
bg: confirming ? theme.background.action.destructive.focused : undefined,
fg: confirming ? theme.text.action.destructive.focused : undefined,
onSelect: () => {
if (connection.status?.url) return void openUrl(connection.status.url).catch(toast.error)
if (connection.status)
return selectMethod(current() ?? integration, methods, location, dialog, onConnected)
if (credentialConnections(current() ?? integration)[0]?.id === connection.id) return
void client.api.credential.activate({ credentialID: connection.id }).catch(toast.error)
},
+37 -13
View File
@@ -43,6 +43,7 @@ import { TabPulse, unreadGlowIntensity } from "./tab-pulse"
import { tint } from "../theme/color"
import { SESSION_SIDEBAR_WIDTH, SESSION_TABS_COMPACT_BREAKPOINT } from "../ui/layout"
import { projectName } from "../util/project"
import { stringWidth } from "../util/string-width"
import { marqueeCycleWidth, marqueeOverflows, marqueeTextParts } from "../util/marquee"
import { useDialog } from "../ui/dialog"
import { useToast } from "../ui/toast"
@@ -112,6 +113,8 @@ export const EMPTY_SESSION_TAB_STATUS: SessionTabsStatus = {
export type SessionTabsController = Pick<ContextController, "tabs" | "current" | "select" | "close" | "move"> & {
newTab?: () => boolean
add?: () => void
recentlyClosed?: ContextController["recentlyClosed"]
reopen?: ContextController["reopen"]
detail?: (sessionID: string) => string | undefined
rename?: (sessionID: string) => void
search?: () => void
@@ -379,11 +382,12 @@ function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsCo
mode: "menu",
commands: [{ bind: "escape,ctrl+c", title: "Close tab menu", group: "Tabs", run: props.onClose }],
}))
const actions = createMemo(() => {
const actions = createMemo<Array<{ title: string; run?: () => void }>>(() => {
const sessionID = props.state.sessionID
const title = props.state.title
const closed = (props.tabs.recentlyClosed?.() ?? []).slice(0, 10)
return [
...(props.tabs.add ? [{ title: "New tab", run: () => props.tabs.add?.() }] : []),
...(sessionID && props.tabs.add ? [{ title: NEW_SESSION_TAB_TITLE, run: () => props.tabs.add?.() }] : []),
...(sessionID
? [
{
@@ -402,15 +406,32 @@ function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsCo
{ title: "Close", run: () => props.tabs.close(sessionID) },
]
: []),
...(!sessionID && props.tabs.reopen
? [
{ title: "Recently closed tabs" },
...closed.map((tab) => ({
title: tab.title || "Untitled session",
run: () => props.tabs.reopen?.(tab.sessionID),
})),
...(closed.length === 0 ? [{ title: "No recently closed tabs" }] : []),
]
: []),
]
})
const [selected, setSelected] = createSignal<number>()
const top = () => Math.max(0, Math.min(props.state.y + 1, dimensions().height - actions().length))
const left = () => Math.max(0, Math.min(props.state.x, dimensions().width - CONTEXT_MENU_WIDTH))
const width = () =>
Math.min(
dimensions().width,
Math.max(CONTEXT_MENU_WIDTH, ...actions().map((action) => Math.min(50, stringWidth(action.title) + 2))),
)
const height = () => Math.min(actions().length, dimensions().height)
const top = () => Math.max(0, Math.min(props.state.y + 1, dimensions().height - height()))
const left = () => Math.max(0, Math.min(props.state.x, dimensions().width - width()))
const run = (index: number) => {
const action = actions()[index]
if (!action?.run) return
props.onClose()
action?.run()
action.run()
}
return (
@@ -437,13 +458,14 @@ function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsCo
event.stopPropagation()
}}
>
<box
<scrollbox
position="absolute"
left={left()}
top={top()}
height={actions().length}
width={CONTEXT_MENU_WIDTH}
flexDirection="column"
height={height()}
width={width()}
scrollX={false}
scrollbarOptions={{ visible: false }}
backgroundColor={background()}
onMouseDown={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) props.onClose()
@@ -457,8 +479,10 @@ function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsCo
width="100%"
paddingLeft={1}
paddingRight={1}
backgroundColor={selected() === index() ? actionHovered() : undefined}
onMouseOver={() => setSelected(index())}
height={1}
flexShrink={0}
backgroundColor={action.run && selected() === index() ? actionHovered() : undefined}
onMouseOver={() => setSelected(action.run ? index() : undefined)}
onMouseOut={() => setSelected(undefined)}
onMouseUp={(event) => {
event.preventDefault()
@@ -467,13 +491,13 @@ function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsCo
run(index())
}}
>
<text fg={theme.text.base} selectable={false}>
<text fg={action.run ? theme.text.base : theme.text.muted} selectable={false} truncate>
{action.title}
</text>
</box>
)}
</For>
</box>
</scrollbox>
</box>
</Portal>
)
@@ -59,7 +59,7 @@ export type ClosedSessionTab = {
index: number
}
const CLOSED_SESSION_TAB_LIMIT = 10
const CLOSED_SESSION_TAB_LIMIT = 25
export function recordClosedSessionTab(
stack: readonly ClosedSessionTab[],
@@ -74,11 +74,14 @@ export function recordClosedSessionTab(
/**
* Pop the most recently closed tab that is not already open and restore it at its original
* position. Entries for already-open sessions are consumed so repeated reopens walk the stack.
* Selecting a session removes only its entry, preserving the rest of the closed history.
*/
export function reopenSessionTab(stack: readonly ClosedSessionTab[], tabs: readonly SessionTab[]) {
export function reopenSessionTab(stack: readonly ClosedSessionTab[], tabs: readonly SessionTab[], sessionID?: string) {
const remaining = [...stack]
while (remaining.length > 0) {
const entry = remaining.pop()!
const index = sessionID ? remaining.findIndex((entry) => entry.tab.sessionID === sessionID) : remaining.length - 1
if (index === -1) break
const [entry] = remaining.splice(index, 1)
if (tabs.some((tab) => tab.sessionID === entry.tab.sessionID)) continue
const next = [...tabs]
next.splice(Math.min(entry.index, tabs.length), 0, entry.tab)
+15 -6
View File
@@ -80,7 +80,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
const [promptPulses, setPromptPulses] = createSignal<Record<string, number>>({})
let history: SessionTabHistory = { entries: [], index: -1 }
// User-closed tabs eligible for reopening; in-memory like history, deleted sessions pruned.
let closedTabs: ClosedSessionTab[] = []
const [closedTabs, setClosedTabs] = createSignal<ClosedSessionTab[]>([])
// Storage mutations apply against the on-disk draft under a file lock, so
// a registration queued by the route effect can land AFTER a removal that
// ran while the write was still in flight — resurrecting a tab that was
@@ -317,7 +317,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
onCleanup(
event.on("session.deleted", (evt) => {
const target = root(evt.data.sessionID)
closedTabs = closedTabs.filter((entry) => entry.tab.sessionID !== target)
setClosedTabs((entries) => entries.filter((entry) => entry.tab.sessionID !== target))
remove(evt.data.sessionID, enabled())
}),
)
@@ -354,6 +354,15 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
tabs() {
return state().tabs
},
recentlyClosed() {
return closedTabs()
.filter((entry) => !state().tabs.some((tab) => tab.sessionID === entry.tab.sessionID))
.toReversed()
.map((entry) => ({
...entry.tab,
title: data.session.get(entry.tab.sessionID)?.title ?? entry.tab.title,
}))
},
newTab() {
return newTab()
},
@@ -420,13 +429,13 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
}
const index = state().tabs.findIndex((tab) => tab.sessionID === target)
const tab = state().tabs[index]
if (tab) closedTabs = recordClosedSessionTab(closedTabs, tab, index)
if (tab) setClosedTabs((entries) => recordClosedSessionTab(entries, tab, index))
remove(target, true)
},
reopen() {
reopen(sessionID?: string) {
if (!enabled()) return
const result = reopenSessionTab(closedTabs, state().tabs)
closedTabs = result.stack
const result = reopenSessionTab(closedTabs(), state().tabs, sessionID)
setClosedTabs(result.stack)
const tabs = result.tabs
if (!tabs || !result.sessionID) return
cancelledTabs.delete(result.sessionID)
@@ -91,7 +91,6 @@ export function activitySummary(items: readonly Item[], instructions: number, cl
.map(([name, count]) => `${count} ${name}${count === 1 ? "" : "s"}`)
.join(", "),
active: items.some((item) => isActive(item, closed)),
failed: items.some(failed),
}
}
@@ -100,11 +99,3 @@ function isActive(item: Item, closed: boolean) {
return !closed && item.part.time?.completed === undefined && item.message.time.completed === undefined
return item.part.state.status === "streaming" || item.part.state.status === "running"
}
function failed(item: Item) {
if (item.part.type !== "tool") return false
if (item.part.state.status === "error") return true
if (canonicalToolName(item.part.name) !== "execute" || item.part.state.status === "streaming") return false
const metadata = item.part.state.metadata
return metadata?.error === true || executeCalls(metadata?.toolCalls).some((call) => call.status === "error")
}
@@ -211,7 +211,7 @@ function ActivityGroup(props: GroupProps) {
<GroupAnchor groupID={disclosure.id()} active={summary().label !== ""}>
<Show when={summary().label}>
<InlineToolRow
icon={summary().failed ? "✗" : disclosure.expanded() ? "−" : "+"}
icon={disclosure.expanded() ? "−" : "+"}
color={hover() ? theme.text.base : theme.text.muted}
complete={true}
pending={summary().label}
@@ -200,6 +200,57 @@ test("a low activity group with nothing finished stays collapsed behind a status
}
})
test("failed low activity uses a disclosure icon and keeps details expandable", async () => {
const config = createTuiResolvedConfig({ animations: false })
const anchors = createTimelineAnchors()
const [expanded, setExpanded] = createStore<Record<string, boolean>>({})
const message: SessionMessageAssistant = {
id: "a",
type: "assistant",
agent: "build",
model: { providerID: "fixture", id: "fixture" },
time: { created: 0, completed: 2 },
content: [
{
type: "tool",
id: "failed-shell",
name: "shell",
time: { created: 0, completed: 2 },
state: { status: "error", input: {}, error: { type: "Fixture", message: "command failed" } },
},
],
}
const row: SessionGroup = {
type: "group",
kind: "activity",
size: 1,
completed: true,
pending: [],
children: [{ type: "entry", size: 1, entry: { type: "part", ref: { messageID: "a", partID: "failed-shell" } } }],
}
const app = await mount({
row,
anchors,
config,
expanded: (id) => expanded[id],
setExpanded: (id, value) => setExpanded(id, value),
message: () => message,
entry: () => <text>command failed</text>,
})
try {
app.renderer.start()
await app.waitForFrame((frame) => frame.includes("1 command"))
expect(app.captureCharFrame()).toContain("+ 1 command")
expect(app.captureCharFrame()).not.toContain("command failed")
await app.mockMouse.click(4, anchors.get({ type: "group", groupID: groupID(row, 0)! })?.node.y ?? -1)
await app.renderOnce()
expect(app.captureCharFrame()).toContain("− 1 command")
expect(app.captureCharFrame()).toContain("command failed")
} finally {
app.renderer.destroy()
}
})
function mount(input: {
row: SessionGroup
anchors: ReturnType<typeof createTimelineAnchors>
@@ -180,11 +180,10 @@ test("activity summary counts finished work by category", () => {
expect(activitySummary(items, 2)).toEqual({
label: "2 commands, 2 edits, 1 thought, 1 read, 1 tool, 2 instructions",
active: false,
failed: false,
})
})
test("running work is active but not counted, and failures are reported", () => {
test("running work is active but not counted, and failed tools are counted", () => {
const open = { ...assistant("a", []), time: { created: 1 } }
const summary = activitySummary(
[
@@ -195,7 +194,7 @@ test("running work is active but not counted, and failures are reported", () =>
],
0,
)
expect(summary).toEqual({ label: "1 read, 1 tool", active: true, failed: true })
expect(summary).toEqual({ label: "1 read, 1 tool", active: true })
})
test("execute counts its finished nested calls instead of itself", () => {
@@ -219,7 +218,6 @@ test("execute counts its finished nested calls instead of itself", () => {
expect(activitySummary([{ message, part: execute }], 0)).toEqual({
label: "2 reads, 1 tool",
active: false,
failed: true,
})
})
@@ -257,8 +255,8 @@ test("a thought still streaming counts as finished once a later row closes its g
{ message: open, part: { type: "reasoning" as const, text: "Planning", time: { created: 1 } } },
{ message: open, part: tool("r1", "read") },
]
expect(activitySummary(items, 0)).toEqual({ label: "1 read", active: true, failed: false })
expect(activitySummary(items, 0, true)).toEqual({ label: "1 thought, 1 read", active: false, failed: false })
expect(activitySummary(items, 0)).toEqual({ label: "1 read", active: true })
expect(activitySummary(items, 0, true)).toEqual({ label: "1 thought, 1 read", active: false })
})
test("until something finishes, the label is the first running item's status", () => {
@@ -271,7 +269,6 @@ test("until something finishes, the label is the first running item's status", (
expect(summarizeActivity(activity, () => open, [], false)).toEqual({
label: "Running edit…",
active: true,
failed: false,
})
})
@@ -35,6 +35,11 @@ for (const orientation of ["horizontal", "vertical"] as const) {
const [newTab, setNewTab] = createSignal(false)
const settings: Info = { tabs: { mode: "on" } }
const copied: string[] = []
const reopened: string[] = []
const [closed, setClosed] = createSignal([
{ sessionID: "closed-new", title: "Most recently closed" },
{ sessionID: "closed-old", title: "Earlier session" },
])
let config!: ReturnType<typeof useConfig>
let theme!: ReturnType<typeof useTheme>
function Colors() {
@@ -57,6 +62,12 @@ for (const orientation of ["horizontal", "vertical"] as const) {
},
close() {},
move() {},
add() {},
recentlyClosed: closed,
reopen(sessionID?: string) {
if (sessionID) reopened.push(sessionID)
setClosed((tabs) => tabs.filter((tab) => tab.sessionID !== sessionID))
},
detail: () => "project",
status: (sessionID: string) => (sessionID === "first" ? status() : EMPTY_SESSION_TAB_STATUS),
} satisfies SessionTabsController
@@ -205,17 +216,45 @@ for (const orientation of ["horizontal", "vertical"] as const) {
const column = rows[row]!.indexOf("First")
await app.mockMouse.click(column, row, MouseButton.RIGHT)
await app.waitForFrame((frame) => frame.includes("Rename"))
expect(app.captureCharFrame().split("\n")[row + 1]!.indexOf("Rename")).toBe(column + 1)
expect(app.captureCharFrame().split("\n")[row + 2]!.indexOf("Rename")).toBe(column + 1)
expect(app.captureCharFrame()).toContain("Copy session ID")
expect(app.captureCharFrame()).toContain("Close")
expect(app.captureCharFrame()).not.toContain("Keep open")
expect(active()).toBe("second")
await app.mockMouse.click(column + 1, row + 2)
await app.mockMouse.click(column + 1, row + 3)
expect(copied).toEqual(["first"])
await app.waitForFrame((frame) => !frame.includes("Rename"))
setNewTab(true)
await app.waitForFrame((frame) => frame.includes("+ New session"))
for (const width of [60, 35]) {
app.renderer.resize(width, 10)
await app.renderOnce()
const rows = app.captureCharFrame().split("\n")
const row = rows.findIndex((line) => line.includes("+ New session"))
await app.mockMouse.click(rows[row]!.indexOf("+ New session") + 2, row, MouseButton.RIGHT)
await app.waitForFrame((frame) => frame.includes("Recently closed tabs"))
expect(app.captureCharFrame()).not.toContain("New tab")
const menu = app.captureCharFrame().split("\n")
expect(menu.some((line) => line.trim() === "New session")).toBe(false)
const earlier = menu.findIndex((line) => line.includes("Earlier session"))
expect(earlier).toBeGreaterThan(menu.findIndex((line) => line.includes("Most recently closed")))
await app.mockMouse.click(menu[earlier]!.indexOf("Earlier session"), earlier)
expect(reopened.at(-1)).toBe("closed-old")
await app.waitForFrame((frame) => !frame.includes("Recently closed tabs"))
setClosed([
{ sessionID: "closed-new", title: "Most recently closed" },
{ sessionID: "closed-old", title: "Earlier session" },
])
}
app.renderer.resize(60, 20)
setClosed(Array.from({ length: 11 }, (_, index) => ({ sessionID: `closed-${index}`, title: `History ${index + 1}` })))
await app.renderOnce()
const historyRows = app.captureCharFrame().split("\n")
const newRow = historyRows.findIndex((line) => line.includes("+ New session"))
await app.mockMouse.click(historyRows[newRow]!.indexOf("+ New session") + 2, newRow, MouseButton.RIGHT)
await app.waitForFrame((frame) => frame.includes("History 10"))
expect(app.captureCharFrame()).not.toContain("History 11")
} finally {
app.renderer.destroy()
}
@@ -201,6 +201,20 @@ describe("session tabs", () => {
expect(reopenSessionTab([], tabs)).toEqual({ stack: [], tabs: undefined, sessionID: undefined })
})
test("reopens a selected closed tab without consuming newer entries", () => {
const stack = recordClosedSessionTab(recordClosedSessionTab([], { sessionID: "b" }, 1), { sessionID: "c" }, 2)
const result = reopenSessionTab(stack, [{ sessionID: "a" }], "b")
expect(result.sessionID).toBe("b")
expect(result.tabs).toEqual([{ sessionID: "a" }, { sessionID: "b" }])
expect(result.stack).toEqual([{ tab: { sessionID: "c" }, index: 2 }])
expect(reopenSessionTab(stack, [], "missing")).toEqual({ stack, tabs: undefined, sessionID: undefined })
expect(reopenSessionTab(stack, [{ sessionID: "b" }], "b")).toEqual({
stack: [{ tab: { sessionID: "c" }, index: 2 }],
tabs: undefined,
sessionID: undefined,
})
})
test("skips and consumes closed entries that are already open", () => {
const stack = [
{ tab: { sessionID: "a" }, index: 0 },
@@ -220,12 +234,12 @@ describe("session tabs", () => {
const reopened = reopenSessionTab(twice, [{ sessionID: "b" }])
expect(reopened.tabs).toEqual([{ sessionID: "b" }, { sessionID: "a" }])
const overflow = Array.from({ length: 12 }, (_, index) => ({ sessionID: String(index) })).reduce(
const overflow = Array.from({ length: 27 }, (_, index) => ({ sessionID: String(index) })).reduce(
(stack, tab, index) => recordClosedSessionTab(stack, tab, index),
twice,
)
expect(overflow).toHaveLength(10)
expect(overflow.at(-1)?.tab.sessionID).toBe("11")
expect(overflow).toHaveLength(25)
expect(overflow.at(-1)?.tab.sessionID).toBe("26")
expect(overflow[0]?.tab.sessionID).toBe("2")
})
@@ -323,6 +323,28 @@ test("keeps each visited session open", async () => {
}
})
test("lists closed tabs newest first and reopens a selected entry", async () => {
const setup = await renderSessionTabs("first", { persisted: ["first", "second", "third"] })
try {
await wait(() => setup.tabs.tabs().length === 3)
setup.tabs.close("second")
await wait(() => setup.tabs.tabs().length === 2)
setup.tabs.close("third")
await wait(() => setup.tabs.tabs().length === 1)
expect(setup.tabs.recentlyClosed().map((tab) => tab.sessionID)).toEqual(["third", "second"])
setup.tabs.reopen("second")
await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "second"))
expect(setup.tabs.current()).toBe("second")
expect(setup.tabs.recentlyClosed().map((tab) => tab.sessionID)).toEqual(["third"])
setup.tabs.reopen()
await wait(() => setup.tabs.tabs().length === 3)
expect(setup.tabs.current()).toBe("third")
expect(setup.tabs.recentlyClosed()).toEqual([])
} finally {
await setup.destroy()
}
})
test("stores session tabs for the current working directory by default", async () => {
const setup = await renderSessionTabs("first")