Key findings
Full referenceWhat OpenCode v1.18.34 does with the model's context, read from the shipped source at commit aec0b9a6d889. Each finding shows the code that establishes it and when it applies; the full records are on the prompts, tools, configuration and network pages. These records derive only from public upstream source. Conditions describe possible harness behavior; they do not establish that any text was sent in a session. Runtime configuration, plugins, MCP servers, provider catalogs and SDK serialization can change a request. Private recordings are not inputs to this extractor. Source excerpts are copyright (c) 2025 opencode, under the upstream MIT license; its notice is preserved in upstream-license.txt.
How the system prompt is chosen
Provider prompt routing
Selection is conditional source behavior. Model publisher names do not identify the observed serving provider.
When: SystemPrompt.provider selects by ordered model.api.id branches, then provider ID, then fallback; request preparation may replace it with agent.prompt.
Source: opencode/src/session/system.ts lines 28–51 · SHA-256 b59739026cff…
export function provider(model: Provider.Model) {
if (model.api.id.includes("muse")) {
const name = model.api.id.includes("muse-glimmer") ? "Muse Glimmer" : "Muse Spark"
return [PROMPT_META.replaceAll("{{MODEL_NAME}}", name)]
}
if (model.api.id.includes("gpt-4") || model.api.id.includes("o1") || model.api.id.includes("o3"))
return [PROMPT_BEAST]
if (model.api.id.includes("gpt")) {
if (model.api.id.includes("gpt-6")) return [PROMPT_ASTRA]
if (model.api.id.includes("codex")) {
return [PROMPT_CODEX]
}
return [PROMPT_GPT]
}
if (model.api.id.includes("gemini-")) return [PROMPT_GEMINI]
if (model.api.id.includes("claude")) return [PROMPT_ANTHROPIC]
if (model.api.id.toLowerCase().includes("trinity")) return [PROMPT_TRINITY]
if (
model.api.id.toLowerCase().includes("kimi") ||
["kimi-for-coding", "moonshotai", "moonshotai-cn"].includes(model.providerID)
)
return [PROMPT_KIMI]
return [PROMPT_DEFAULT]
}
Global, project and configured instructions
First existing global candidate wins. Project search tries AGENTS.md, CLAUDE.md when enabled, then deprecated CONTEXT.md and stops at the first filename with matches. Explicit instructions add globs/files or fetched URL bodies.
When: Instruction.system loads discovered instructions; Claude compatibility may be disabled and project config may be disabled.
Source: opencode/src/session/instruction.ts lines 58–178 · SHA-256 58f5f531a3cf…
const flags = yield* RuntimeFlags.Service
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
const globalFiles = [
path.join(global.config, "AGENTS.md"),
...(!flags.disableClaudeCodePrompt ? [path.join(global.home, ".claude", "CLAUDE.md")] : []),
]
const instructionFiles = [
"AGENTS.md",
...(!flags.disableClaudeCodePrompt ? ["CLAUDE.md"] : []),
"CONTEXT.md", // deprecated
]
const state = yield* InstanceState.make(
Effect.fn("Instruction.state")(() =>
Effect.succeed({
// Track which instruction files have already been attached for a given assistant message.
claims: new Map<MessageID, Set<string>>(),
}),
),
)
const relative = Effect.fnUntraced(function* (instruction: string) {
const ctx = yield* InstanceState.context
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
return yield* fs
.globUp(instruction, ctx.directory, ctx.worktree)
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
}
return yield* fs
.globUp(instruction, global.config, global.config)
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
})
const read = Effect.fnUntraced(function* (filepath: string) {
return yield* fs.readFileString(filepath).pipe(Effect.catch(() => Effect.succeed("")))
})
const fetch = Effect.fnUntraced(function* (url: string) {
const res = yield* http.execute(HttpClientRequest.get(url)).pipe(
Effect.timeout(5000),
Effect.catch(() => Effect.succeed(null)),
)
if (!res) return ""
const body = yield* res.arrayBuffer.pipe(Effect.catch(() => Effect.succeed(new ArrayBuffer(0))))
return new TextDecoder().decode(body)
})
const clear = Effect.fn("Instruction.clear")(function* (messageID: MessageID) {
const s = yield* InstanceState.get(state)
s.claims.delete(messageID)
})
const systemPaths = Effect.fn("Instruction.systemPaths")(function* () {
const config = yield* cfg.get()
const ctx = yield* InstanceState.context
const paths = new Set<string>()
for (const file of globalFiles) {
if (yield* fs.existsSafe(file)) {
paths.add(path.resolve(file))
break
}
}
// The first project-level match wins so we don't stack AGENTS.md/CLAUDE.md from every ancestor.
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
for (const file of instructionFiles) {
const matches = yield* fs
.findUp(file, ctx.directory, ctx.worktree)
.pipe(Effect.catch(() => Effect.succeed([])))
if (matches.length > 0) {
matches.forEach((item) => paths.add(path.resolve(item)))
break
}
}
}
if (config.instructions) {
for (const raw of config.instructions) {
if (raw.startsWith("https://") || raw.startsWith("http://")) continue
const instruction = raw.startsWith("~/") ? path.join(global.home, raw.slice(2)) : raw
const matches = yield* (
path.isAbsolute(instruction)
? fs.glob(path.basename(instruction), {
cwd: path.dirname(instruction),
absolute: true,
include: "file",
})
: relative(instruction)
).pipe(Effect.catch(() => Effect.succeed([] as string[])))
matches.forEach((item) => paths.add(path.resolve(item)))
}
}
return paths
})
const system = Effect.fn("Instruction.system")(function* () {
const config = yield* cfg.get()
const paths = yield* systemPaths()
const urls = (config.instructions ?? []).filter(
(item) => item.startsWith("https://") || item.startsWith("http://"),
)
const files = yield* Effect.forEach(Array.from(paths), read, { concurrency: 8 })
const remote = yield* Effect.forEach(urls, fetch, { concurrency: 4 })
return [
...Array.from(paths).flatMap((item, i) => (files[i] ? [`Instructions from: ${item}\n${files[i]}`] : [])),
...urls.flatMap((item, i) => (remote[i] ? [`Instructions from: ${item}\n${remote[i]}`] : [])),
]
})
const find = Effect.fn("Instruction.find")(function* (dir: string) {
for (const file of instructionFiles) {
const filepath = path.resolve(path.join(dir, file))
if (yield* fs.existsSafe(filepath)) return filepath
}
return undefined
})
How a request is built and sent
Request preparation and plugin transforms
agent.prompt replaces provider prompt; then input.system and user.system append. Plugins may transform system, params and headers. Options merge base, model, agent and selected user variant in that order. OpenAI OAuth puts system text into instructions instead of system messages; GitLab workflows have a separate systemPrompt path.
When: LLMRequestPrep.prepare runs before the selected runtime executes.
Source: opencode/src/session/llm/request.ts lines 56–146 · SHA-256 98f2b612644a…
export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: PrepareInput) {
const isOpenaiOauth = input.provider.id === "openai" && input.auth?.type === "oauth"
const system = [
[
...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)),
...input.system,
...(input.user.system ? [input.user.system] : []),
]
.filter((x) => x)
.join("\n"),
]
const header = system[0]
yield* input.plugin.trigger(
"experimental.chat.system.transform",
{ sessionID: input.sessionID, model: input.model },
{ system },
)
if (system.length > 2 && system[0] === header) {
const rest = system.slice(1)
system.length = 0
system.push(header, rest.join("\n"))
}
const variant =
!input.small && input.model.variants && input.user.model.variant
? input.model.variants[input.user.model.variant]
: {}
const base = input.small
? ProviderTransform.smallOptions(input.model)
: ProviderTransform.options({
model: input.model,
sessionID: input.sessionID,
providerOptions: input.provider.options,
})
const options = mergeOptions(mergeOptions(mergeOptions(base, input.model.options), input.agent.options), variant)
if (
input.model.api.npm === "@ai-sdk/azure" &&
(input.provider.options.useCompletionUrls || input.model.options.useCompletionUrls || options.useCompletionUrls)
) {
delete options.reasoningSummary
delete options.include
}
if (isOpenaiOauth) options.instructions = system.join("\n")
const messages =
isOpenaiOauth || input.isWorkflow
? input.messages
: [
...system.map(
(x): ModelMessage => ({
role: "system",
content: x,
}),
),
...input.messages,
]
const params = yield* input.plugin.trigger(
"chat.params",
{
sessionID: input.sessionID,
agent: input.agent.name,
model: input.model,
provider: input.provider,
message: input.user,
},
{
temperature: input.model.capabilities.temperature
? (input.agent.temperature ?? ProviderTransform.temperature(input.model))
: undefined,
topP: input.agent.topP ?? ProviderTransform.topP(input.model),
topK: ProviderTransform.topK(input.model),
maxOutputTokens: ProviderTransform.maxOutputTokens(input.model, input.flags.outputTokenMax),
options,
},
)
const { headers } = yield* input.plugin.trigger(
"chat.headers",
{
sessionID: input.sessionID,
agent: input.agent.name,
model: input.model,
provider: input.provider,
message: input.user,
},
{
headers: {},
},
)
Tool registration, model selection and definition hooks
tool.definition may alter descriptions or schemas. Task descriptions append permitted subagents; execute appends a visible MCP catalog.
When: ToolRegistry state combines built-ins, config-directory custom tools and plugin tools; model/flags/permissions affect advertised tools.
Source: opencode/src/tool/registry.ts lines 120–353 · SHA-256 81af6d956205…
const state = yield* InstanceState.make<State>(
Effect.fn("ToolRegistry.state")(function* (ctx) {
const custom: Tool.Def[] = []
function fromPlugin(id: string, def: ToolDefinition): Tool.Def {
// Plugin tools still expose Zod args publicly; keep that compatibility
// boxed at the registry boundary and give the LLM the original JSON Schema.
// Normalize missing args to `{}` once — pre-1.14.49 the code was
// `z.object(def.args)` and Zod silently tolerated undefined (#27451, #27630).
const args = def.args ?? {}
const entries = Object.entries(args)
const allZod = entries.every((entry) => isZodType(entry[1]))
const zodParams = allZod ? z.object(args) : undefined
const jsonSchema = zodParams ? zodJsonSchema(zodParams) : legacyJsonSchema(entries)
const parameters = zodParams
? Schema.declare<unknown>((u): u is unknown => zodParams.safeParse(u).success)
: Schema.Unknown
return {
id,
parameters,
jsonSchema,
description: def.description,
execute: (args, toolCtx) =>
Effect.gen(function* () {
// Bridge the host's Effect-based `ask` into a Promise-returning
// function for the plugin to make sure context persists
const bridge = yield* EffectBridge.make()
const pluginCtx: PluginToolContext = {
...toolCtx,
ask: (req) => bridge.promise(toolCtx.ask(req)),
directory: ctx.directory,
worktree: ctx.worktree,
}
const result = yield* Effect.promise(() => def.execute(args as any, pluginCtx))
const output = typeof result === "string" ? result : result.output
const metadata = typeof result === "string" ? {} : (result.metadata ?? {})
const attachments = typeof result === "string" ? undefined : result.attachments
const info = yield* agent.get(toolCtx.agent)
const out = yield* truncate.output(output, {}, info)
return {
title: typeof result === "string" ? "" : (result.title ?? ""),
output: out.truncated ? out.content : output,
attachments,
metadata: {
...metadata,
truncated: out.truncated,
...(out.truncated && { outputPath: out.outputPath }),
},
}
}).pipe(
Effect.withSpan("Tool.execute", {
attributes: {
"tool.name": id,
"session.id": toolCtx.sessionID,
"message.id": toolCtx.messageID,
...(toolCtx.callID ? { "tool.call_id": toolCtx.callID } : {}),
},
}),
),
}
}
const dirs = yield* config.directories()
const matches = dirs.flatMap((dir) =>
Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }),
)
if (matches.length) yield* config.waitForDependencies()
for (const match of matches) {
const namespace = path.basename(match, path.extname(match))
// `match` is an absolute filesystem path from `Glob.scanSync(..., { absolute: true })`.
// Import it as `file://` so Node on Windows accepts the dynamic import.
const mod = yield* Effect.promise(() => import(pathToFileURL(match).href))
for (const [id, def] of Object.entries(mod)) {
if (!isPluginTool(def)) continue
custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))
}
}
const plugins = yield* plugin.list()
for (const p of plugins) {
for (const [id, def] of Object.entries(p.tool ?? {})) {
custom.push(fromPlugin(id, def))
}
}
yield* config.get()
const questionEnabled = ["app", "cli", "desktop"].includes(flags.client) || flags.enableQuestionTool
const tool = yield* Effect.all({
invalid: Tool.init(invalid),
shell: Tool.init(shell),
read: Tool.init(read),
glob: Tool.init(globtool),
grep: Tool.init(greptool),
edit: Tool.init(edit),
write: Tool.init(writetool),
task: Tool.init(task),
fetch: Tool.init(webfetch),
todo: Tool.init(todo),
search: Tool.init(websearch),
skill: Tool.init(skilltool),
patch: Tool.init(patchtool),
question: Tool.init(question),
lsp: Tool.init(lsptool),
plan: Tool.init(plan),
...(codeModeTool ? { execute: Tool.init(codeModeTool) } : {}),
})
return {
custom,
builtin: [
tool.invalid,
...(questionEnabled ? [tool.question] : []),
tool.shell,
tool.read,
tool.glob,
tool.grep,
tool.edit,
tool.write,
tool.task,
tool.fetch,
tool.todo,
tool.search,
tool.skill,
tool.patch,
...(tool.execute ? [tool.execute] : []),
...(flags.experimentalLspTool ? [tool.lsp] : []),
...(flags.experimentalPlanMode && flags.client === "cli" ? [tool.plan] : []),
],
task: tool.task,
read: tool.read,
}
}),
)
const all: Interface["all"] = Effect.fn("ToolRegistry.all")(function* () {
const s = yield* InstanceState.get(state)
return [...s.builtin, ...s.custom] as Tool.Def[]
})
const ids: Interface["ids"] = Effect.fn("ToolRegistry.ids")(function* () {
return (yield* all()).map((tool) => tool.id)
})
const describeTask = Effect.fn("ToolRegistry.describeTask")(function* (agent: Agent.Info) {
const items = (yield* agents.list()).filter((item) => item.mode !== "primary")
const filtered = items.filter(
(item) => Permission.evaluate("task", item.name, agent.permission).action !== "deny",
)
const list = filtered.toSorted((a, b) => a.name.localeCompare(b.name))
const description = list
.map(
(item) =>
`- ${item.name}: ${item.description ?? "This subagent should only be called manually by the user."}`,
)
.join("\n")
return ["Available agent types and the tools they have access to:", description].join("\n")
})
const describeCodeMode = Effect.fn("ToolRegistry.describeCodeMode")(function* (input: {
agent: Agent.Info
permission?: PermissionV1.Ruleset
}) {
if (!codeMode) return
const ruleset = Permission.merge(input.agent.permission, input.permission ?? [])
const tools = Permission.visibleTools(yield* mcp.tools(), ruleset)
if (Object.keys(tools).length === 0) return
return codeMode.describeCatalog(tools, Object.keys(yield* mcp.clients()).map(McpCatalog.sanitize))
})
const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) {
const filtered = (yield* all()).filter((tool) => {
if (tool.id === WebSearchTool.id) {
return webSearchEnabled(input.providerID, { exa: flags.enableExa, parallel: flags.enableParallel })
}
const usePatch =
input.modelID.includes("gpt-") && !input.modelID.includes("oss") && !input.modelID.includes("gpt-4")
if (tool.id === ApplyPatchTool.id) return usePatch
if (tool.id === EditTool.id || tool.id === WriteTool.id) return !usePatch
return true
})
const codeModeDescription = filtered.some((tool) => tool.id === "execute")
? yield* describeCodeMode(input)
: undefined
const visible = filtered.filter((tool) => tool.id !== "execute" || codeModeDescription)
return yield* Effect.forEach(
visible,
Effect.fnUntraced(function* (tool: Tool.Def) {
const output = {
description: tool.description,
parameters: tool.parameters,
jsonSchema: tool.jsonSchema,
}
yield* plugin.trigger("tool.definition", { toolID: tool.id }, output)
const jsonSchema =
output.parameters === tool.parameters || output.jsonSchema !== tool.jsonSchema
? output.jsonSchema
: undefined
return {
id: tool.id,
description: [
output.description,
tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined,
tool.id === "execute" ? codeModeDescription : undefined,
]
.filter(Boolean)
.join("\n"),
parameters: output.parameters,
jsonSchema,
execute: tool.execute,
formatValidationError: tool.formatValidationError,
}
}),
{ concurrency: "unbounded" },
)
})
const named: Interface["named"] = Effect.fn("ToolRegistry.named")(function* () {
const s = yield* InstanceState.get(state)
return { task: s.task, read: s.read }
})
return Service.of({ ids, all, named, tools })
}),
)
function isZodType(value: unknown): value is z.ZodType {
return typeof value === "object" && value !== null && "_zod" in value
}
Native runtime support gate
Accepts provider IDs openai, anthropic or starting opencode, with supported SDK packages and configured API key; OAuth additionally needs the OpenAI fetch override. OpenRouter/Alibaba/DeepSeek/Moonshot do not pass this provider-ID gate.
When: Only when experimentalNativeLlm is enabled and this status gate succeeds.
Source: opencode/src/session/llm/native-runtime.ts lines 48–75 · SHA-256 bdf464f6558b…
}
function statusWithFetch(
input: Pick<StreamInput, "model" | "provider" | "auth">,
fetch: typeof globalThis.fetch | undefined,
): RuntimeStatus {
const providerID = input.model.providerID
if (providerID !== "openai" && providerID !== "anthropic" && !providerID.startsWith("opencode"))
return { type: "unsupported", reason: "provider is not openai, opencode, or anthropic" }
const npm = input.model.api.npm
if (npm !== "@ai-sdk/openai" && npm !== "@ai-sdk/openai-compatible" && npm !== "@ai-sdk/anthropic")
return { type: "unsupported", reason: "provider package is not OpenAI, OpenAI-compatible, or Anthropic" }
if (input.auth?.type === "oauth" && !(input.provider.id === "openai" && fetch)) {
return { type: "unsupported", reason: "OAuth auth requires a provider fetch override" }
}
const apiKey = typeof input.provider.options.apiKey === "string" ? input.provider.options.apiKey : input.provider.key
if (!apiKey) return { type: "unsupported", reason: "API key is not configured" }
return {
type: "supported",
apiKey,
baseURL: typeof input.provider.options.baseURL === "string" ? input.provider.options.baseURL : undefined,
}
}
export function stream(input: StreamInput): StreamResult {
const fetch = providerFetch(input)
Resolved SDK endpoint and fetch layer
Nonempty provider.options.baseURL takes precedence over model.api.url, then configured/environment substitutions apply. Provider credentials and model headers are merged before the timeout-aware fetch wrapper. Actual captured host remains the evidence of client destination.
When: resolveSDK loads the selected provider package.
Source: opencode/src/provider/provider.ts lines 1783–1864 · SHA-256 25f9ae85b705…
const list = Effect.fn("Provider.list")(() => InstanceState.use(state, (s) => s.providers))
async function resolveSDK(model: Model, s: State, envs: Record<string, string | undefined>) {
try {
const provider = s.providers[model.providerID]
const options = { ...provider.options }
if (
model.providerID === "google-vertex" &&
model.api.npm === "@ai-sdk/google-vertex/anthropic" &&
!options.baseURL
) {
const baseURL = googleVertexAnthropicBaseURL(
typeof options.project === "string" ? options.project : undefined,
typeof options.location === "string" ? options.location : undefined,
)
if (baseURL) options.baseURL = baseURL
}
if (model.providerID === "google-vertex" && !model.api.npm.includes("@ai-sdk/openai-compatible")) {
delete options.fetch
}
if (model.api.npm.includes("@ai-sdk/openai-compatible") && options["includeUsage"] !== false) {
options["includeUsage"] = true
}
const baseURL = iife(() => {
let url =
typeof options["baseURL"] === "string" && options["baseURL"] !== "" ? options["baseURL"] : model.api.url
if (!url) return
const loader = s.varsLoaders[model.providerID]
if (loader) {
const vars = loader(options)
for (const [key, value] of Object.entries(vars)) {
const field = "${" + key + "}"
url = url.replaceAll(field, value)
}
}
url = url.replace(/\$\{([^}]+)\}/g, (item, key) => {
const val = envs[String(key)]
return val ?? item
})
return url
})
if (baseURL !== undefined) options["baseURL"] = baseURL
if (options["apiKey"] === undefined && provider.key) options["apiKey"] = provider.key
if (model.headers)
options["headers"] = {
...options["headers"],
...model.headers,
}
const key = Hash.fast(
JSON.stringify({
providerID: model.providerID,
npm: model.api.npm,
options,
}),
)
const existing = s.sdk.get(key)
if (existing) return existing
options["fetch"] = timeoutFetch(options)
delete options["chunkTimeout"]
delete options["headerTimeout"]
const bundledLoader = BUNDLED_PROVIDERS[model.api.npm]
if (bundledLoader) {
const factory = await bundledLoader()
const loaded = factory({
name: model.providerID,
...options,
})
s.sdk.set(key, loaded)
return loaded as SDK
}
const installedPath = await (async () => {
Reasoning, history and export
Visible reasoning persistence
Persists distinct reasoning parts and provider metadata. Orphan reasoning deltas are dropped, so stored exports can differ from raw wire streams.
When: Reasoning events arrive from the selected runtime with a preceding reasoning-start.
Source: opencode/src/session/processor.ts lines 278–315 · SHA-256 a4f082ea43e0…
const handleEvent = Effect.fnUntraced(function* (value: StreamEvent) {
switch (value.type) {
case "reasoning-start":
if (value.id in ctx.reasoningMap) return
ctx.reasoningMap[value.id] = {
id: PartID.ascending(),
messageID: ctx.assistantMessage.id,
sessionID: ctx.assistantMessage.sessionID,
type: "reasoning",
text: "",
time: { start: Date.now() },
metadata: value.providerMetadata,
}
yield* session.updatePart(ctx.reasoningMap[value.id])
return
case "reasoning-delta":
// Match dev: silently drop orphan deltas (no preceding reasoning-start).
if (!(value.id in ctx.reasoningMap)) return
ctx.reasoningMap[value.id].text += value.text
if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata
yield* session.updatePartDelta({
sessionID: ctx.reasoningMap[value.id].sessionID,
messageID: ctx.reasoningMap[value.id].messageID,
partID: ctx.reasoningMap[value.id].id,
field: "text",
delta: value.text,
})
return
case "reasoning-end":
if (value.providerMetadata && value.id in ctx.reasoningMap) {
ctx.reasoningMap[value.id].metadata = value.providerMetadata
}
yield* finishReasoning(value.id)
return
case "tool-input-start":
Stored assistant history replay and model changes
For the same model, reasoning parts preserve provider metadata. When model changes, nonempty reasoning becomes text and metadata is omitted. Completed, failed and interrupted tools have separate replay paths.
When: MessageV2 converts stored session parts for a new selected provider/model.
Source: opencode/src/session/message-v2.ts lines 249–386 · SHA-256 e01ecbdc054a…
const differentModel = `${model.providerID}/${model.id}` !== `${msg.info.providerID}/${msg.info.modelID}`
const media: Array<{ mime: string; url: string; filename?: string }> = []
if (
msg.info.error &&
!(
AbortedError.isInstance(msg.info.error) &&
msg.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning")
)
) {
continue
}
const assistantMessage: UIMessage = {
id: msg.info.id,
role: "assistant",
parts: [],
}
// Anthropic adaptive thinking can persist assistant turns like:
// step-start, reasoning(signature), text(""), step-start,
// reasoning(signature). The empty text part is a structural separator,
// but it does not carry the signature metadata itself. Dropping it shifts
// signed thinking positions after step-start splitting/provider regrouping;
// keeping it as "" is filtered by the AI SDK and rejected by Anthropic.
// It is unclear whether this shape originates in our stream processing,
// a proxy, or a lower-level library, but preserving a non-empty separator
// here is the only safe replay point we have.
// Use a single space so the separator survives replay without changing
// the neighboring signed reasoning blocks.
const hasSignedReasoning = msg.parts.some((part) => {
if (part.type !== "reasoning") return false
return part.metadata?.anthropic?.signature != null
})
for (const part of msg.parts) {
if (part.type === "text") {
const text = part.text === "" && hasSignedReasoning ? " " : part.text
assistantMessage.parts.push({
type: "text",
text,
...(differentModel ? {} : { providerMetadata: part.metadata }),
})
}
if (part.type === "step-start")
assistantMessage.parts.push({
type: "step-start",
})
if (part.type === "tool") {
toolNames.add(part.tool)
if (part.state.status === "completed") {
const outputText = part.state.time.compacted
? "[Old tool result content cleared]"
: truncateToolOutput(part.state.output, options?.toolOutputMaxChars)
const attachments = part.state.time.compacted || options?.stripMedia ? [] : (part.state.attachments ?? [])
// For providers that don't support media in tool results, extract media files
// (images, PDFs) to be sent as a separate user message
const mediaAttachments = attachments.filter((a) => isMedia(a.mime))
const extractedMedia = mediaAttachments.filter((a) => !supportsMediaInToolResult(a))
if (extractedMedia.length > 0) {
media.push(...extractedMedia)
}
const finalAttachments = attachments.filter((a) => !isMedia(a.mime) || supportsMediaInToolResult(a))
const output =
finalAttachments.length > 0
? {
text: outputText,
attachments: finalAttachments,
}
: outputText
assistantMessage.parts.push({
type: ("tool-" + part.tool) as `tool-${string}`,
state: "output-available",
toolCallId: part.callID,
input: part.state.input,
output,
...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}),
...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
})
}
if (part.state.status === "error") {
const output = part.state.metadata?.interrupted === true ? part.state.metadata.output : undefined
if (typeof output === "string") {
assistantMessage.parts.push({
type: ("tool-" + part.tool) as `tool-${string}`,
state: "output-available",
toolCallId: part.callID,
input: part.state.input,
output,
...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}),
...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
})
} else {
assistantMessage.parts.push({
type: ("tool-" + part.tool) as `tool-${string}`,
state: "output-error",
toolCallId: part.callID,
input: part.state.input,
errorText: part.state.error,
...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}),
...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
})
}
}
// Handle pending/running tool calls to prevent dangling tool_use blocks
// Anthropic/Claude APIs require every tool_use to have a corresponding tool_result
if (part.state.status === "pending" || part.state.status === "running")
assistantMessage.parts.push({
type: ("tool-" + part.tool) as `tool-${string}`,
state: "output-error",
toolCallId: part.callID,
input: part.state.input,
errorText: "[Tool execution was interrupted]",
...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}),
...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
})
}
if (part.type === "reasoning") {
if (differentModel) {
if (part.text.trim().length > 0)
assistantMessage.parts.push({
type: "text",
text: part.text,
})
continue
}
assistantMessage.parts.push({
type: "reasoning",
text: part.text,
providerMetadata: part.metadata,
})
}
}
if (assistantMessage.parts.length > 0) {
result.push(assistantMessage)
// Inject pending media as a user message for providers that don't support
// media (images, PDFs) in tool results
if (media.length > 0) {
Native JSON export boundary
Writes {info,messages}, where each message carries info and parts from session storage. This is stored session evidence, not the fully assembled request or exact system prompt.
When: User explicitly runs export for a real session; optional sanitize mode redacts transcript/file data.
Source: opencode/src/cli/cmd/export.ts lines 222–292 · SHA-256 79e3bd1625f3…
export const ExportCommand = effectCmd({
command: "export [sessionID]",
describe: "export session data as JSON",
builder: (yargs) =>
yargs
.positional("sessionID", {
describe: "session id to export",
type: "string",
})
.option("sanitize", {
describe: "redact sensitive transcript and file data",
type: "boolean",
}),
handler: Effect.fn("Cli.export")(function* (args) {
return yield* run(args)
}),
})
const run = Effect.fn("Cli.export.body")(function* (args: { sessionID?: string; sanitize?: boolean }) {
const svc = yield* Session.Service
let sessionID = args.sessionID ? SessionID.make(args.sessionID) : undefined
process.stderr.write(`Exporting session: ${sessionID ?? "latest"}\n`)
if (!sessionID) {
UI.empty()
prompts.intro("Export session", { output: process.stderr })
const sessions = yield* svc.list()
if (sessions.length === 0) {
prompts.log.error("No sessions found", { output: process.stderr })
prompts.outro("Done", { output: process.stderr })
return
}
sessions.sort((a, b) => b.time.updated - a.time.updated)
const selectedSession = yield* Effect.promise(() =>
prompts.autocomplete({
message: "Select session to export",
maxItems: 10,
options: sessions.map((session) => ({
label: session.title,
value: session.id,
hint: `${new Date(session.time.updated).toLocaleString()} • ${session.id.slice(-8)}`,
})),
output: process.stderr,
}),
)
if (prompts.isCancel(selectedSession)) {
return yield* Effect.die(new UI.CancelledError())
}
sessionID = selectedSession
prompts.outro("Exporting session...", { output: process.stderr })
}
// Match legacy try/catch — catches both typed failures and defects
// (Session.Service.get throws NotFoundError as a defect, not a typed E).
return yield* Effect.gen(function* () {
const sessionInfo = yield* svc.get(sessionID!)
const messages = yield* svc.messages({ sessionID: sessionInfo.id })
const exportData = { info: sessionInfo, messages }
process.stdout.write(JSON.stringify(args.sanitize ? sanitize(exportData) : exportData, null, 2))
process.stdout.write(EOL)
}).pipe(Effect.catchCause(() => fail(`Session not found: ${sessionID!}`)))
})
Evidence limits
- Installed CLI version, build-injected catalog and runtime identity require separate verification.
- No private instructions, environment values, skill bodies, MCP server content or plugin output is expanded.
- AI SDK provider dependency implementations are outside this source checkout; native protocol records are labeled separately from default SDK execution.
- Dynamic catalog endpoints/capabilities can change after the release; configured baseURL can route through a gateway.
- Core runner and standalone native adapters are mapped as separate source paths, without claiming that a captured CLI run selected them.
- Source records do not prove exact request bodies, received visible reasoning, serving-provider identity, geography, retention or downstream hops.