Compare commits

..
Author SHA1 Message Date
Claude 49fa822aa6 docs(opencode): seed CONTEXT.md with location vocabulary
Resolves the Project / Worktree / Workspace / Instance / Directory tangle
via the grill-with-docs skill. Workspace (control-plane connection target,
wrk_ ID) is a peer concept, not a synonym for Worktree (git worktree on
disk). Fixes the conflated comment on ProjectCommands.start.
2026-05-11 12:38:42 +00:00
Brendan Allan c933504d9c fix(ui): better handle patch file support when rendering patch/edit tools (#26828) 2026-05-11 17:21:59 +08:00
Shoubhit Dash 2d0d3d596e feat(compaction): serialize compaction tail (#26830) 2026-05-11 13:40:36 +05:30
opencode-agent[bot] 3bd98ea055 chore: generate 2026-05-11 07:28:32 +00:00
Shoubhit Dash 7e997cfba4 refactor(scout): resolve configured reference mentions (#26701) 2026-05-11 12:57:26 +05:30
Brendan Allan 5d6f2a1524 fix(ui): use part_text_accum_delta to prevent markdown cutoff during streaming (#26822) 2026-05-11 15:15:03 +08:00
opencode-agent[bot] b1cb71856e chore: generate 2026-05-11 05:27:40 +00:00
Aiden Cline 518264fcd9 fix(opencode): fix full session fork (#26811) 2026-05-11 00:26:36 -05:00
Dax 7235c9c9b8 Trace data migrations (#26809) 2026-05-11 03:23:01 +00:00
Kit Langton 274033cd52 Validate prompt messages with Effect Schema (#26796) 2026-05-11 02:59:20 +00:00
Kit Langton 9b369ee815 chore(llm): make cache: 'auto' the default (#26798) 2026-05-10 22:46:01 -04:00
Sebastian 721ff5121e fix prompt history behaviour and session line up/down commands (#26797) 2026-05-11 02:27:46 +00:00
opencode-agent[bot] cfbf5d1c6f chore: update nix node_modules hashes 2026-05-11 02:20:35 +00:00
opencode-agent[bot] 02cb7e7b71 chore: generate 2026-05-11 02:11:07 +00:00
Kit Langton 942630eb4a feat(llm): cache-policy auto-placement (#26786) 2026-05-10 22:09:55 -04:00
opencode ce66b191d1 sync release versions for v1.14.48 2026-05-11 02:07:48 +00:00
Kit Langton 6f1f5944ce Delete unused opencode Zod helpers (#26793) 2026-05-10 21:57:18 -04:00
opencode-agent[bot] 1c49b2ed67 chore: generate 2026-05-11 01:55:22 +00:00
opencode-agent[bot] 3c6be604ec chore: update nix node_modules hashes 2026-05-11 01:54:13 +00:00
Kit Langton ddc02c2893 Drop synchronous SyncEvent facades (#26789) 2026-05-11 01:53:50 +00:00
Kit Langton 2703eff2e2 refactor(llm): normalize Usage as inclusive total + non-overlapping breakdown (#26735) 2026-05-10 21:52:50 -04:00
66 changed files with 1651 additions and 435 deletions
@@ -231,6 +231,7 @@ export function createChildStoreManager(input: {
limit: 5,
message: {},
part: {},
part_text_accum_delta: {},
})
children[key] = child
disposers.set(key, dispose)
@@ -81,6 +81,7 @@ const baseState = (input: Partial<State> = {}) =>
limit: 10,
message: {},
part: {},
part_text_accum_delta: {},
...input,
}) as State
@@ -211,6 +211,12 @@ export function applyDirectoryEvent(input: {
const result = Binary.search(messages, props.messageID, (m) => m.id)
if (result.found) messages.splice(result.index, 1)
}
const parts = draft.part[props.messageID]
if (parts) {
for (const part of parts) {
delete draft.part_text_accum_delta[part.id]
}
}
delete draft.part[props.messageID]
}),
)
@@ -219,6 +225,11 @@ export function applyDirectoryEvent(input: {
case "message.part.updated": {
const part = (event.properties as { part: Part }).part
if (SKIP_PARTS.has(part.type)) break
input.setStore(
produce((draft) => {
delete draft.part_text_accum_delta[part.id]
}),
)
const parts = input.store.part[part.messageID]
if (!parts) {
input.setStore("part", part.messageID, [part])
@@ -240,6 +251,11 @@ export function applyDirectoryEvent(input: {
}
case "message.part.removed": {
const props = event.properties as { messageID: string; partID: string }
input.setStore(
produce((draft) => {
delete draft.part_text_accum_delta[props.partID]
}),
)
const parts = input.store.part[props.messageID]
if (!parts) break
const result = Binary.search(parts, props.partID, (p) => p.id)
@@ -263,6 +279,7 @@ export function applyDirectoryEvent(input: {
if (!parts) break
const result = Binary.search(parts, props.partID, (p) => p.id)
if (!result.found) break
input.setStore("part_text_accum_delta", props.partID, (existing) => (existing ?? "") + props.delta)
input.setStore(
"part",
props.messageID,
@@ -39,6 +39,7 @@ describe("app session cache", () => {
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
question: Record<string, QuestionRequest[] | undefined>
part_text_accum_delta: Record<string, string | undefined>
} = {
session_status: { ses_1: { type: "busy" } as SessionStatus },
session_diff: { ses_1: [] },
@@ -47,12 +48,14 @@ describe("app session cache", () => {
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
permission: { ses_1: [] as PermissionRequest[] },
question: { ses_1: [] as QuestionRequest[] },
part_text_accum_delta: { prt_1: "streamed text" },
}
dropSessionCaches(store, ["ses_1"])
expect(store.message.ses_1).toBeUndefined()
expect(store.part.msg_1).toBeUndefined()
expect(store.part_text_accum_delta.prt_1).toBeUndefined()
expect(store.todo.ses_1).toBeUndefined()
expect(store.session_diff.ses_1).toBeUndefined()
expect(store.session_status.ses_1).toBeUndefined()
@@ -70,6 +73,7 @@ describe("app session cache", () => {
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
question: Record<string, QuestionRequest[] | undefined>
part_text_accum_delta: Record<string, string | undefined>
} = {
session_status: {},
session_diff: {},
@@ -78,6 +82,7 @@ describe("app session cache", () => {
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
permission: {},
question: {},
part_text_accum_delta: {},
}
dropSessionCaches(store, ["ses_1"])
@@ -18,6 +18,7 @@ type SessionCache = {
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
question: Record<string, QuestionRequest[] | undefined>
part_text_accum_delta: Record<string, string | undefined>
}
export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<string>) {
@@ -27,6 +28,9 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<stri
for (const key of Object.keys(store.part)) {
const parts = store.part[key]
if (!parts?.some((part) => stale.has(part?.sessionID ?? ""))) continue
for (const part of parts) {
delete store.part_text_accum_delta[part.id]
}
delete store.part[key]
}
@@ -72,6 +72,9 @@ export type State = {
part: {
[messageID: string]: Part[]
}
part_text_accum_delta: {
[partID: string]: string
}
}
export type VcsCache = {
+1 -1
View File
@@ -36,7 +36,7 @@ export function zod<S extends Schema.Top>(schema: S): z.ZodType<Schema.Schema.Ty
* mapped `.omit()` / `.extend()` surface triggers brand-intersection
* explosions for branded primitives (`string & Brand<"SessionID">` extends
* `object` via the brand and gets walked into the prototype by `DeepPartial`,
* `updateSchema`, etc.), and zod's inference through `z.ZodType<T | undefined>`
* mapped-schema helpers, and zod's inference through `z.ZodType<T | undefined>`
* wrappers also can't reconstruct `T` cleanly. Consumers that care about the
* post-`.omit()` shape should cast `c.req.valid(...)` to the expected type.
*/
+129
View File
@@ -0,0 +1,129 @@
# @opencode-ai/llm
Schema-first LLM core for opencode. One typed request, response, event, and tool language; provider quirks live in adapters, not in calling code.
```ts
import { Effect } from "effect"
import { LLM, LLMClient } from "@opencode-ai/llm"
import { OpenAI } from "@opencode-ai/llm/providers"
const model = OpenAI.model("gpt-4o-mini", { apiKey: process.env.OPENAI_API_KEY })
const request = LLM.request({
model,
system: "You are concise.",
prompt: "Say hello in one short sentence.",
generation: { maxTokens: 40 },
})
const program = Effect.gen(function* () {
const response = yield* LLMClient.generate(request)
console.log(response.text)
})
```
Run `LLMClient.stream(request)` instead of `generate` when you want incremental `LLMEvent`s. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses, Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployment.
## Public API
- **`LLM.request({...})`** — build a provider-neutral `LLMRequest`. Accepts ergonomic inputs (`system: string`, `prompt: string`) that normalize into the canonical Schema classes.
- **`LLM.generate` / `LLM.stream`** — re-exported from `LLMClient` for one-import use.
- **`LLM.user(...)` / `LLM.assistant(...)` / `LLM.toolMessage(...)`** — message constructors.
- **`LLM.toolCall(...)` / `LLM.toolResult(...)` / `LLM.toolDefinition(...)`** — tool-related parts.
- **`LLMClient.prepare(request)`** — compile a request through protocol body construction, validation, and HTTP preparation without sending. Useful for inspection and testing.
- **`LLMEvent.is.*`** — typed guards (`is.text`, `is.toolCall`, `is.requestFinish`, …) for filtering streams.
## Caching
Prompt caching is **on by default**. Every `LLMRequest` resolves to `cache: "auto"` unless the caller opts out with `cache: "none"`. Each protocol translates `CacheHint`s to its wire format (`cache_control` on Anthropic, `cachePoint` on Bedrock; OpenAI and Gemini do implicit caching server-side and don't need inline markers — auto is a no-op there).
### Auto placement
`"auto"` places three breakpoints — last tool definition, last system part, latest user message. The last-user-message boundary is the load-bearing detail: in a tool-use loop, a single user turn expands into many assistant/tool round-trips, all sharing that prefix. Caching at that boundary lets every intra-turn API call hit.
The math justifies the default: Anthropic's 5-minute cache write is 1.25× base, read is 0.1×, so a single reuse within 5 minutes already wins. One-shot completions below the per-model minimum-cacheable-token threshold silently no-op on the wire, so the worst case is harmless.
### Opting out
```ts
LLM.request({
model,
system,
prompt: "one-off question",
cache: "none",
})
```
### Granular policy
```ts
cache: {
tools?: boolean,
system?: boolean,
messages?: "latest-user-message" | "latest-assistant" | { tail: number },
ttlSeconds?: number, // ≥ 3600 → 1h on Anthropic/Bedrock; else 5m
}
```
### Manual hints
Inline `CacheHint` on any text / system / tool / tool-result part overrides automatic placement. The auto policy preserves manual hints; it only fills gaps.
```ts
LLM.request({
model,
system: [
{ type: "text", text: "stable system prompt", cache: { type: "ephemeral" } },
],
...
})
```
### Provider behavior table
| Protocol | `cache: "auto"` |
| ----------------------- | ------------------------------------------------------------------------- |
| Anthropic Messages | emits up to 3 `cache_control` markers (4-breakpoint cap enforced) |
| Bedrock Converse | emits up to 3 `cachePoint` blocks (4-breakpoint cap enforced) |
| OpenAI Chat / Responses | no-op (implicit caching above 1024 tokens) |
| Gemini | no-op (implicit caching on 2.5+; explicit `CachedContent` is out-of-band) |
Normalized cache usage is read back into `response.usage.cacheReadInputTokens` and `cacheWriteInputTokens` across every provider.
## Providers
Each provider exports a `model(...)` helper that records identity, protocol, capabilities, auth, and defaults.
```ts
import { Anthropic } from "@opencode-ai/llm/providers"
const model = Anthropic.model("claude-sonnet-4-6", {
apiKey: process.env.ANTHROPIC_API_KEY,
})
```
Included providers: OpenAI, Anthropic, Google (Gemini), Amazon Bedrock, Azure OpenAI, Cloudflare, GitHub Copilot, OpenRouter, xAI, plus generic OpenAI-compatible helpers for DeepSeek, Cerebras, Groq, Fireworks, Together, etc.
## Provider options & HTTP overlays
Three escape hatches in order of stability:
1. **`generation`** — portable knobs (`maxTokens`, `temperature`, `topP`, `topK`, penalties, seed, stop).
2. **`providerOptions: { <provider>: {...} }`** — typed-at-the-facade provider-specific knobs (OpenAI `promptCacheKey`, Anthropic `thinking`, Gemini `thinkingConfig`, OpenRouter routing).
3. **`http: { body, headers, query }`** — last-resort serializable overlays merged into the final HTTP request. Reach for this only when a stable typed path doesn't yet exist.
Model-level defaults are overridden by request-level values for each axis.
## Routes
Adding a new model or deployment is usually 515 lines using `Route.make({ protocol, transport, ... })`. The four orthogonal pieces are protocol (body construction + stream parsing), transport (endpoint + auth + framing + encoding), defaults, and capabilities. See `AGENTS.md` for the architectural detail.
## Effect
This package is built on Effect. Public methods return `Effect` or `Stream`; provide `LLMClient.layer` (the default registers every shipped route) for runtime dispatch. The example at `example/tutorial.ts` is a runnable walkthrough.
## See also
- `AGENTS.md` — architecture, route construction, contributor guide
- `example/tutorial.ts` — runnable end-to-end walkthrough
- `test/provider/*.test.ts` — fixture-first protocol tests; `*.recorded.test.ts` files cover live cassettes
+111
View File
@@ -0,0 +1,111 @@
// Apply an `LLMRequest.cache` policy by injecting `CacheHint`s onto the parts
// the policy designates. Runs once at compile time, before the per-protocol
// body builder, so the existing inline-hint lowering path handles the rest.
//
// The default `"auto"` shape places one breakpoint at the last tool definition,
// one at the last system part, and one at the latest user message. This
// matches what production agent harnesses (LangChain's caching middleware,
// kern-ai's 10x cost-reduction playbook) converge on for tool-use loops: the
// latest user message stays put while a single turn explodes into many
// assistant/tool round-trips, so caching at that boundary lets every
// intra-turn API call hit the prefix.
//
// Manual `cache: CacheHint` placements on individual parts are preserved —
// this function only fills gaps the caller left empty.
import { CacheHint, type CachePolicy, type CachePolicyObject } from "./schema/options"
import { LLMRequest, Message, ToolDefinition, type ContentPart } from "./schema/messages"
const AUTO: CachePolicyObject = {
tools: true,
system: true,
messages: "latest-user-message",
}
const NONE: CachePolicyObject = {}
// Resolution rules:
// - undefined → "auto" — caching is on by default. The math favors it:
// Anthropic 5m-cache write is 1.25x base, read is 0.1x,
// so a single reuse within 5 minutes already wins.
// - "auto" → tools + system + latest user msg.
// - "none" → no auto placement; manual `CacheHint`s still flow.
// - object form → exactly what the caller asked for.
const resolve = (policy: CachePolicy | undefined): CachePolicyObject => {
if (policy === undefined || policy === "auto") return AUTO
if (policy === "none") return NONE
return policy
}
// Protocols whose wire format ignores inline cache markers (OpenAI's implicit
// 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(["anthropic-messages", "bedrock-converse"])
const makeHint = (ttlSeconds: number | undefined): CacheHint =>
ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" })
const markLastTool = (tools: ReadonlyArray<ToolDefinition>, hint: CacheHint): ReadonlyArray<ToolDefinition> => {
if (tools.length === 0) return tools
const last = tools.length - 1
if (tools[last]!.cache) return tools
return tools.map((tool, i) => (i === last ? new ToolDefinition({ ...tool, cache: hint }) : tool))
}
const markLastSystem = (system: LLMRequest["system"], hint: CacheHint): LLMRequest["system"] => {
if (system.length === 0) return system
const last = system.length - 1
if (system[last]!.cache) return system
return system.map((part, i) => (i === last ? { ...part, cache: hint } : part))
}
const lastIndexOfRole = (messages: ReadonlyArray<Message>, role: Message["role"]): number =>
messages.findLastIndex((m) => m.role === role)
// Mark the last text part of `messages[index]`. If no text part exists, mark
// the last content part regardless of type — that's the breakpoint position
// in tool-result-only messages too.
const markMessageAt = (messages: ReadonlyArray<Message>, index: number, hint: CacheHint): ReadonlyArray<Message> => {
if (index < 0 || index >= messages.length) return messages
const target = messages[index]!
if (target.content.length === 0) return messages
const lastTextIndex = target.content.findLastIndex((part) => part.type === "text")
const markAt = lastTextIndex >= 0 ? lastTextIndex : target.content.length - 1
const existing = target.content[markAt]!
if ("cache" in existing && existing.cache) return messages
const nextContent = target.content.map((part, i) => (i === markAt ? ({ ...part, cache: hint } as ContentPart) : part))
const next = new Message({ ...target, content: nextContent })
// Single pass over `messages`, substituting the one updated entry. Long
// conversations call this on every request, so avoid `.map()` here — its
// closure dispatch and identity copies show up in profiling.
const result = messages.slice()
result[index] = next
return result
}
const markMessages = (
messages: ReadonlyArray<Message>,
strategy: NonNullable<CachePolicyObject["messages"]>,
hint: CacheHint,
): ReadonlyArray<Message> => {
if (messages.length === 0) return messages
if (strategy === "latest-user-message") return markMessageAt(messages, lastIndexOfRole(messages, "user"), hint)
if (strategy === "latest-assistant") return markMessageAt(messages, lastIndexOfRole(messages, "assistant"), hint)
const start = Math.max(0, messages.length - strategy.tail)
let next = messages
for (let i = start; i < messages.length; i++) next = markMessageAt(next, i, hint)
return next
}
export const applyCachePolicy = (request: LLMRequest): LLMRequest => {
if (!RESPECTS_INLINE_HINTS.has(request.model.route)) return request
const policy = resolve(request.cache)
if (!policy.tools && !policy.system && !policy.messages) return request
const hint = makeHint(policy.ttlSeconds)
const tools = policy.tools ? markLastTool(request.tools, hint) : request.tools
const system = policy.system ? markLastSystem(request.system, hint) : request.system
const messages = policy.messages ? markMessages(request.messages, policy.messages, hint) : request.messages
if (tools === request.tools && system === request.system && messages === request.messages) return request
return LLMRequest.update(request, { tools, system, messages })
}
@@ -404,34 +404,56 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
return "unknown"
}
// Anthropic reports the non-overlapping breakdown natively — its
// `input_tokens` is the *non-cached* count per the Messages API docs, with
// cache reads and writes as separate fields. We sum them to derive the
// inclusive `inputTokens` the rest of the contract expects. Extended
// thinking tokens are *not* broken out by Anthropic — they're billed as
// part of `output_tokens`, so `reasoningTokens` stays `undefined` and
// `outputTokens` carries the combined total.
const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => {
if (!usage) return undefined
const nonCached = usage.input_tokens
const cacheRead = usage.cache_read_input_tokens ?? undefined
const cacheWrite = usage.cache_creation_input_tokens ?? undefined
const inputTokens = ProviderShared.sumTokens(nonCached, cacheRead, cacheWrite)
return new Usage({
inputTokens: usage.input_tokens,
inputTokens,
outputTokens: usage.output_tokens,
cacheReadInputTokens: usage.cache_read_input_tokens ?? undefined,
cacheWriteInputTokens: usage.cache_creation_input_tokens ?? undefined,
totalTokens: ProviderShared.totalTokens(usage.input_tokens, usage.output_tokens, undefined),
native: usage,
nonCachedInputTokens: nonCached,
cacheReadInputTokens: cacheRead,
cacheWriteInputTokens: cacheWrite,
totalTokens: ProviderShared.totalTokens(inputTokens, usage.output_tokens, undefined),
providerMetadata: { anthropic: usage },
})
}
// Anthropic emits usage on `message_start` and again on `message_delta` — the
// final delta carries the authoritative totals. Right-biased merge: each
// field prefers `right` when defined, falls back to `left`. `totalTokens` is
// recomputed from the merged input/output to stay consistent.
// field prefers `right` when defined, falls back to `left`. `inputTokens` is
// recomputed from the merged breakdown so the inclusive total stays
// consistent with `nonCached + cacheRead + cacheWrite`.
const mergeUsage = (left: Usage | undefined, right: Usage | undefined) => {
if (!left) return right
if (!right) return left
const inputTokens = right.inputTokens ?? left.inputTokens
const nonCachedInputTokens = right.nonCachedInputTokens ?? left.nonCachedInputTokens
const cacheReadInputTokens = right.cacheReadInputTokens ?? left.cacheReadInputTokens
const cacheWriteInputTokens = right.cacheWriteInputTokens ?? left.cacheWriteInputTokens
const inputTokens = ProviderShared.sumTokens(nonCachedInputTokens, cacheReadInputTokens, cacheWriteInputTokens)
const outputTokens = right.outputTokens ?? left.outputTokens
return new Usage({
inputTokens,
outputTokens,
cacheReadInputTokens: right.cacheReadInputTokens ?? left.cacheReadInputTokens,
cacheWriteInputTokens: right.cacheWriteInputTokens ?? left.cacheWriteInputTokens,
nonCachedInputTokens,
cacheReadInputTokens,
cacheWriteInputTokens,
totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined),
native: { ...left.native, ...right.native },
providerMetadata: {
anthropic: {
...(left.providerMetadata?.["anthropic"] ?? {}),
...(right.providerMetadata?.["anthropic"] ?? {}),
},
},
})
}
@@ -395,15 +395,22 @@ const mapFinishReason = (reason: string): FinishReason => {
return "unknown"
}
// AWS Bedrock Converse reports `inputTokens` (inclusive total) with
// `cacheReadInputTokens` and `cacheWriteInputTokens` as subsets. Pass
// the total through and derive the non-cached breakdown. Bedrock does
// not break reasoning out of `outputTokens` for any current model.
const mapUsage = (usage: BedrockUsageSchema | undefined): Usage | undefined => {
if (!usage) return undefined
const cacheTotal = (usage.cacheReadInputTokens ?? 0) + (usage.cacheWriteInputTokens ?? 0)
const nonCached = ProviderShared.subtractTokens(usage.inputTokens, cacheTotal)
return new Usage({
inputTokens: usage.inputTokens,
outputTokens: usage.outputTokens,
totalTokens: ProviderShared.totalTokens(usage.inputTokens, usage.outputTokens, usage.totalTokens),
nonCachedInputTokens: nonCached,
cacheReadInputTokens: usage.cacheReadInputTokens,
cacheWriteInputTokens: usage.cacheWriteInputTokens,
native: usage,
totalTokens: ProviderShared.totalTokens(usage.inputTokens, usage.outputTokens, usage.totalTokens),
providerMetadata: { bedrock: usage },
})
}
+17 -4
View File
@@ -281,15 +281,28 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
// =============================================================================
// Stream Parsing
// =============================================================================
// Gemini reports `promptTokenCount` (inclusive total) with a
// `cachedContentTokenCount` subset. `candidatesTokenCount` is *exclusive*
// of `thoughtsTokenCount` — visible-only, not a total — so we sum the two
// to produce the inclusive `outputTokens` the rest of the contract expects.
const mapUsage = (usage: GeminiUsage | undefined) => {
if (!usage) return undefined
const cached = usage.cachedContentTokenCount
const nonCached = ProviderShared.subtractTokens(usage.promptTokenCount, cached)
// `candidatesTokenCount` is visible-only; sum with thoughts to produce the
// inclusive `outputTokens` the contract expects. Only compute the total
// when the visible component is reported — otherwise we'd fabricate an
// inclusive number from a partial breakdown.
const outputTokens =
usage.candidatesTokenCount !== undefined ? usage.candidatesTokenCount + (usage.thoughtsTokenCount ?? 0) : undefined
return new Usage({
inputTokens: usage.promptTokenCount,
outputTokens: usage.candidatesTokenCount,
outputTokens,
nonCachedInputTokens: nonCached,
cacheReadInputTokens: cached,
reasoningTokens: usage.thoughtsTokenCount,
cacheReadInputTokens: usage.cachedContentTokenCount,
totalTokens: ProviderShared.totalTokens(usage.promptTokenCount, usage.candidatesTokenCount, usage.totalTokenCount),
native: usage,
totalTokens: ProviderShared.totalTokens(usage.promptTokenCount, outputTokens, usage.totalTokenCount),
providerMetadata: { google: usage },
})
}
+12 -3
View File
@@ -290,15 +290,24 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
return "unknown"
}
// OpenAI Chat reports `prompt_tokens` (inclusive total) with a
// `cached_tokens` subset, and `completion_tokens` (inclusive total) with
// a `reasoning_tokens` subset. We pass the inclusive totals through and
// derive the non-cached breakdown so the `LLM.Usage` contract is
// satisfied on both sides.
const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
if (!usage) return undefined
const cached = usage.prompt_tokens_details?.cached_tokens
const reasoning = usage.completion_tokens_details?.reasoning_tokens
const nonCached = ProviderShared.subtractTokens(usage.prompt_tokens, cached)
return new Usage({
inputTokens: usage.prompt_tokens,
outputTokens: usage.completion_tokens,
reasoningTokens: usage.completion_tokens_details?.reasoning_tokens,
cacheReadInputTokens: usage.prompt_tokens_details?.cached_tokens,
nonCachedInputTokens: nonCached,
cacheReadInputTokens: cached,
reasoningTokens: reasoning,
totalTokens: ProviderShared.totalTokens(usage.prompt_tokens, usage.completion_tokens, usage.total_tokens),
native: usage,
providerMetadata: { openai: usage },
})
}
+11 -3
View File
@@ -276,15 +276,23 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
// =============================================================================
// Stream Parsing
// =============================================================================
// OpenAI Responses reports `input_tokens` (inclusive total) with a
// `cached_tokens` subset, and `output_tokens` (inclusive total) with a
// `reasoning_tokens` subset. Pass the totals through and derive the
// non-cached breakdown.
const mapUsage = (usage: OpenAIResponsesUsage | null | undefined) => {
if (!usage) return undefined
const cached = usage.input_tokens_details?.cached_tokens
const reasoning = usage.output_tokens_details?.reasoning_tokens
const nonCached = ProviderShared.subtractTokens(usage.input_tokens, cached)
return new Usage({
inputTokens: usage.input_tokens,
outputTokens: usage.output_tokens,
reasoningTokens: usage.output_tokens_details?.reasoning_tokens,
cacheReadInputTokens: usage.input_tokens_details?.cached_tokens,
nonCachedInputTokens: nonCached,
cacheReadInputTokens: cached,
reasoningTokens: reasoning,
totalTokens: ProviderShared.totalTokens(usage.input_tokens, usage.output_tokens, usage.total_tokens),
native: usage,
providerMetadata: { openai: usage },
})
}
+36
View File
@@ -42,6 +42,13 @@ export interface ToolAccumulator {
* supplied total; otherwise falls back to `inputTokens + outputTokens` only
* when at least one is defined. Returns `undefined` when neither input nor
* output is known so routes don't publish a misleading `0`.
*
* Under the additive `LLM.Usage` contract, `inputTokens` and `outputTokens`
* are the non-cached input and visible output only. The provider-supplied
* `total` is the source of truth when present; the computed fallback
* under-counts cache and reasoning by design and exists mainly so
* Anthropic-style providers (which don't surface a total) still get a
* sensible aggregate on the input + output axes.
*/
export const totalTokens = (
inputTokens: number | undefined,
@@ -53,6 +60,35 @@ export const totalTokens = (
return (inputTokens ?? 0) + (outputTokens ?? 0)
}
/**
* Subtract `subtrahend` from `total`, clamping to zero if the provider
* reports a non-sensical breakdown (e.g. `cached_tokens > prompt_tokens`).
* Used by protocol mappers when deriving a non-overlapping breakdown field
* from a provider's inclusive total — `nonCachedInputTokens` from
* `inputTokens - cacheReadInputTokens - cacheWriteInputTokens`.
*
* If `total` is `undefined`, returns `undefined` (we don't fabricate
* counts). If `subtrahend` is `undefined`, returns `total` unchanged. The
* provider-native breakdown stays available on `Usage.native` for debugging.
*/
export const subtractTokens = (total: number | undefined, subtrahend: number | undefined): number | undefined => {
if (total === undefined) return undefined
if (subtrahend === undefined) return total
return Math.max(0, total - subtrahend)
}
/**
* Sum a list of optional token counts, returning `undefined` only when
* every value is `undefined` (so we don't fabricate a `0`). Used by
* protocol mappers to derive the inclusive `inputTokens` total from a
* provider that natively reports a non-overlapping breakdown
* (e.g. Anthropic, whose `input_tokens` is already non-cached only).
*/
export const sumTokens = (...values: ReadonlyArray<number | undefined>): number | undefined => {
if (values.every((value) => value === undefined)) return undefined
return values.reduce<number>((acc, value) => acc + (value ?? 0), 0)
}
export const eventError = (route: string, message: string, raw?: string) =>
new LLMError({
module: "ProviderShared",
+2 -1
View File
@@ -8,6 +8,7 @@ import type { Transport, TransportRuntime } from "./transport"
import { WebSocketExecutor } from "./transport"
import type { Service as WebSocketExecutorService } from "./transport/websocket"
import type { Protocol } from "./protocol"
import { applyCachePolicy } from "../cache-policy"
import * as ProviderShared from "../protocols/shared"
import * as ToolRuntime from "../tool-runtime"
import type { Tools } from "../tool"
@@ -400,7 +401,7 @@ export function make<Body, Prepared, Frame, Event, State>(
// validated provider body plus transport-private prepared data, but does not
// execute transport.
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) {
const resolved = resolveRequestOptions(request)
const resolved = applyCachePolicy(resolveRequestOptions(request))
const route = registeredRoute(resolved.model.route)
if (!route) return yield* noRoute(resolved.model)
+58 -3
View File
@@ -3,15 +3,70 @@ import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, ResponseID,
import { ModelRef } from "./options"
import { ToolResultValue } from "./messages"
/**
* Token usage reported by an LLM provider.
*
* **Inclusive totals** (match AI SDK / OpenAI / LangChain convention — a
* reader from any of those ecosystems sees the number they expect):
*
* - `inputTokens` — total prompt tokens, *including* cached reads/writes.
* - `outputTokens` — total output tokens, *including* reasoning.
* - `totalTokens` — provider-supplied total, or `inputTokens + outputTokens`.
*
* **Non-overlapping breakdown** (every field is independently meaningful;
* consumers never have to subtract):
*
* - `nonCachedInputTokens` — the "fresh" portion of the prompt.
* - `cacheReadInputTokens` — input tokens served from cache.
* - `cacheWriteInputTokens` — input tokens written to cache.
* - `reasoningTokens` — subset of `outputTokens` spent on hidden reasoning.
*
* **Invariant**: `nonCachedInputTokens + cacheReadInputTokens +
* cacheWriteInputTokens = inputTokens`, and `reasoningTokens ≤ outputTokens`.
* Each protocol mapper computes whichever side it doesn't get natively,
* with `Math.max(0, …)` clamping for defense against provider bugs. Because
* every breakdown field is stored independently, downstream consumers can
* read whatever they need (cost-by-category, context-pressure, AI-SDK-style
* inclusive total) without ever subtracting — eliminating the underflow
* class of bug where a clamped difference would silently store the wrong
* value.
*
* **Semantics by provider**:
*
* - OpenAI Chat / Responses / Gemini / Bedrock: provider reports inclusive
* `inputTokens` and an inclusive `outputTokens`; mapper subtracts to
* derive the breakdown.
* - Anthropic: provider reports the breakdown natively (`input_tokens` is
* non-cached only); mapper sums to derive the inclusive `inputTokens`.
* Anthropic does *not* break extended-thinking out of `output_tokens`, so
* `reasoningTokens` is `undefined` and `outputTokens` carries the
* combined total — a documented limitation of the Anthropic API.
*
* `providerMetadata` always carries the provider's raw usage payload —
* keyed by provider name (`{ openai: ... }`, `{ anthropic: ... }`, etc.)
* — for fields we don't normalize and for billing-level audit trails.
* Matches the same escape-hatch field on `LLMEvent`.
*/
export class Usage extends Schema.Class<Usage>("LLM.Usage")({
inputTokens: Schema.optional(Schema.Number),
outputTokens: Schema.optional(Schema.Number),
reasoningTokens: Schema.optional(Schema.Number),
nonCachedInputTokens: Schema.optional(Schema.Number),
cacheReadInputTokens: Schema.optional(Schema.Number),
cacheWriteInputTokens: Schema.optional(Schema.Number),
reasoningTokens: Schema.optional(Schema.Number),
totalTokens: Schema.optional(Schema.Number),
native: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}) {}
providerMetadata: Schema.optional(ProviderMetadata),
}) {
/**
* Visible output tokens — `outputTokens` minus `reasoningTokens`, clamped
* to zero. The one place subtraction happens in this contract; the clamp
* means a provider reporting `reasoningTokens > outputTokens` produces a
* harmless zero rather than a negative that crashes downstream schemas.
*/
get visibleOutputTokens() {
return Math.max(0, (this.outputTokens ?? 0) - (this.reasoningTokens ?? 0))
}
}
export const RequestStart = Schema.Struct({
type: Schema.tag("request-start"),
+3 -1
View File
@@ -1,6 +1,6 @@
import { Schema } from "effect"
import { JsonSchema, MessageRole, ProviderMetadata } from "./ids"
import { CacheHint, GenerationOptions, HttpOptions, ModelRef, ProviderOptions } from "./options"
import { CacheHint, CachePolicy, GenerationOptions, HttpOptions, ModelRef, ProviderOptions } from "./options"
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value)
@@ -206,6 +206,7 @@ export class LLMRequest extends Schema.Class<LLMRequest>("LLM.Request")({
providerOptions: Schema.optional(ProviderOptions),
http: Schema.optional(HttpOptions),
responseFormat: Schema.optional(ResponseFormat),
cache: Schema.optional(CachePolicy),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}) {}
@@ -223,6 +224,7 @@ export namespace LLMRequest {
providerOptions: request.providerOptions,
http: request.http,
responseFormat: request.responseFormat,
cache: request.cache,
metadata: request.metadata,
})
+28
View File
@@ -200,3 +200,31 @@ export class CacheHint extends Schema.Class<CacheHint>("LLM.CacheHint")({
type: Schema.Literals(["ephemeral", "persistent"]),
ttlSeconds: Schema.optional(Schema.Number),
}) {}
// Auto-placement policy for prompt caching. The protocol-neutral lowering step
// reads this and injects `CacheHint`s at the configured boundaries; the
// per-protocol body builders then translate those hints into wire markers as
// usual. `"auto"` is the recommended default for agent loops — it places one
// breakpoint at the last tool definition, one at the last system part, and one
// at the latest user message. The combination of provider invalidation
// hierarchy (tools → system → messages) and Anthropic/Bedrock's 20-block
// lookback means three trailing breakpoints reliably cover the static prefix.
//
// Pass `"none"` to opt out entirely (the legacy behavior). Pass the granular
// object form to override individual choices.
export const CachePolicyObject = Schema.Struct({
tools: Schema.optional(Schema.Boolean),
system: Schema.optional(Schema.Boolean),
messages: Schema.optional(
Schema.Union([
Schema.Literal("latest-user-message"),
Schema.Literal("latest-assistant"),
Schema.Struct({ tail: Schema.Number }),
]),
),
ttlSeconds: Schema.optional(Schema.Number),
})
export type CachePolicyObject = Schema.Schema.Type<typeof CachePolicyObject>
export const CachePolicy = Schema.Union([Schema.Literal("auto"), Schema.Literal("none"), CachePolicyObject])
export type CachePolicy = Schema.Schema.Type<typeof CachePolicy>
+262
View File
@@ -0,0 +1,262 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { CacheHint, LLM } from "../src"
import { LLMClient } from "../src/route"
import * as AnthropicMessages from "../src/protocols/anthropic-messages"
import * as BedrockConverse from "../src/protocols/bedrock-converse"
import * as Gemini from "../src/protocols/gemini"
import * as OpenAIChat from "../src/protocols/openai-chat"
import { applyCachePolicy } from "../src/cache-policy"
import { it } from "./lib/effect"
const anthropicModel = AnthropicMessages.model({
id: "claude-sonnet-4-5",
baseURL: "https://api.anthropic.test/v1/",
headers: { "x-api-key": "test" },
})
const bedrockModel = BedrockConverse.model({
id: "anthropic.claude-3-5-sonnet-20241022-v2:0",
credentials: { region: "us-east-1", accessKeyId: "fixture", secretAccessKey: "fixture" },
})
const openaiModel = OpenAIChat.model({
id: "gpt-4o-mini",
baseURL: "https://api.openai.test/v1/",
headers: { authorization: "Bearer test" },
})
const geminiModel = Gemini.model({
id: "gemini-2.5-flash",
baseURL: "https://generativelanguage.test/v1beta/",
headers: { "x-goog-api-key": "test" },
})
describe("applyCachePolicy", () => {
it.effect("undefined cache resolves to 'auto' (the recommended default)", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.request({
model: anthropicModel,
system: "You are concise.",
prompt: "hi",
}),
)
// No explicit cache field → auto policy fires → last system part + latest
// user message both get cache_control markers.
expect(prepared.body).toMatchObject({
system: [{ type: "text", text: "You are concise.", cache_control: { type: "ephemeral" } }],
messages: [{ role: "user", content: [{ type: "text", text: "hi", cache_control: { type: "ephemeral" } }] }],
})
}),
)
it.effect("'auto' marks the last tool, last system part, and latest user message on Anthropic", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.request({
model: anthropicModel,
system: "Sys A",
tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
messages: [LLM.user("first user"), LLM.assistant("assistant reply"), LLM.user("latest user message")],
cache: "auto",
}),
)
expect(prepared.body).toMatchObject({
tools: [{ name: "t1", cache_control: { type: "ephemeral" } }],
system: [{ type: "text", text: "Sys A", cache_control: { type: "ephemeral" } }],
messages: [
{ role: "user", content: [{ type: "text", text: "first user" }] },
{ role: "assistant", content: [{ type: "text", text: "assistant reply" }] },
{
role: "user",
content: [{ type: "text", text: "latest user message", cache_control: { type: "ephemeral" } }],
},
],
})
}),
)
it.effect("'auto' is a no-op on OpenAI (implicit caching protocol)", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.request({
model: openaiModel,
system: "Sys",
prompt: "hi",
cache: "auto",
}),
)
const body = prepared.body as { messages: Array<{ content: unknown }> }
// OpenAI doesn't accept cache_control on messages — policy must skip.
const flat = JSON.stringify(body)
expect(flat).not.toContain("cache_control")
expect(flat).not.toContain("cachePoint")
}),
)
it.effect("'auto' is a no-op on Gemini (out-of-band caching protocol)", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.request({
model: geminiModel,
system: "Sys",
prompt: "hi",
cache: "auto",
}),
)
const flat = JSON.stringify(prepared.body)
expect(flat).not.toContain("cache_control")
expect(flat).not.toContain("cachePoint")
}),
)
it.effect("'auto' on Bedrock emits cachePoint markers in the right places", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.request({
model: bedrockModel,
system: "Sys",
tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
messages: [LLM.user("first user"), LLM.assistant("reply"), LLM.user("latest user")],
cache: "auto",
}),
)
expect(prepared.body).toMatchObject({
toolConfig: {
tools: [{ toolSpec: { name: "t1" } }, { cachePoint: { type: "default" } }],
},
system: [{ text: "Sys" }, { cachePoint: { type: "default" } }],
messages: [
{ role: "user", content: [{ text: "first user" }] },
{ role: "assistant", content: [{ text: "reply" }] },
{ role: "user", content: [{ text: "latest user" }, { cachePoint: { type: "default" } }] },
],
})
}),
)
it.effect("'none' disables auto placement even when manual hints exist", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.request({
model: anthropicModel,
system: "Sys",
tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
prompt: "hi",
cache: "none",
}),
)
expect(prepared.body).toMatchObject({
tools: [{ name: "t1", cache_control: undefined }],
system: [{ type: "text", text: "Sys", cache_control: undefined }],
})
}),
)
it.effect("granular object form: tools-only marks just tools", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.request({
model: anthropicModel,
system: "Sys",
tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
prompt: "hi",
cache: { tools: true },
}),
)
expect(prepared.body).toMatchObject({
tools: [{ name: "t1", cache_control: { type: "ephemeral" } }],
system: [{ type: "text", text: "Sys", cache_control: undefined }],
})
}),
)
it.effect("auto policy preserves manual CacheHints on other parts", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.request({
model: anthropicModel,
system: [
{ type: "text", text: "first system", cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }) },
{ type: "text", text: "last system" },
],
prompt: "hi",
cache: "auto",
}),
)
const body = prepared.body as { system: Array<{ text: string; cache_control?: unknown }> }
expect(body.system[0]?.cache_control).toEqual({ type: "ephemeral", ttl: "1h" })
expect(body.system[1]?.cache_control).toEqual({ type: "ephemeral" })
}),
)
it.effect("ttlSeconds in the policy flows through to wire markers", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.request({
model: anthropicModel,
system: "Sys",
prompt: "hi",
cache: { system: true, ttlSeconds: 3600 },
}),
)
expect(prepared.body).toMatchObject({
system: [{ type: "text", text: "Sys", cache_control: { type: "ephemeral", ttl: "1h" } }],
})
}),
)
it.effect("messages: { tail: 2 } marks the last 2 message boundaries", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.request({
model: anthropicModel,
messages: [LLM.user("u1"), LLM.assistant("a1"), LLM.user("u2"), LLM.assistant("a2")],
cache: { messages: { tail: 2 } },
}),
)
const body = prepared.body as { messages: Array<{ content: Array<{ cache_control?: unknown }> }> }
expect(body.messages[0]?.content[0]?.cache_control).toBeUndefined()
expect(body.messages[1]?.content[0]?.cache_control).toBeUndefined()
expect(body.messages[2]?.content[0]?.cache_control).toEqual({ type: "ephemeral" })
expect(body.messages[3]?.content[0]?.cache_control).toEqual({ type: "ephemeral" })
}),
)
it.effect("'latest-assistant' marks the last assistant message", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.request({
model: anthropicModel,
messages: [LLM.user("u1"), LLM.assistant("a1"), LLM.user("u2")],
cache: { messages: "latest-assistant" },
}),
)
const body = prepared.body as { messages: Array<{ content: Array<{ cache_control?: unknown }> }> }
expect(body.messages[0]?.content[0]?.cache_control).toBeUndefined()
expect(body.messages[1]?.content[0]?.cache_control).toEqual({ type: "ephemeral" })
expect(body.messages[2]?.content[0]?.cache_control).toBeUndefined()
}),
)
test("returns the same request reference when policy is a no-op (pure function)", () => {
const request = LLM.request({
model: anthropicModel,
prompt: "hi",
cache: "none",
})
expect(applyCachePolicy(request)).toBe(request)
})
})
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -20,6 +20,9 @@ const cacheRequest = LLM.request({
model,
system: [{ type: "text", text: LARGE_CACHEABLE_SYSTEM, cache: new CacheHint({ type: "ephemeral" }) }],
prompt: "Say hi.",
// Manual hint on the system part is the only marker we want here — skip the
// auto-policy's latest-user-message breakpoint so the cassette body matches.
cache: "none",
generation: { maxTokens: 16, temperature: 0 },
})
@@ -28,7 +31,12 @@ const recorded = recordedTests({
provider: "anthropic",
protocol: "anthropic-messages",
requires: ["ANTHROPIC_API_KEY"],
options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) },
// Two identical requests in one cassette — match by recording order so the
// second call replays the cached-hit interaction.
options: {
dispatch: "sequential",
redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }),
},
})
describe("Anthropic Messages cache recorded", () => {
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { CacheHint, LLM, LLMError } from "../../src"
import { CacheHint, LLM, LLMError, Usage } from "../../src"
import { LLMClient } from "../../src/route"
import * as AnthropicMessages from "../../src/protocols/anthropic-messages"
import { it } from "../lib/effect"
@@ -18,6 +18,9 @@ const request = LLM.request({
model,
system: { type: "text", text: "You are concise.", cache: new CacheHint({ type: "ephemeral" }) },
prompt: "Say hello.",
// This fixture predates the `cache: "auto"` default; pin the policy off so
// existing wire-shape assertions only see the manual hint on the system part.
cache: "none",
generation: { maxTokens: 20, temperature: 0 },
})
@@ -48,6 +51,7 @@ describe("Anthropic Messages route", () => {
LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
LLM.toolMessage({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }),
],
cache: "none",
}),
)
@@ -110,10 +114,11 @@ describe("Anthropic Messages route", () => {
expect(response.text).toBe("Hello!")
expect(response.reasoning).toBe("thinking")
expect(response.usage).toMatchObject({
inputTokens: 5,
inputTokens: 6,
outputTokens: 2,
nonCachedInputTokens: 5,
cacheReadInputTokens: 1,
totalTokens: 7,
totalTokens: 8,
})
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
providerMetadata: { anthropic: { signature: "sig_1" } },
@@ -152,7 +157,13 @@ describe("Anthropic Messages route", () => {
{
type: "request-finish",
reason: "tool-calls",
usage: { inputTokens: 5, outputTokens: 1, totalTokens: 6, native: { input_tokens: 5, output_tokens: 1 } },
usage: new Usage({
inputTokens: 5,
outputTokens: 1,
nonCachedInputTokens: 5,
totalTokens: 6,
providerMetadata: { anthropic: { input_tokens: 5, output_tokens: 1 } },
}),
},
])
}),
@@ -27,6 +27,9 @@ const cacheRequest = LLM.request({
model,
system: [{ type: "text", text: LARGE_CACHEABLE_SYSTEM, cache: new CacheHint({ type: "ephemeral" }) }],
prompt: "Say hi.",
// Manual hint on the system part is the only marker we want here — skip the
// auto-policy's latest-user-message breakpoint so the cassette body matches.
cache: "none",
generation: { maxTokens: 16, temperature: 0 },
})
@@ -35,6 +38,9 @@ const recorded = recordedTests({
provider: "amazon-bedrock",
protocol: "bedrock-converse",
requires: ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"],
// Two identical requests in one cassette — match by recording order so the
// second call replays the cached-hit interaction.
options: { dispatch: "sequential" },
})
describe("Bedrock Converse cache recorded", () => {
@@ -63,6 +63,9 @@ const baseRequest = LLM.request({
model,
system: "You are concise.",
prompt: "Say hello.",
// Wire-shape assertions in this file predate the `cache: "auto"` default;
// pin the policy off so they only exercise the lowering path itself.
cache: "none",
generation: { maxTokens: 64, temperature: 0 },
})
@@ -125,6 +128,7 @@ describe("Bedrock Converse route", () => {
LLM.assistant([LLM.toolCall({ id: "tool_1", name: "lookup", input: { query: "weather" } })]),
LLM.toolMessage({ id: "tool_1", name: "lookup", result: { forecast: "sunny" } }),
],
cache: "none",
}),
)
@@ -339,6 +343,7 @@ describe("Bedrock Converse route", () => {
{ type: "media", mediaType: "image/webp", data: "DDDD" },
]),
],
cache: "none",
}),
)
@@ -470,6 +475,7 @@ describe("Bedrock Converse route", () => {
LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: {} })]),
LLM.toolMessage({ id: "call_1", name: "lookup", result: { temp: 72 }, cache }),
],
cache: "none",
}),
)
@@ -555,6 +561,7 @@ describe("Bedrock Converse recorded", () => {
model: recordedModel(),
system: "Reply with the single word 'Hello'.",
prompt: "Say hello.",
cache: "none",
generation: { maxTokens: 16, temperature: 0 },
}),
)
@@ -577,6 +584,7 @@ describe("Bedrock Converse recorded", () => {
prompt: "Call get_weather with city exactly Paris.",
tools: [weatherTool],
toolChoice: LLM.toolChoice(weatherTool),
cache: "none",
generation: { maxTokens: 80, temperature: 0 },
}),
)
@@ -8,7 +8,7 @@ import { recordedTests } from "../recorded-test"
const model = Gemini.model({
id: "gemini-2.5-flash",
apiKey: process.env.GEMINI_API_KEY ?? "fixture",
apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY ?? process.env.GEMINI_API_KEY ?? "fixture",
})
// Gemini does implicit prefix caching on 2.5+ models above ~1024 tokens. The
@@ -28,7 +28,10 @@ const recorded = recordedTests({
prefix: "gemini-cache",
provider: "google",
protocol: "gemini",
requires: ["GEMINI_API_KEY"],
requires: ["GOOGLE_GENERATIVE_AI_API_KEY"],
// Two identical requests in one cassette — match by recording order so the
// second call replays the cached-hit interaction.
options: { dispatch: "sequential" },
})
describe("Gemini cache recorded", () => {
+21 -16
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMError } from "../../src"
import { LLM, LLMError, Usage } from "../../src"
import { LLMClient } from "../../src/route"
import * as Gemini from "../../src/protocols/gemini"
import { it } from "../lib/effect"
@@ -198,9 +198,10 @@ describe("Gemini route", () => {
expect(response.reasoning).toBe("thinking")
expect(response.usage).toMatchObject({
inputTokens: 5,
outputTokens: 2,
reasoningTokens: 1,
outputTokens: 3,
nonCachedInputTokens: 4,
cacheReadInputTokens: 1,
reasoningTokens: 1,
totalTokens: 7,
})
expect(response.events).toEqual([
@@ -210,20 +211,23 @@ describe("Gemini route", () => {
{
type: "request-finish",
reason: "stop",
usage: {
usage: new Usage({
inputTokens: 5,
outputTokens: 2,
reasoningTokens: 1,
outputTokens: 3,
nonCachedInputTokens: 4,
cacheReadInputTokens: 1,
reasoningTokens: 1,
totalTokens: 7,
native: {
promptTokenCount: 5,
candidatesTokenCount: 2,
totalTokenCount: 7,
thoughtsTokenCount: 1,
cachedContentTokenCount: 1,
providerMetadata: {
google: {
promptTokenCount: 5,
candidatesTokenCount: 2,
totalTokenCount: 7,
thoughtsTokenCount: 1,
cachedContentTokenCount: 1,
},
},
},
}),
},
])
}),
@@ -257,12 +261,13 @@ describe("Gemini route", () => {
{
type: "request-finish",
reason: "tool-calls",
usage: {
usage: new Usage({
inputTokens: 5,
outputTokens: 1,
nonCachedInputTokens: 5,
totalTokens: 6,
native: { promptTokenCount: 5, candidatesTokenCount: 1 },
},
providerMetadata: { google: { promptTokenCount: 5, candidatesTokenCount: 1 } },
}),
},
])
}),
+13 -10
View File
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import { Effect, Schema, Stream } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMError } from "../../src"
import { LLM, LLMError, Usage } from "../../src"
import * as Azure from "../../src/providers/azure"
import * as OpenAI from "../../src/providers/openai"
import * as OpenAIChat from "../../src/protocols/openai-chat"
@@ -230,20 +230,23 @@ describe("OpenAI Chat route", () => {
{
type: "request-finish",
reason: "stop",
usage: {
usage: new Usage({
inputTokens: 5,
outputTokens: 2,
reasoningTokens: 0,
nonCachedInputTokens: 4,
cacheReadInputTokens: 1,
reasoningTokens: 0,
totalTokens: 7,
native: {
prompt_tokens: 5,
completion_tokens: 2,
total_tokens: 7,
prompt_tokens_details: { cached_tokens: 1 },
completion_tokens_details: { reasoning_tokens: 0 },
providerMetadata: {
openai: {
prompt_tokens: 5,
completion_tokens: 2,
total_tokens: 7,
prompt_tokens_details: { cached_tokens: 1 },
completion_tokens_details: { reasoning_tokens: 0 },
},
},
},
}),
},
])
}),
@@ -29,6 +29,9 @@ const recorded = recordedTests({
provider: "openai",
protocol: "openai-responses",
requires: ["OPENAI_API_KEY"],
// Two identical requests in one cassette — match by recording order so the
// second call replays the cached-hit interaction, not the cold-miss one.
options: { dispatch: "sequential" },
})
describe("OpenAI Responses cache recorded", () => {
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import { ConfigProvider, Effect, Layer, Stream } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMError } from "../../src"
import { LLM, LLMError, Usage } from "../../src"
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
import * as Azure from "../../src/providers/azure"
import * as OpenAI from "../../src/providers/openai"
@@ -342,20 +342,23 @@ describe("OpenAI Responses route", () => {
type: "request-finish",
reason: "stop",
providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } },
usage: {
usage: new Usage({
inputTokens: 5,
outputTokens: 2,
reasoningTokens: 0,
nonCachedInputTokens: 4,
cacheReadInputTokens: 1,
reasoningTokens: 0,
totalTokens: 7,
native: {
input_tokens: 5,
output_tokens: 2,
total_tokens: 7,
input_tokens_details: { cached_tokens: 1 },
output_tokens_details: { reasoning_tokens: 0 },
providerMetadata: {
openai: {
input_tokens: 5,
output_tokens: 2,
total_tokens: 7,
input_tokens_details: { cached_tokens: 1 },
output_tokens_details: { reasoning_tokens: 0 },
},
},
},
}),
},
])
}),
@@ -411,7 +414,13 @@ describe("OpenAI Responses route", () => {
{
type: "request-finish",
reason: "tool-calls",
usage: { inputTokens: 5, outputTokens: 1, totalTokens: 6, native: { input_tokens: 5, output_tokens: 1 } },
usage: new Usage({
inputTokens: 5,
outputTokens: 1,
nonCachedInputTokens: 5,
totalTokens: 6,
providerMetadata: { openai: { input_tokens: 5, output_tokens: 1 } },
}),
},
])
}),
+3
View File
@@ -51,6 +51,7 @@ export const textRequest = (input: {
model: input.model,
system: "You are concise.",
prompt: input.prompt ?? "Reply with exactly: Hello!",
cache: "none",
generation:
input.temperature === false
? { maxTokens: input.maxTokens ?? 20 }
@@ -70,6 +71,7 @@ export const weatherToolRequest = (input: {
prompt: "Call get_weather with city exactly Paris.",
tools: [weatherTool],
toolChoice: LLM.toolChoice(weatherTool),
cache: "none",
generation:
input.temperature === false
? { maxTokens: input.maxTokens ?? 80 }
@@ -88,6 +90,7 @@ export const weatherToolLoopRequest = (input: {
model: input.model,
system: input.system ?? "Use the get_weather tool, then answer in one short sentence.",
prompt: "What is the weather in Paris?",
cache: "none",
generation:
input.temperature === false
? { maxTokens: input.maxTokens ?? 80 }
+29 -1
View File
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { ContentPart, LLMEvent, LLMRequest, ModelID, ModelLimits, ModelRef, ProviderID } from "../src/schema"
import { ContentPart, LLMEvent, LLMRequest, ModelID, ModelLimits, ModelRef, ProviderID, Usage } from "../src/schema"
import { ProviderShared } from "../src/protocols/shared"
const model = new ModelRef({
id: ModelID.make("fake-model"),
@@ -48,3 +49,30 @@ describe("llm schema", () => {
expect(ContentPart.guards.media({ type: "text", text: "hi" })).toBe(false)
})
})
describe("LLM.Usage", () => {
test("subtractTokens clamps non-sensical breakdowns to zero", () => {
// Defense against a provider reporting cached_tokens > prompt_tokens or
// reasoning_tokens > completion_tokens — the negative would otherwise
// round-trip through the pipeline and crash strict downstream schemas.
expect(ProviderShared.subtractTokens(5, 3)).toBe(2)
expect(ProviderShared.subtractTokens(5, 10)).toBe(0)
expect(ProviderShared.subtractTokens(5, undefined)).toBe(5)
expect(ProviderShared.subtractTokens(undefined, 3)).toBeUndefined()
expect(ProviderShared.subtractTokens(undefined, undefined)).toBeUndefined()
})
test("sumTokens returns undefined only when every input is undefined", () => {
expect(ProviderShared.sumTokens(1, 2, 3)).toBe(6)
expect(ProviderShared.sumTokens(1, undefined, 3)).toBe(4)
expect(ProviderShared.sumTokens(undefined, undefined, undefined)).toBeUndefined()
expect(ProviderShared.sumTokens()).toBeUndefined()
})
test("visibleOutputTokens clamps reasoning > output to zero", () => {
expect(new Usage({ outputTokens: 10, reasoningTokens: 4 }).visibleOutputTokens).toBe(6)
expect(new Usage({ outputTokens: 10 }).visibleOutputTokens).toBe(10)
expect(new Usage({ outputTokens: 4, reasoningTokens: 10 }).visibleOutputTokens).toBe(0)
expect(new Usage({}).visibleOutputTokens).toBe(0)
})
})
+45
View File
@@ -0,0 +1,45 @@
# opencode
The runtime that powers the opencode CLI/TUI: it manages projects on disk, runs
agent sessions against LLM providers, and exposes a control plane so other
clients (web, IDE, remote workspaces) can connect to the same state.
## Language
### Location & runtime
**Project**:
The persisted, VCS-rooted thing the user owns — one row per repo (or per non-VCS directory).
_Avoid_: repo, codebase, folder
**Worktree**:
A git worktree of a **Project** — a branch checkout sitting at a directory on disk.
_Avoid_: branch dir, checkout, workspace
**Workspace**:
A control-plane connection target for a **Project**, identified by a `wrk_…` ID. Local or remote. Describes _how_ a client is connected to a Project, not where the Project lives on disk.
_Avoid_: worktree, session, project (the connection is not the project itself)
**Instance**:
The runtime `(project, worktree, directory)` ALS context the opencode process is currently operating inside. Not persisted; not user-visible.
_Avoid_: session, context, runtime
**Directory**:
A filesystem path string. The Instance always has one as its current working directory.
_Avoid_: folder, cwd (in domain prose; fine in code)
## Relationships
- A **Project** has zero or more **Worktrees**.
- A **Worktree** belongs to exactly one **Project**.
- A **Workspace** points at exactly one **Project** (and optionally a specific branch/directory inside it).
- An **Instance** is bound to one **Project**, one **Worktree**, and one **Directory** for the life of an async context.
## Example dialogue
> **Dev:** "When the user opens a remote target, do we create a new **Worktree**?"
> **Maintainer:** "No — a remote target is a **Workspace** pointing at a **Project** on the other side. The remote host already has its own **Worktrees**; we just connect to one."
## Flagged ambiguities
- "workspace" was used in `src/project/project.ts` to mean **Worktree** ("Startup script to run when creating a new workspace (worktree)") — resolved: these are distinct. The start command runs when creating a **Worktree**, not a **Workspace**.
-71
View File
@@ -26,7 +26,6 @@ import * as Option from "effect/Option"
import * as OtelTracer from "@effect/opentelemetry/Tracer"
import { zod } from "@opencode-ai/core/effect-zod"
import { withStatics, type DeepMutable } from "@opencode-ai/core/schema"
import { Reference } from "@/reference/reference"
export const Info = Schema.Struct({
name: Schema.String,
@@ -301,76 +300,6 @@ export const layer = Layer.effect(
item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
}
function referencePrompt(reference: Reference.Resolved) {
if (reference.kind === "local") {
return [
`You are configured reference @${reference.name}, a read-only research agent for external reference material.`,
`Local directory: ${reference.path}`,
`Inspect this directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches. Do not edit files.`,
`Return exact absolute file paths for findings whenever possible.`,
].join("\n\n")
}
if (reference.kind === "invalid") {
return [
`You are configured reference @${reference.name}, but this reference is not usable yet.`,
`Configured repository: ${reference.repository}`,
`Problem: ${reference.message}`,
`Explain this configuration problem if invoked. Do not edit files or attempt fallback clones.`,
].join("\n\n")
}
return [
`You are configured reference @${reference.name}, a read-only research agent for external reference material.`,
`Repository: ${reference.repository}`,
...(reference.branch ? [`Branch/ref: ${reference.branch}`] : []),
`Cached directory: ${reference.path}`,
`OpenCode materializes this configured repository before use. Do not call repo_clone for this reference.`,
`Inspect the cached directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches, then use Glob, Grep, and Read inside that directory. Do not edit files.`,
`Return exact absolute file paths for findings whenever possible.`,
].join("\n\n")
}
function referenceDescription(reference: Reference.Resolved) {
if (reference.kind === "local") return `Scout reference for local directory ${reference.path}`
if (reference.kind === "git") return `Scout reference for repository ${reference.repository}`
return `Invalid Scout reference for repository ${reference.repository}`
}
if (Flag.OPENCODE_EXPERIMENTAL_SCOUT) {
const resolvedReferences = Reference.resolveAll({
references: cfg.reference ?? {},
directory: ctx.directory,
worktree: ctx.worktree,
})
for (const resolved of resolvedReferences) {
if (agents[resolved.name]) continue
const localPath = resolved.kind === "invalid" ? undefined : resolved.path
agents[resolved.name] = {
name: resolved.name,
description: referenceDescription(resolved),
permission: Permission.merge(
agents.scout.permission,
Permission.fromConfig({
repo_clone: "deny",
...(localPath
? {
external_directory: {
[localPath]: "allow",
[path.join(localPath, "*")]: "allow",
},
}
: {}),
}),
),
prompt: referencePrompt(resolved),
options: { reference: cfg.reference?.[resolved.name], resolved },
mode: "subagent",
native: false,
}
}
}
// Ensure Truncate.GLOB is allowed unless explicitly configured
for (const name in agents) {
const agent = agents[name]
@@ -926,13 +926,7 @@ export function Prompt(props: PromptProps) {
target: inputTarget,
enabled: (() => {
cursorVersion()
return (
inputTarget() !== undefined &&
!props.disabled &&
!auto()?.visible &&
input !== undefined &&
(input.cursorOffset === 0 || input.visualCursor.visualRow === 0)
)
return inputTarget() !== undefined && !props.disabled && !auto()?.visible && input !== undefined
})(),
commands: [
{
@@ -941,12 +935,12 @@ export function Prompt(props: PromptProps) {
category: "Prompt",
run() {
if (input.cursorOffset !== 0) {
input.cursorOffset = 0
return
if (input.scrollY + input.visualCursor.visualRow === 0) input.cursorOffset = 0
return false
}
const item = history.move(-1, input.plainText)
if (!item) return
if (!item) return false
input.setText(item.input)
setStore("prompt", item)
setStore("mode", item.mode ?? "normal")
@@ -964,13 +958,7 @@ export function Prompt(props: PromptProps) {
target: inputTarget,
enabled: (() => {
cursorVersion()
return (
inputTarget() !== undefined &&
!props.disabled &&
!auto()?.visible &&
input !== undefined &&
(input.cursorOffset === input.plainText.length || input.visualCursor.visualRow === input.height - 1)
)
return inputTarget() !== undefined && !props.disabled && !auto()?.visible && input !== undefined
})(),
commands: [
{
@@ -979,12 +967,16 @@ export function Prompt(props: PromptProps) {
category: "Prompt",
run() {
if (input.cursorOffset !== input.plainText.length) {
input.cursorOffset = input.plainText.length
return
if (
input.scrollY + input.visualCursor.visualRow ===
Math.max(0, input.editorView.getTotalVirtualLineCount() - 1)
)
input.cursorOffset = input.plainText.length
return false
}
const item = history.move(1, input.plainText)
if (!item) return
if (!item) return false
input.setText(item.input)
setStore("prompt", item)
setStore("mode", item.mode ?? "normal")
@@ -746,7 +746,7 @@ export function Session() {
title: "Line up",
value: "session.line.up",
category: "Session",
enabled: false,
hidden: true,
run: () => {
scroll.scrollBy(-1)
dialog.clear()
@@ -756,7 +756,7 @@ export function Session() {
title: "Line down",
value: "session.line.down",
category: "Session",
enabled: false,
hidden: true,
run: () => {
scroll.scrollBy(1)
dialog.clear()
+2 -2
View File
@@ -273,10 +273,10 @@ export const Info = Schema.Struct({
}),
tail_turns: Schema.optional(NonNegativeInt).annotate({
description:
"Number of recent user turns, including their following assistant/tool responses, to keep verbatim during compaction (default: 2)",
"Number of recent user turns, including their following assistant/tool responses, to serialize into the compaction summary (default: 2)",
}),
preserve_recent_tokens: Schema.optional(NonNegativeInt).annotate({
description: "Maximum number of tokens from recent turns to preserve verbatim after compaction",
description: "Maximum number of tokens from recent turns to serialize into the compaction summary",
}),
reserved: Schema.optional(NonNegativeInt).annotate({
description: "Token buffer for compaction. Leaves enough window to avoid overflow during compaction.",
@@ -641,7 +641,7 @@ export const layer = Layer.effect(
// "claim" this session so any future events coming from
// the old workspace are ignored
SyncEvent.claim(input.sessionID, input.workspaceID ?? previous.projectID)
yield* sync.claim(input.sessionID, input.workspaceID ?? previous.projectID)
}
}
+1 -1
View File
@@ -36,7 +36,7 @@ export const layer = Layer.effect(
if (completed) continue
log.info("running data migration", { name: migration.name })
yield* migration.run
yield* migration.run.pipe(Effect.withSpan("DataMigration", { attributes: { name: migration.name } }))
Database.use((db) =>
db
.insert(DataMigrationTable)
@@ -86,7 +86,7 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
)
const disposeContext = Effect.fn("InstanceStore.disposeContext")(function* (ctx: InstanceContext) {
yield* Effect.logInfo("disposing instance", { directory: ctx.directory })
yield* Effect.logInfo("disposing instance").pipe(Effect.annotateLogs("directory", ctx.directory))
yield* Effect.promise(() => runDisposers(ctx.directory))
yield* emitDisposed({ directory: ctx.directory, project: ctx.project.id })
})
@@ -109,7 +109,7 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
const entry: Entry = { deferred: Deferred.makeUnsafe<InstanceContext>() }
cache.set(directory, entry)
yield* Effect.gen(function* () {
yield* Effect.logInfo("creating instance", { directory })
yield* Effect.logInfo("creating instance").pipe(Effect.annotateLogs("directory", directory))
yield* completeLoad(directory, input, entry)
}).pipe(Effect.forkIn(scope, { startImmediately: true }))
return yield* restore(Deferred.await(entry.deferred))
@@ -125,7 +125,7 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
const entry: Entry = { deferred: Deferred.makeUnsafe<InstanceContext>() }
cache.set(directory, entry)
yield* Effect.gen(function* () {
yield* Effect.logInfo("reloading instance", { directory })
yield* Effect.logInfo("reloading instance").pipe(Effect.annotateLogs("directory", directory))
if (previous) {
yield* Deferred.await(previous.deferred).pipe(Effect.ignore)
yield* Effect.promise(() => runDisposers(directory))
+1 -1
View File
@@ -34,7 +34,7 @@ const ProjectIcon = Schema.Struct({
const ProjectCommands = Schema.Struct({
start: optionalOmitUndefined(
Schema.String.annotate({ description: "Startup script to run when creating a new workspace (worktree)" }),
Schema.String.annotate({ description: "Startup script to run when creating a new worktree" }),
),
})
@@ -236,9 +236,9 @@ export const SessionApi = HttpApi.make("session")
HttpApiEndpoint.post("fork", SessionPaths.fork, {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
payload: ForkPayload,
payload: Schema.optional(ForkPayload),
success: described(Session.Info, "200"),
error: ApiNotFoundError,
error: [HttpApiError.BadRequest, ApiNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.fork",
@@ -187,13 +187,30 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
const fork = Effect.fn("SessionHttpApi.fork")(function* (ctx: {
params: { sessionID: SessionID }
payload: typeof ForkPayload.Type
payload?: typeof ForkPayload.Type
}) {
return yield* SessionError.mapStorageNotFound(
session.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload.messageID }),
session.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload?.messageID }),
)
})
const forkRaw = Effect.fn("SessionHttpApi.forkRaw")(function* (ctx: {
params: { sessionID: SessionID }
request: HttpServerRequest.HttpServerRequest
}) {
const body = yield* Effect.orDie(ctx.request.text)
if (body.trim().length === 0) return yield* fork({ params: ctx.params })
const json = yield* Effect.try({
try: () => JSON.parse(body) as unknown,
catch: () => new HttpApiError.BadRequest({}),
})
const payload = yield* Schema.decodeUnknownEffect(ForkPayload)(json).pipe(
Effect.mapError(() => new HttpApiError.BadRequest({})),
)
return yield* fork({ params: ctx.params, payload })
})
const abort = Effect.fn("SessionHttpApi.abort")(function* (ctx: { params: { sessionID: SessionID } }) {
yield* promptSvc.cancel(ctx.params.sessionID)
return true
@@ -373,7 +390,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
.handleRaw("create", createRaw)
.handle("remove", remove)
.handle("update", update)
.handle("fork", fork)
.handleRaw("fork", forkRaw)
.handle("abort", abort)
.handle("init", init)
.handle("share", share)
+36 -24
View File
@@ -79,12 +79,10 @@ Rules:
type Turn = {
start: number
end: number
id: MessageID
}
type Tail = {
start: number
id: MessageID
}
type CompletedCompaction = {
@@ -121,19 +119,41 @@ function completedCompactions(messages: MessageV2.WithParts[]) {
})
}
function buildPrompt(input: { previousSummary?: string; context: string[] }) {
function buildPrompt(input: { previousSummary?: string; context: string[]; tail?: string }) {
const source = input.tail
? "the conversation history above and the serialized recent conversation tail below"
: "the conversation history above"
const anchor = input.previousSummary
? [
"Update the anchored summary below using the conversation history above.",
`Update the anchored summary below using ${source}.`,
"Preserve still-true details, remove stale details, and merge in the new facts.",
"<previous-summary>",
input.previousSummary,
"</previous-summary>",
].join("\n")
: "Create a new anchored summary from the conversation history above."
return [anchor, SUMMARY_TEMPLATE, ...input.context].join("\n\n")
: `Create a new anchored summary from ${source}.`
const tail = input.tail
? [
"Fold this serialized recent conversation tail into the summary; it is not provider message history.",
"<recent-conversation-tail>",
input.tail,
"</recent-conversation-tail>",
].join("\n")
: undefined
return [anchor, ...(tail ? [tail] : []), SUMMARY_TEMPLATE, ...input.context].join("\n\n")
}
const serialize = Effect.fn("SessionCompaction.serialize")(function* (input: {
messages: MessageV2.WithParts[]
model: Provider.Model
}) {
const messages = yield* MessageV2.toModelMessagesEffect(input.messages, input.model, {
stripMedia: true,
toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS,
})
return messages.length ? JSON.stringify(messages, null, 2) : undefined
})
function preserveRecentBudget(input: { cfg: Config.Info; model: Provider.Model }) {
return (
input.cfg.compaction?.preserve_recent_tokens ??
@@ -150,7 +170,6 @@ function turns(messages: MessageV2.WithParts[]) {
result.push({
start: i,
end: messages.length,
id: msg.info.id,
})
}
for (let i = 0; i < result.length - 1; i++) {
@@ -177,7 +196,6 @@ function splitTurn(input: {
if (size > input.budget) continue
return {
start,
id: input.messages[start]!.info.id,
} satisfies Tail
}
return undefined
@@ -244,8 +262,7 @@ export const layer: Layer.Layer<
messages: MessageV2.WithParts[]
model: Provider.Model
}) {
const msgs = yield* MessageV2.toModelMessagesEffect(input.messages, input.model)
return Token.estimate(JSON.stringify(msgs))
return Token.estimate((yield* serialize(input)) ?? "")
})
const select = Effect.fn("SessionCompaction.select")(function* (input: {
@@ -254,10 +271,10 @@ export const layer: Layer.Layer<
model: Provider.Model
}) {
const limit = input.cfg.compaction?.tail_turns ?? DEFAULT_TAIL_TURNS
if (limit <= 0) return { head: input.messages, tail_start_id: undefined }
if (limit <= 0) return { head: input.messages, tail: [] }
const budget = preserveRecentBudget({ cfg: input.cfg, model: input.model })
const all = turns(input.messages)
if (!all.length) return { head: input.messages, tail_start_id: undefined }
if (!all.length) return { head: input.messages, tail: [] }
const recent = all.slice(-limit)
const sizes = yield* Effect.forEach(
recent,
@@ -276,7 +293,7 @@ export const layer: Layer.Layer<
const size = sizes[i]
if (total + size <= budget) {
total += size
keep = { start: turn.start, id: turn.id }
keep = { start: turn.start }
continue
}
const remaining = budget - total
@@ -292,10 +309,10 @@ export const layer: Layer.Layer<
break
}
if (!keep || keep.start === 0) return { head: input.messages, tail_start_id: undefined }
if (!keep) return { head: input.messages, tail: [] }
return {
head: input.messages.slice(0, keep.start),
tail_start_id: keep.id,
tail: input.messages.slice(keep.start),
}
})
@@ -406,7 +423,10 @@ export const layer: Layer.Layer<
{ sessionID: input.sessionID },
{ context: [], prompt: undefined },
)
const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context })
const tailMessages = structuredClone(selected.tail)
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: tailMessages })
const tail = yield* serialize({ messages: tailMessages, model })
const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context, tail })
const msgs = structuredClone(selected.head)
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, {
@@ -473,13 +493,6 @@ export const layer: Layer.Layer<
return "stop"
}
if (compactionPart && selected.tail_start_id && compactionPart.tail_start_id !== selected.tail_start_id) {
yield* session.updatePart({
...compactionPart,
tail_start_id: selected.tail_start_id,
})
}
if (result === "continue" && input.auto) {
if (replay) {
const original = replay.info
@@ -575,7 +588,6 @@ export const layer: Layer.Layer<
sessionID: input.sessionID,
timestamp: DateTime.makeUnsafe(Date.now()),
text: summary ?? "",
include: selected.tail_start_id,
})
}
yield* bus.publish(Event.Compacted, { sessionID: input.sessionID })
+10 -65
View File
@@ -23,7 +23,7 @@ import type { SystemError } from "bun"
import type { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "@/provider/schema"
import { Effect, Schema, Types } from "effect"
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
import { zod } from "@opencode-ai/core/effect-zod"
import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema"
import { namedSchemaError } from "@/util/named-schema-error"
import * as EffectLogger from "@opencode-ai/core/effect/logger"
@@ -402,7 +402,7 @@ export const User = Schema.Struct({
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type User = Types.DeepMutable<Schema.Schema.Type<typeof User>>
const _Part = Schema.Union([
export const Part = Schema.Union([
TextPart,
SubtaskPart,
ReasoningPart,
@@ -416,22 +416,6 @@ const _Part = Schema.Union([
RetryPart,
CompactionPart,
]).annotate({ discriminator: "type", identifier: "Part" })
export const Part = Object.assign(_Part, {
zod: zod(_Part) as unknown as z.ZodType<
| TextPart
| SubtaskPart
| ReasoningPart
| FilePart
| ToolPart
| StepStartPart
| StepFinishPart
| SnapshotPart
| PatchPart
| AgentPart
| RetryPart
| CompactionPart
>,
})
export type Part =
| TextPart
| SubtaskPart
@@ -573,15 +557,12 @@ export type Assistant = Omit<Types.DeepMutable<Schema.Schema.Type<typeof Assista
error?: AssistantError
}
const _Info = Schema.Union([User, Assistant]).annotate({ discriminator: "role", identifier: "Message" })
export const Info = Object.assign(_Info, {
zod: zod(_Info) as unknown as z.ZodType<User | Assistant>,
})
export const Info = Schema.Union([User, Assistant]).annotate({ discriminator: "role", identifier: "Message" })
export type Info = User | Assistant
const UpdatedEventSchema = Schema.Struct({
sessionID: SessionID,
info: _Info,
info: Info,
})
const RemovedEventSchema = Schema.Struct({
@@ -591,7 +572,7 @@ const RemovedEventSchema = Schema.Struct({
const PartUpdatedEventSchema = Schema.Struct({
sessionID: SessionID,
part: _Part,
part: Part,
time: NonNegativeInt,
})
@@ -639,8 +620,8 @@ export const Event = {
}
export const WithParts = Schema.Struct({
info: _Info,
parts: Schema.Array(_Part),
info: Info,
parts: Schema.Array(Part),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export type WithParts = {
info: Info
@@ -859,12 +840,13 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
return part.metadata?.anthropic?.signature != null
})
for (const part of msg.parts) {
if (msg.info.summary && part.type !== "text") continue
if (part.type === "text") {
const text = part.text === "" && hasSignedReasoning ? " " : part.text
assistantMessage.parts.push({
type: "text",
text,
...(differentModel ? {} : { providerMetadata: part.metadata }),
...(differentModel || msg.info.summary ? {} : { providerMetadata: part.metadata }),
})
}
if (part.type === "step-start")
@@ -1090,53 +1072,16 @@ export function get(input: { sessionID: SessionID; messageID: MessageID }): With
export function filterCompacted(msgs: Iterable<WithParts>) {
const result = [] as WithParts[]
const completed = new Set<string>()
let retain: MessageID | undefined
for (const msg of msgs) {
result.push(msg)
if (retain) {
if (msg.info.id === retain) break
continue
}
if (msg.info.role === "user" && completed.has(msg.info.id)) {
const part = msg.parts.find((item): item is CompactionPart => item.type === "compaction")
if (!part) continue
if (!part.tail_start_id) break
retain = part.tail_start_id
if (msg.info.id === retain) break
if (msg.parts.some((item): item is CompactionPart => item.type === "compaction")) break
continue
}
if (msg.info.role === "user" && completed.has(msg.info.id) && msg.parts.some((part) => part.type === "compaction"))
break
if (msg.info.role === "assistant" && msg.info.summary && msg.info.finish && !msg.info.error)
completed.add(msg.info.parentID)
}
result.reverse()
const compactionIndex = result.findLastIndex(
(msg) =>
msg.info.role === "user" &&
msg.parts.some((item): item is CompactionPart => item.type === "compaction" && item.tail_start_id !== undefined),
)
const compaction = result[compactionIndex]
const part = compaction?.parts.find(
(item): item is CompactionPart => item.type === "compaction" && item.tail_start_id !== undefined,
)
const summaryIndex = compaction
? result.findIndex(
(msg, index) =>
index > compactionIndex &&
msg.info.role === "assistant" &&
msg.info.summary &&
msg.info.parentID === compaction.info.id,
)
: -1
const tailIndex = part?.tail_start_id ? result.findIndex((msg) => msg.info.id === part.tail_start_id) : -1
if (tailIndex >= 0 && tailIndex < compactionIndex && summaryIndex > compactionIndex) {
return [
...result.slice(compactionIndex, summaryIndex + 1),
...result.slice(tailIndex, compactionIndex),
...result.slice(summaryIndex + 1),
]
}
return result
}
+178 -7
View File
@@ -56,7 +56,8 @@ import { EffectBridge } from "@/effect/bridge"
import { SyncEvent } from "@/sync"
import { SessionEvent } from "@/v2/session-event"
import { Modelv2 } from "@/v2/model"
import { AgentAttachment, FileAttachment, Source } from "@/v2/session-prompt"
import { AgentAttachment, FileAttachment, ReferenceAttachment, Source } from "@/v2/session-prompt"
import { Reference } from "@/reference/reference"
import * as DateTime from "effect/DateTime"
import { eq } from "@/storage/db"
import * as Database from "@/storage/db"
@@ -65,6 +66,9 @@ import { SessionTable } from "./session.sql"
// @ts-ignore
globalThis.AI_SDK_LOG_WARNINGS = false
const decodeMessageInfo = Schema.decodeUnknownExit(MessageV2.Info)
const decodeMessagePart = Schema.decodeUnknownExit(MessageV2.Part)
const STRUCTURED_OUTPUT_DESCRIPTION = `Use this tool to return your final response in the requested structured format.
IMPORTANT:
@@ -78,6 +82,45 @@ const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested struc
const log = Log.create({ service: "session.prompt" })
const elog = EffectLogger.create({ service: "session.prompt" })
type ReferencePromptMetadata = {
name: string
kind: "local" | "git" | "invalid"
path?: string
repository?: string
branch?: string
target?: string
targetPath?: string
problem?: string
source: { value: string; start: number; end: number }
}
function stringField(record: Record<string, unknown>, key: string) {
return typeof record[key] === "string" ? record[key] : undefined
}
function referencePromptMetadata(input: unknown): ReferencePromptMetadata | undefined {
if (!input || typeof input !== "object" || Array.isArray(input)) return
const record = input as Record<string, unknown>
const name = stringField(record, "name")
const kind = stringField(record, "kind")
if (!name || (kind !== "local" && kind !== "git" && kind !== "invalid")) return
if (!record.source || typeof record.source !== "object" || Array.isArray(record.source)) return
const source = record.source as Record<string, unknown>
const value = stringField(source, "value")
if (!value || typeof source.start !== "number" || typeof source.end !== "number") return
return {
name,
kind,
path: stringField(record, "path"),
repository: stringField(record, "repository"),
branch: stringField(record, "branch"),
target: stringField(record, "target"),
targetPath: stringField(record, "targetPath"),
problem: stringField(record, "problem"),
source: { value, start: source.start, end: source.end },
}
}
export interface Interface {
readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
readonly prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts>
@@ -116,6 +159,7 @@ export const layer = Layer.effect(
const summary = yield* SessionSummary.Service
const sys = yield* SystemPrompt.Service
const llm = yield* LLM.Service
const references = yield* Reference.Service
const sync = yield* SyncEvent.Service
const runner = Effect.fn("SessionPrompt.runner")(function* () {
return yield* EffectBridge.make()
@@ -138,12 +182,116 @@ export const layer = Layer.effect(
const parts: Types.DeepMutable<PromptInput["parts"]> = [{ type: "text", text: template }]
const files = ConfigMarkdown.files(template)
const seen = new Set<string>()
const mentionSource = (match: RegExpMatchArray) => {
const start = match.index ?? 0
return { value: match[0], start, end: start + match[0].length }
}
const referenceTextPart = (input: {
reference: Reference.Resolved
source: ReturnType<typeof mentionSource>
target?: string
targetPath?: string
problem?: string
}): MessageV2.TextPartInput => {
const metadata: ReferencePromptMetadata = {
name: input.reference.name,
kind: input.reference.kind,
...(input.reference.kind === "invalid"
? { repository: input.reference.repository }
: { path: input.reference.path }),
...(input.reference.kind === "git"
? { repository: input.reference.repository, branch: input.reference.branch }
: {}),
...(input.target === undefined ? {} : { target: input.target }),
...(input.targetPath ? { targetPath: input.targetPath } : {}),
problem: input.problem ?? (input.reference.kind === "invalid" ? input.reference.message : undefined),
source: input.source,
}
const label = metadata.target === undefined ? `@${metadata.name}` : `@${metadata.name}/${metadata.target}`
return {
type: "text",
synthetic: true,
text: [
`Referenced configured reference ${label}.`,
...(metadata.kind === "local" ? ["Kind: local directory"] : []),
...(metadata.kind === "git" ? ["Kind: git repository"] : []),
...(metadata.repository ? [`Repository: ${metadata.repository}`] : []),
...(metadata.branch ? [`Branch/ref: ${metadata.branch}`] : []),
...(metadata.path ? [`Reference root: ${metadata.path}`] : []),
...(metadata.targetPath ? [`Resolved path: ${metadata.targetPath}`] : []),
...(metadata.problem
? [`Problem: ${metadata.problem}`]
: [
"For targeted context, inspect the reference path directly with Read, Glob, and Grep. For broader research, call the task tool with subagent scout and include this reference path.",
]),
].join("\n"),
metadata: { reference: metadata },
}
}
yield* Effect.forEach(
files,
Effect.fnUntraced(function* (match) {
const name = match[1]
if (!name) return
if (seen.has(name)) return
seen.add(name)
const slash = name.indexOf("/")
const alias = slash === -1 ? name : name.slice(0, slash)
const reference = yield* references.get(alias)
if (reference) {
const source = mentionSource(match)
if (reference.kind === "invalid") {
parts.push(
referenceTextPart({ reference, source, target: slash === -1 ? undefined : name.slice(slash + 1) }),
)
return
}
yield* references.ensure(reference.path)
if (slash === -1) {
parts.push(referenceTextPart({ reference, source }))
return
}
const target = name.slice(slash + 1)
const targetPath = path.resolve(reference.path, target)
if (!AppFileSystem.contains(reference.path, targetPath)) {
parts.push(
referenceTextPart({
reference,
source,
target,
targetPath,
problem: `Path escapes configured reference @${alias}: ${target}`,
}),
)
return
}
const info = yield* fsys.stat(targetPath).pipe(Effect.option)
if (Option.isNone(info)) {
parts.push(
referenceTextPart({
reference,
source,
target,
targetPath,
problem: `Path does not exist inside configured reference @${alias}: ${target}`,
}),
)
return
}
parts.push({
type: "file",
url: pathToFileURL(targetPath).href,
filename: name,
mime: info.value.type === "Directory" ? "application/x-directory" : "text/plain",
})
return
}
const filepath = name.startsWith("~/")
? path.join(os.homedir(), name.slice(2))
: path.resolve(ctx.worktree, name)
@@ -1292,26 +1440,26 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const parts = resolvedParts
const parsed = MessageV2.Info.zod.safeParse(info)
if (!parsed.success) {
const parsed = decodeMessageInfo(info, { errors: "all", propertyOrder: "original" })
if (Exit.isFailure(parsed)) {
log.error("invalid user message before save", {
sessionID: input.sessionID,
messageID: info.id,
agent: info.agent,
model: info.model,
issues: parsed.error.issues,
cause: Cause.pretty(parsed.cause),
})
}
parts.forEach((part, index) => {
const p = MessageV2.Part.zod.safeParse(part)
if (p.success) return
const p = decodeMessagePart(part, { errors: "all", propertyOrder: "original" })
if (Exit.isSuccess(p)) return
log.error("invalid user part before save", {
sessionID: input.sessionID,
messageID: info.id,
partID: part.id,
partType: part.type,
index,
issues: p.error.issues,
cause: Cause.pretty(p.cause),
part,
})
})
@@ -1323,6 +1471,26 @@ NOTE: At any point in time through this workflow you should feel free to ask the
if (part.type === "text") {
if (part.synthetic) result.synthetic.push(part.text)
else result.text.push(part.text)
const reference = referencePromptMetadata(part.metadata?.reference)
if (reference) {
result.references.push(
new ReferenceAttachment({
name: reference.name,
kind: reference.kind,
uri: reference.path ? pathToFileURL(reference.path).href : undefined,
repository: reference.repository,
branch: reference.branch,
target: reference.target,
targetUri: reference.targetPath ? pathToFileURL(reference.targetPath).href : undefined,
problem: reference.problem,
source: new Source({
start: reference.source.start,
end: reference.source.end,
text: reference.source.value,
}),
}),
)
}
}
if (part.type === "file") {
result.files.push(
@@ -1360,6 +1528,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
text: [] as string[],
files: [] as FileAttachment[],
agents: [] as AgentAttachment[],
references: [] as ReferenceAttachment[],
synthetic: [] as string[],
},
)
@@ -1372,6 +1541,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
text: nextPrompt.text.join("\n"),
files: nextPrompt.files,
agents: nextPrompt.agents,
references: nextPrompt.references,
},
})
}
@@ -1814,6 +1984,7 @@ export const defaultLayer = Layer.suspend(() =>
Agent.defaultLayer,
SystemPrompt.defaultLayer,
LLM.defaultLayer,
Reference.defaultLayer,
Bus.layer,
CrossSpawnSpawner.defaultLayer,
SyncEvent.defaultLayer,
+14 -29
View File
@@ -10,7 +10,6 @@ import { EventID } from "./schema"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Context, Effect, Layer, Schema as EffectSchema } from "effect"
import type { DeepMutable } from "@opencode-ai/core/schema"
import { makeRuntime } from "@/effect/run-service"
import { serviceUse } from "@/effect/service-use"
import { InstanceState } from "@/effect/instance-state"
@@ -63,6 +62,7 @@ export interface Interface {
options?: { publish: boolean; ownerID?: string },
) => Effect.Effect<string | undefined>
readonly remove: (aggregateID: string) => Effect.Effect<void>
readonly claim: (aggregateID: string, ownerID: string) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SyncEvent") {}
@@ -175,11 +175,24 @@ export const layer = Layer.effect(Service)(
})
})
const claim: Interface["claim"] = Effect.fn("SyncEvent.claim")((aggregateID, ownerID) =>
Effect.sync(() =>
Database.use((db) =>
db
.update(EventSequenceTable)
.set({ owner_id: ownerID })
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.run(),
),
),
)
return Service.of({
run,
replay,
replayAll,
remove,
claim,
})
}),
)
@@ -188,8 +201,6 @@ export const defaultLayer = layer
export const use = serviceUse(Service)
const runtime = makeRuntime(Service, defaultLayer)
export const registry = new Map<string, Definition>()
let projectors: Map<Definition, ProjectorFunc> | undefined
const versions = new Map<string, number>()
@@ -336,32 +347,6 @@ function process<Def extends Definition>(
})
}
export function replay(event: SerializedEvent, options?: { publish: boolean; ownerID?: string }) {
return runtime.runSync((sync) => sync.replay(event, options))
}
export function replayAll(events: SerializedEvent[], options?: { publish: boolean; ownerID?: string }) {
return runtime.runSync((sync) => sync.replayAll(events, options))
}
export function run<Def extends Definition>(def: Def, data: Event<Def>["data"], options?: { publish?: boolean }) {
return runtime.runSync((sync) => sync.run(def, data, options))
}
export function remove(aggregateID: string) {
return runtime.runSync((sync) => sync.remove(aggregateID))
}
export function claim(aggregateID: string, ownerID: string) {
Database.use((db) =>
db
.update(EventSequenceTable)
.set({ owner_id: ownerID })
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.run(),
)
}
export function effectPayloads() {
return registry
.entries()
-21
View File
@@ -1,21 +0,0 @@
import { z } from "zod"
export function fn<T extends z.ZodType, Result>(schema: T, cb: (input: z.infer<T>) => Result) {
const result = (input: z.infer<T>) => {
let parsed
try {
parsed = schema.parse(input)
} catch (e) {
console.trace("schema validation failure stack trace:")
if (e instanceof z.ZodError) {
console.error("schema validation issues:", JSON.stringify(e.issues, null, 2))
}
throw e
}
return cb(parsed)
}
result.force = (input: z.infer<T>) => cb(input)
result.schema = schema
return result
}
@@ -1,13 +0,0 @@
import z from "zod"
export function updateSchema<T extends z.ZodRawShape>(schema: z.ZodObject<T>) {
const next = {} as {
[K in keyof T]: z.ZodOptional<z.ZodNullable<T[K]>>
}
for (const [k, v] of Object.entries(schema.required().shape) as [keyof T & string, z.ZodTypeAny][]) {
next[k] = v.nullable() as unknown as (typeof next)[typeof k]
}
return z.object(next)
}
@@ -123,6 +123,7 @@ export function update<Result>(adapter: Adapter<Result>, event: SessionEvent.Eve
text: event.data.prompt.text,
files: event.data.prompt.files,
agents: event.data.prompt.agents,
references: event.data.prompt.references,
time: { created: event.data.timestamp },
}),
)
@@ -34,6 +34,7 @@ export class User extends Schema.Class<User>("Session.Message.User")({
text: Prompt.fields.text,
files: Prompt.fields.files,
agents: Prompt.fields.agents,
references: Prompt.fields.references,
type: Schema.Literal("user"),
time: Schema.Struct({
created: V2Schema.DateTimeUtcFromMillis,
@@ -29,8 +29,21 @@ export class AgentAttachment extends Schema.Class<AgentAttachment>("Prompt.Agent
source: Source.pipe(Schema.optional),
}) {}
export class ReferenceAttachment extends Schema.Class<ReferenceAttachment>("Prompt.ReferenceAttachment")({
name: Schema.String,
kind: Schema.Literals(["local", "git", "invalid"]),
uri: Schema.String.pipe(Schema.optional),
repository: Schema.String.pipe(Schema.optional),
branch: Schema.String.pipe(Schema.optional),
target: Schema.String.pipe(Schema.optional),
targetUri: Schema.String.pipe(Schema.optional),
problem: Schema.String.pipe(Schema.optional),
source: Source.pipe(Schema.optional),
}) {}
export class Prompt extends Schema.Class<Prompt>("Prompt")({
text: Schema.String,
files: Schema.Array(FileAttachment).pipe(Schema.optional),
agents: Schema.Array(AgentAttachment).pipe(Schema.optional),
references: Schema.Array(ReferenceAttachment).pipe(Schema.optional),
}) {}
+8 -44
View File
@@ -141,16 +141,12 @@ test("scout agent allows repo cloning and repo cache reads", async () => {
})
})
test("reference config creates scout-backed subagents", async () => {
test("reference config does not create subagents", async () => {
await withExperimentalScout(true, async () => {
await using tmp = await tmpdir({
config: {
reference: {
effect: "github.com/effect/effect-smol",
effectDev: {
repository: "https://github.com/effect/effect-smol",
branch: "dev",
},
effectFull: {
repository: "Effect-TS/effect",
branch: "main",
@@ -165,45 +161,13 @@ test("reference config creates scout-backed subagents", async () => {
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const effect = await load(tmp.path, (svc) => svc.get("effect"))
const effectDev = await load(tmp.path, (svc) => svc.get("effectDev"))
const effectFull = await load(tmp.path, (svc) => svc.get("effectFull"))
const local = await load(tmp.path, (svc) => svc.get("localdocs"))
const localFull = await load(tmp.path, (svc) => svc.get("localdocsFull"))
expect(effect).toBeDefined()
expect(effect?.mode).toBe("subagent")
expect(effect?.prompt).toContain("Repository: github.com/effect/effect-smol")
expect(effect?.prompt).toContain(
`Cached directory: ${path.join(Global.Path.repos, "github.com", "effect", "effect-smol")}`,
)
expect(effect?.prompt).toContain("Do not call repo_clone")
expect(evalPerm(effect, "repo_clone")).toBe("deny")
expect(effectDev).toBeDefined()
expect(effectDev?.prompt).toContain("Problem: Reference conflicts with @effect")
expect(effectDev?.prompt).not.toContain("Cached directory:")
expect(effectFull).toBeDefined()
expect(effectFull?.mode).toBe("subagent")
expect(effectFull?.prompt).toContain("Repository: Effect-TS/effect")
expect(effectFull?.prompt).toContain("Branch/ref: main")
expect(evalPerm(effectFull, "repo_clone")).toBe("deny")
expect(local).toBeDefined()
expect(local?.mode).toBe("subagent")
expect(local?.prompt).toContain(`Local directory: ${path.resolve(tmp.path, "../docs")}`)
expect(
Permission.evaluate(
"external_directory",
path.join(path.resolve(tmp.path, "../docs"), "README.md"),
local!.permission,
).action,
).toBe("allow")
expect(localFull).toBeDefined()
expect(localFull?.mode).toBe("subagent")
expect(localFull?.prompt).toContain(`Local directory: ${path.resolve(tmp.path, "../local-docs")}`)
const agents = await load(tmp.path, (svc) => svc.list())
const names = agents.map((agent) => agent.name)
expect(names).toContain("scout")
expect(names).not.toContain("effect")
expect(names).not.toContain("effectFull")
expect(names).not.toContain("localdocs")
expect(names).not.toContain("localdocsFull")
},
})
})
@@ -356,7 +356,6 @@ describe("session HttpApi", () => {
const forked = yield* requestJson<Session.Info>(pathFor(SessionPaths.fork, { sessionID: created.id }), {
method: "POST",
headers,
body: JSON.stringify({}),
})
expect(forked.id).not.toBe(created.id)
@@ -926,12 +926,12 @@ describe("session.compaction.process", () => {
)
itCompaction.instance(
"persists tail_start_id for retained recent turns",
"does not persist tail_start_id for serialized recent turns",
Effect.gen(function* () {
const ssn = yield* SessionNs.Service
const session = yield* ssn.create({})
yield* createUserMessage(session.id, "first")
const keep = yield* createUserMessage(session.id, "second")
yield* createUserMessage(session.id, "second")
yield* createUserMessage(session.id, "third")
yield* createSummaryCompaction(session.id)
@@ -947,18 +947,18 @@ describe("session.compaction.process", () => {
const part = yield* readCompactionPart(session.id)
expect(part?.type).toBe("compaction")
expect(part?.tail_start_id).toBe(keep.id)
expect(part?.tail_start_id).toBeUndefined()
}).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }) })),
)
itCompaction.instance(
"shrinks retained tail to fit preserve token budget",
"does not persist tail_start_id when shrinking serialized tail",
Effect.gen(function* () {
const ssn = yield* SessionNs.Service
const session = yield* ssn.create({})
yield* createUserMessage(session.id, "first")
yield* createUserMessage(session.id, "x".repeat(2_000))
const keep = yield* createUserMessage(session.id, "tiny")
yield* createUserMessage(session.id, "tiny")
yield* createSummaryCompaction(session.id)
const msgs = yield* ssn.messages({ sessionID: session.id })
@@ -973,7 +973,7 @@ describe("session.compaction.process", () => {
const part = yield* readCompactionPart(session.id)
expect(part?.type).toBe("compaction")
expect(part?.tail_start_id).toBe(keep.id)
expect(part?.tail_start_id).toBeUndefined()
}).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 100 }) })),
)
@@ -1005,7 +1005,7 @@ describe("session.compaction.process", () => {
)
itCompaction.instance(
"falls back to full summary when retained tail media exceeds preserve token budget",
"serializes retained tail media as text in the summary input",
() => {
const stub = llm()
let captured = ""
@@ -1078,15 +1078,16 @@ describe("session.compaction.process", () => {
const part = yield* readCompactionPart(session.id)
expect(part?.type).toBe("compaction")
expect(part?.tail_start_id).toBe(keep.id)
expect(part?.tail_start_id).toBeUndefined()
expect(captured).toContain("zzzz")
expect(captured).not.toContain("keep tail")
expect(captured).toContain("keep tail")
const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id))
expect(filtered.map((msg) => msg.info.id).slice(0, 3)).toEqual([parent!, expect.any(String), keep.id])
expect(filtered.map((msg) => msg.info.id)).toEqual([parent!, expect.any(String)])
expect(filtered[1]?.info.role).toBe("assistant")
expect(filtered[1]?.info.role === "assistant" ? filtered[1].info.summary : false).toBe(true)
expect(filtered.map((msg) => msg.info.id)).not.toContain(large.id)
expect(filtered.map((msg) => msg.info.id)).not.toContain(keep.id)
}).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) }))
},
{ git: true },
@@ -1353,13 +1354,13 @@ describe("session.compaction.process", () => {
)
itCompaction.instance(
"summarizes only the head while keeping recent tail out of summary input",
"summarizes the head while serializing recent tail into summary input",
() => {
const stub = llm()
let captured = ""
let captured: LLM.StreamInput["messages"] = []
stub.push(
reply("summary", (input) => {
captured = JSON.stringify(input.messages)
captured = input.messages
}),
)
return Effect.gen(function* () {
@@ -1380,10 +1381,15 @@ describe("session.compaction.process", () => {
auto: false,
})
expect(captured).toContain("older context")
expect(captured).not.toContain("keep this turn")
expect(captured).not.toContain("and this one too")
expect(captured).not.toContain("What did we do so far?")
const head = JSON.stringify(captured.slice(0, -1))
const prompt = JSON.stringify(captured.at(-1))
expect(head).toContain("older context")
expect(head).not.toContain("keep this turn")
expect(head).not.toContain("and this one too")
expect(prompt).toContain("keep this turn")
expect(prompt).toContain("and this one too")
expect(prompt).toContain("recent-conversation-tail")
expect(prompt).not.toContain("What did we do so far?")
}).pipe(withCompaction({ llm: stub.layer }))
},
{ git: true },
@@ -1431,7 +1437,7 @@ describe("session.compaction.process", () => {
{ git: true },
)
itCompaction.instance("keeps recent pre-compaction turns across repeated compactions", () => {
itCompaction.instance("does not replay recent pre-compaction turns across repeated compactions", () => {
const stub = llm()
stub.push(reply("summary one"))
stub.push(reply("summary two"))
@@ -1462,8 +1468,8 @@ describe("session.compaction.process", () => {
expect(ids).not.toContain(u1.id)
expect(ids).not.toContain(u2.id)
expect(ids).toContain(u3.id)
expect(ids).toContain(u4.id)
expect(ids).not.toContain(u3.id)
expect(ids).not.toContain(u4.id)
expect(filtered.some((msg) => msg.info.role === "assistant" && msg.info.summary)).toBe(true)
expect(
filtered.some((msg) => msg.info.role === "user" && msg.parts.some((part) => part.type === "compaction")),
@@ -1472,7 +1478,7 @@ describe("session.compaction.process", () => {
})
itCompaction.instance(
"ignores previous summaries when sizing the retained tail",
"ignores previous summaries when sizing the serialized tail",
Effect.gen(function* () {
const ssn = yield* SessionNs.Service
const test = yield* TestInstance
@@ -1511,7 +1517,7 @@ describe("session.compaction.process", () => {
const part = yield* readCompactionPart(session.id)
expect(part?.type).toBe("compaction")
expect(part?.tail_start_id).toBe(keep.id)
expect(part?.tail_start_id).toBeUndefined()
}).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 500 }) })),
)
})
@@ -785,7 +785,7 @@ describe("MessageV2.filterCompacted", () => {
})
})
test("retains original tail when compaction stores tail_start_id", async () => {
test("ignores original tail when compaction stores tail_start_id", async () => {
await WithInstance.provide({
directory: root,
fn: async () => {
@@ -834,14 +834,14 @@ describe("MessageV2.filterCompacted", () => {
const result = MessageV2.filterCompacted(MessageV2.stream(session.id))
expect(result.map((item) => item.info.id)).toEqual([c1, s1, u2, a2, u3, a3])
expect(result.map((item) => item.info.id)).toEqual([c1, s1, u3, a3])
await svc.remove(session.id)
},
})
})
test("fork remaps compaction tail_start_id for filterCompacted", async () => {
test("fork keeps legacy tail_start_id without replaying the tail", async () => {
await WithInstance.provide({
directory: root,
fn: async () => {
@@ -889,7 +889,7 @@ describe("MessageV2.filterCompacted", () => {
})
const parentFiltered = MessageV2.filterCompacted(MessageV2.stream(session.id))
expect(parentFiltered.map((item) => item.info.id)).toEqual([c1, s1, u2, a2, u3, a3])
expect(parentFiltered.map((item) => item.info.id)).toEqual([c1, s1, u3, a3])
const forked = await svc.fork({ sessionID: session.id })
const childFiltered = MessageV2.filterCompacted(MessageV2.stream(forked.id))
@@ -899,7 +899,7 @@ describe("MessageV2.filterCompacted", () => {
expect(tailPart?.type).toBe("compaction")
if (!tailPart || tailPart.type !== "compaction") throw new Error("Expected forked compaction part")
expect(tailPart.tail_start_id).toBeDefined()
expect(childFiltered.some((m) => m.info.id === tailPart.tail_start_id)).toBe(true)
expect(childFiltered.some((m) => m.info.id === tailPart.tail_start_id)).toBe(false)
await svc.remove(forked.id)
await svc.remove(session.id)
@@ -907,7 +907,7 @@ describe("MessageV2.filterCompacted", () => {
})
})
test("retains an assistant tail when compaction starts inside a turn", async () => {
test("does not replay an assistant tail when compaction starts inside a turn", async () => {
await WithInstance.provide({
directory: root,
fn: async () => {
@@ -964,7 +964,7 @@ describe("MessageV2.filterCompacted", () => {
const result = MessageV2.filterCompacted(MessageV2.stream(session.id))
expect(result.map((item) => item.info.id)).toEqual([c1, s1, a3, u3, a4])
expect(result.map((item) => item.info.id)).toEqual([c1, s1, u3, a4])
await svc.remove(session.id)
},
@@ -1041,7 +1041,7 @@ describe("MessageV2.filterCompacted", () => {
const result = MessageV2.filterCompacted(MessageV2.stream(session.id))
expect(result.map((item) => item.info.id)).toEqual([c2, s2, u3, a3, u4, a4])
expect(result.map((item) => item.info.id)).toEqual([c2, s2, u4, a4])
await svc.remove(session.id)
},
@@ -2,6 +2,7 @@ import { NodeFileSystem } from "@effect/platform-node"
import { FetchHttpClient } from "effect/unstable/http"
import { expect } from "bun:test"
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
import fs from "fs/promises"
import path from "path"
import { fileURLToPath } from "url"
import { NamedError } from "@opencode-ai/core/util/error"
@@ -203,6 +204,7 @@ function makeHttp() {
SessionPrompt.layer.pipe(
Layer.provide(SessionRevert.defaultLayer),
Layer.provide(Image.defaultLayer),
Layer.provide(Reference.defaultLayer),
Layer.provide(summary),
Layer.provideMerge(run),
Layer.provideMerge(compact),
@@ -1791,6 +1793,102 @@ it.live("keeps stored part order stable when file resolution is async", () =>
),
)
it.live("resolves configured reference mentions before workspace paths and agents", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const docs = path.join(dir, "external-docs")
yield* Effect.promise(() => fs.mkdir(path.join(docs, "guide"), { recursive: true }))
yield* Effect.promise(() => fs.mkdir(path.join(dir, "docs"), { recursive: true }))
yield* Effect.promise(() => Bun.write(path.join(docs, "README.md"), "reference readme"))
yield* Effect.promise(() => Bun.write(path.join(docs, "guide", "intro.md"), "reference intro"))
yield* Effect.promise(() => Bun.write(path.join(dir, "docs", "README.md"), "workspace readme"))
const prompt = yield* SessionPrompt.Service
const parts = yield* prompt.resolvePromptParts(
"Use @docs and @docs/README.md and @docs/guide and @docs/missing.md and @docs/README.md and @build",
)
const references = parts.filter(
(part): part is MessageV2.TextPartInput =>
part.type === "text" && part.synthetic === true && part.text.startsWith("Referenced configured reference "),
)
const files = parts.filter((part): part is MessageV2.FilePartInput => part.type === "file")
const agents = parts.filter((part): part is MessageV2.AgentPartInput => part.type === "agent")
const bare = references.find((part) => part.text.includes("@docs."))
const missing = references.find((part) => part.text.includes("@docs/missing.md"))
const guide = files.find((part) => part.filename === "docs/guide")
expect(references.length).toBe(2)
expect(bare?.metadata?.reference).toMatchObject({
name: "docs",
kind: "local",
path: docs,
})
expect(missing?.text).toContain("Path does not exist inside configured reference @docs")
expect(missing?.metadata?.reference).toMatchObject({
target: "missing.md",
targetPath: path.join(docs, "missing.md"),
})
expect(files.length).toBe(2)
expect(files.map((file) => fileURLToPath(file.url)).sort()).toEqual(
[path.join(docs, "README.md"), path.join(docs, "guide")].sort(),
)
expect(guide?.mime).toBe("application/x-directory")
expect(agents.map((agent) => agent.name)).toEqual(["build"])
}),
{
git: true,
config: {
...cfg,
reference: {
docs: "./external-docs",
},
},
},
),
)
it.live("injects metadata for bare configured reference mentions", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const docs = path.join(dir, "external-docs")
yield* Effect.promise(() => fs.mkdir(docs, { recursive: true }))
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({})
const message = yield* prompt.prompt({
sessionID: session.id,
noReply: true,
parts: yield* prompt.resolvePromptParts("Use @docs for context"),
})
const stored = MessageV2.get({ sessionID: session.id, messageID: message.info.id })
const synthetic = stored.parts.filter(
(part): part is MessageV2.TextPart => part.type === "text" && part.synthetic === true,
)
const reference = synthetic.find((part) => part.text.startsWith("Referenced configured reference @docs."))
expect(reference?.metadata?.reference).toMatchObject({ name: "docs", kind: "local", path: docs })
expect(synthetic.some((part) => part.text.includes(`Reference root: ${docs}`))).toBe(true)
expect(synthetic.some((part) => part.text.includes("subagent scout"))).toBe(true)
yield* sessions.remove(session.id)
}),
{
git: true,
config: {
...cfg,
reference: {
docs: "./external-docs",
},
},
},
),
)
// Special characters in filenames
it.live("handles filenames with # character", () =>
@@ -154,6 +154,7 @@ function makeHttp() {
SessionPrompt.layer.pipe(
Layer.provide(SessionRevert.defaultLayer),
Layer.provide(Image.defaultLayer),
Layer.provide(Reference.defaultLayer),
Layer.provide(SessionSummary.defaultLayer),
Layer.provideMerge(run),
Layer.provideMerge(compact),
+24 -1
View File
@@ -4,7 +4,7 @@ import { Effect, Layer, Schema } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Bus } from "../../src/bus"
import { SyncEvent } from "../../src/sync"
import { Database } from "@/storage/db"
import { Database, eq } from "@/storage/db"
import { EventSequenceTable, EventTable } from "../../src/sync/event.sql"
import { MessageID } from "../../src/session/schema"
import { Flag } from "@opencode-ai/core/flag/flag"
@@ -323,5 +323,28 @@ describe("SyncEvent", () => {
}),
),
)
it.live(
"claim updates the event sequence owner",
provideTmpdirInstance(() =>
Effect.gen(function* () {
const { Created } = setup()
const id = MessageID.ascending()
yield* SyncEvent.use.run(Created, { id, name: "claimed" }, { publish: false })
yield* SyncEvent.use.claim(id, "owner-1")
yield* SyncEvent.use.claim(id, "owner-2")
const row = Database.use((db) =>
db
.select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, id))
.get(),
)
expect(row).toEqual({ seq: 0, ownerID: "owner-2" })
}),
),
)
})
})
+18
View File
@@ -771,6 +771,7 @@ export type Prompt = {
text: string
files?: Array<PromptFileAttachment>
agents?: Array<PromptAgentAttachment>
references?: Array<PromptReferenceAttachment>
}
export type GlobalEvent = {
@@ -2718,6 +2719,18 @@ export type PromptAgentAttachment = {
source?: PromptSource
}
export type PromptReferenceAttachment = {
name: string
kind: "local" | "git" | "invalid"
uri?: string
repository?: string
branch?: string
target?: string
targetUri?: string
problem?: string
source?: PromptSource
}
export type EventSessionNextPrompted = {
id: string
type: "session.next.prompted"
@@ -3121,6 +3134,7 @@ export type SessionMessageUser = {
text: string
files?: Array<PromptFileAttachment>
agents?: Array<PromptAgentAttachment>
references?: Array<PromptReferenceAttachment>
type: "user"
}
@@ -5590,6 +5604,10 @@ export type SessionForkData = {
}
export type SessionForkErrors = {
/**
* Bad request
*/
400: BadRequestError
/**
* NotFoundError
*/
+57
View File
@@ -5536,6 +5536,16 @@
}
}
},
"400": {
"description": "Bad request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BadRequestError"
}
}
}
},
"404": {
"description": "NotFoundError",
"content": {
@@ -11110,6 +11120,12 @@
"items": {
"$ref": "#/components/schemas/PromptAgentAttachment"
}
},
"references": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PromptReferenceAttachment"
}
}
},
"required": ["text"],
@@ -17029,6 +17045,41 @@
"required": ["name"],
"additionalProperties": false
},
"PromptReferenceAttachment": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"kind": {
"type": "string",
"enum": ["local", "git", "invalid"]
},
"uri": {
"type": "string"
},
"repository": {
"type": "string"
},
"branch": {
"type": "string"
},
"target": {
"type": "string"
},
"targetUri": {
"type": "string"
},
"problem": {
"type": "string"
},
"source": {
"$ref": "#/components/schemas/PromptSource"
}
},
"required": ["name", "kind"],
"additionalProperties": false
},
"EventSessionNextPrompted": {
"type": "object",
"properties": {
@@ -18221,6 +18272,12 @@
"$ref": "#/components/schemas/PromptAgentAttachment"
}
},
"references": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PromptReferenceAttachment"
}
},
"type": {
"type": "string",
"enum": ["user"]
+36 -15
View File
@@ -264,6 +264,7 @@ function getDirectory(path: string | undefined) {
}
import type { IconProps } from "./icon"
import { normalize } from "./session-diff"
export type ToolInfo = {
icon: IconProps["name"]
@@ -1461,7 +1462,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
const streaming = createMemo(
() => props.message.role === "assistant" && typeof (props.message as AssistantMessage).time.completed !== "number",
)
const text = () => (part().text ?? "").trim()
const text = () => (data.store.part_text_accum_delta?.[part().id] ?? part().text ?? "").trim()
const isLastTextPart = createMemo(() => {
const last = (data.store.part?.[props.message.id] ?? [])
.filter((item): item is TextPart => item?.type === "text" && !!item.text?.trim())
@@ -1521,11 +1522,12 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
}
PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) {
const data = useData()
const part = () => props.part as ReasoningPart
const streaming = createMemo(
() => props.message.role === "assistant" && typeof (props.message as AssistantMessage).time.completed !== "number",
)
const text = () => part().text.trim()
const text = () => (data.store.part_text_accum_delta?.[part().id] ?? part().text).trim()
return (
<Show when={text()}>
@@ -1877,6 +1879,31 @@ ToolRegistry.register({
const path = createMemo(() => props.metadata?.filediff?.file || props.input.filePath || "")
const filename = () => getFilename(props.input.filePath ?? "")
const pending = () => props.status === "pending" || props.status === "running"
const fileCompProps = createMemo(() => {
try {
if (props.metadata?.filediff) {
const diff = normalize({
...props.metadata?.filediff,
status: "modified",
})
const fileDiff = diff.fileDiff
if (fileDiff) return { fileDiff, hunkSeparators: fileDiff.isPartial ? "simple" : "line-info-basic" }
}
} catch {}
return {
before: {
name: props.metadata?.filediff?.file || props.input.filePath,
contents: props.metadata?.filediff?.before || props.input.oldString || "",
},
after: {
name: props.metadata?.filediff?.file || props.input.filePath,
contents: props.metadata?.filediff?.after || props.input.newString || "",
},
}
})
return (
<div data-component="edit-tool">
<BasicTool
@@ -1918,18 +1945,7 @@ ToolRegistry.register({
}
>
<div data-component="edit-content">
<Dynamic
component={fileComponent}
mode="diff"
before={{
name: props.metadata?.filediff?.file || props.input.filePath,
contents: props.metadata?.filediff?.before || props.input.oldString || "",
}}
after={{
name: props.metadata?.filediff?.file || props.input.filePath,
contents: props.metadata?.filediff?.after || props.input.newString || "",
}}
/>
<Dynamic component={fileComponent} mode="diff" {...fileCompProps()} />
</div>
</ToolFileAccordion>
</Show>
@@ -2110,7 +2126,12 @@ ToolRegistry.register({
<Accordion.Content>
<Show when={visible()}>
<div data-component="apply-patch-file-diff">
<Dynamic component={fileComponent} mode="diff" fileDiff={file.view.fileDiff} />
<Dynamic
component={fileComponent}
mode="diff"
fileDiff={file.view.fileDiff}
hunkSeparators={file.view.fileDiff.isPartial ? "simple" : "line-info-basic"}
/>
</div>
</Show>
</Accordion.Content>
+13 -5
View File
@@ -1,4 +1,4 @@
import { parseDiffFromFile, type FileDiffMetadata } from "@pierre/diffs"
import { parseDiffFromFile, parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs"
import { formatPatch, parsePatch, structuredPatch } from "diff"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
@@ -34,6 +34,8 @@ function patch(diff: ReviewDiff) {
const afterLines: Array<{ text: string; newline: boolean }> = []
let previous: "-" | "+" | " " | undefined
const patchIsPartial = patch.hunks.every((h) => h.oldStart > 1)
for (const hunk of patch.hunks) {
for (const line of hunk.lines) {
if (line.startsWith("\\")) {
@@ -67,9 +69,10 @@ function patch(diff: ReviewDiff) {
before: beforeLines.map((line) => line.text + (line.newline ? "\n" : "")).join(""),
after: afterLines.map((line) => line.text + (line.newline ? "\n" : "")).join(""),
patch: diff.patch,
patchIsPartial,
}
} catch {
return { before: "", after: "", patch: diff.patch }
return { before: "", after: "", patch: diff.patch, patchIsPartial: false }
}
}
return {
@@ -86,27 +89,32 @@ function patch(diff: ReviewDiff) {
{ context: Number.MAX_SAFE_INTEGER },
),
),
patchIsPartial: false,
}
}
function file(file: string, patch: string, before: string, after: string) {
function file(file: string, patch: string, before: string, after: string, partial = false) {
const hit = cache.get(patch)
if (hit) return hit
const value = parseDiffFromFile({ name: file, contents: before }, { name: file, contents: after })
let value: FileDiffMetadata | undefined
if (partial) value = parsePatchFiles(patch)[0]?.files[0]
if (value === undefined) value = parseDiffFromFile({ name: file, contents: before }, { name: file, contents: after })
cache.set(patch, value)
return value
}
export function normalize(diff: ReviewDiff): ViewDiff {
const next = patch(diff)
const fileDiff = file(diff.file, next.patch, next.before, next.after, next.patchIsPartial)
return {
file: diff.file,
patch: next.patch,
additions: diff.additions,
deletions: diff.deletions,
status: diff.status,
fileDiff: file(diff.file, next.patch, next.before, next.after),
fileDiff,
}
}
+3
View File
@@ -24,6 +24,9 @@ type Data = {
part: {
[messageID: string]: Part[]
}
part_text_accum_delta?: {
[partID: string]: string
}
}
export type NavigateToSessionFn = (sessionID: string) => void