Compare commits

..

1 Commits

Author SHA1 Message Date
Aiden Cline e7b56e8122 fix(ai): normalize loose provider usage 2026-08-05 18:43:02 +00:00
23 changed files with 546 additions and 214 deletions
@@ -237,7 +237,7 @@ export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesB
const AnthropicUsage = Schema.StructWithRest(
Schema.Struct({
input_tokens: Schema.optional(Schema.Number),
input_tokens: optionalNull(Schema.Number),
output_tokens: Schema.optional(Schema.Number),
cache_creation_input_tokens: optionalNull(Schema.Number),
cache_read_input_tokens: optionalNull(Schema.Number),
@@ -684,7 +684,7 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
// expose that subset through `output_tokens_details.thinking_tokens`.
const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => {
if (!usage) return undefined
const nonCached = usage.input_tokens
const nonCached = usage.input_tokens ?? undefined
const cacheRead = usage.cache_read_input_tokens ?? undefined
const cacheWrite = usage.cache_creation_input_tokens ?? undefined
const inputTokens = ProviderShared.sumTokens(nonCached, cacheRead, cacheWrite)
+12 -10
View File
@@ -20,7 +20,7 @@ import {
} from "../schema"
import { BedrockEventStream } from "./bedrock-event-stream"
import { classifyProviderFailure } from "../provider-error"
import { JsonObject, optionalArray, ProviderShared } from "./shared"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
import { BedrockAuth } from "./utils/bedrock-auth"
import { BedrockCache } from "./utils/bedrock-cache"
import { BedrockMedia } from "./utils/bedrock-media"
@@ -150,8 +150,8 @@ const BedrockUsageSchema = Schema.Struct({
inputTokens: Schema.optional(Schema.Number),
outputTokens: Schema.optional(Schema.Number),
totalTokens: Schema.optional(Schema.Number),
cacheReadInputTokens: Schema.optional(Schema.Number),
cacheWriteInputTokens: Schema.optional(Schema.Number),
cacheReadInputTokens: optionalNull(Schema.Number),
cacheWriteInputTokens: optionalNull(Schema.Number),
})
type BedrockUsageSchema = Schema.Schema.Type<typeof BedrockUsageSchema>
@@ -206,9 +206,9 @@ const BedrockEvent = Schema.Struct({
additionalModelResponseFields: Schema.optional(Schema.Unknown),
}),
),
metadata: Schema.optional(
metadata: optionalNull(
Schema.Struct({
usage: Schema.optional(BedrockUsageSchema),
usage: optionalNull(BedrockUsageSchema),
metrics: Schema.optional(Schema.Unknown),
}),
),
@@ -464,19 +464,21 @@ const mapFinishReason = (reason: string): FinishReason => {
// AWS reports inputTokens separately from cache reads and writes.
// Bedrock does not break reasoning out of outputTokens for current models.
const mapUsage = (usage: BedrockUsageSchema | undefined): Usage | undefined => {
const mapUsage = (usage: BedrockUsageSchema | null | undefined): Usage | undefined => {
if (!usage) return undefined
const cacheRead = usage.cacheReadInputTokens ?? undefined
const cacheWrite = usage.cacheWriteInputTokens ?? undefined
const inputTokens = ProviderShared.sumTokens(
usage.inputTokens,
usage.cacheReadInputTokens,
usage.cacheWriteInputTokens,
cacheRead,
cacheWrite,
)
return new Usage({
inputTokens,
outputTokens: usage.outputTokens,
nonCachedInputTokens: usage.inputTokens,
cacheReadInputTokens: usage.cacheReadInputTokens,
cacheWriteInputTokens: usage.cacheWriteInputTokens,
cacheReadInputTokens: cacheRead,
cacheWriteInputTokens: cacheWrite,
totalTokens: ProviderShared.totalTokens(inputTokens, usage.outputTokens, usage.totalTokens),
providerMetadata: { bedrock: usage },
})
@@ -76,12 +76,12 @@ const consumeFrames = (route: string) => (state: FrameBufferState, chunk: Uint8A
// before handing the object to the chunk schema. JSON decode goes
// through the shared Schema-driven codec to satisfy the package rule
// against ad-hoc `JSON.parse` calls.
const parsed = (yield* ProviderShared.parseJson(
const parsed = yield* ProviderShared.parseJson(
route,
payload,
"Failed to parse Bedrock Converse event-stream payload",
)) as Record<string, unknown>
delete parsed.p
)
if (ProviderShared.isRecord(parsed)) delete parsed.p
out.push({ [eventType]: parsed })
}
return [cursor, out] as const
+22 -17
View File
@@ -18,7 +18,7 @@ import {
type ToolCallPart,
type ToolDefinition,
} from "../schema"
import { JsonObject, optionalArray, ProviderShared } from "./shared"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
import { GeminiToolSchema } from "./utils/gemini-tool-schema"
import { Lifecycle } from "./utils/lifecycle"
import { ToolSchemaProjection } from "./utils/tool-schema"
@@ -162,13 +162,16 @@ const GeminiBodyFields = {
const GeminiBody = Schema.Struct(GeminiBodyFields)
export type GeminiBody = Schema.Schema.Type<typeof GeminiBody>
const GeminiUsage = Schema.Struct({
cachedContentTokenCount: Schema.optional(Schema.Number),
thoughtsTokenCount: Schema.optional(Schema.Number),
promptTokenCount: Schema.optional(Schema.Number),
candidatesTokenCount: Schema.optional(Schema.Number),
totalTokenCount: Schema.optional(Schema.Number),
})
const GeminiUsage = Schema.StructWithRest(
Schema.Struct({
cachedContentTokenCount: optionalNull(Schema.Number),
thoughtsTokenCount: optionalNull(Schema.Number),
promptTokenCount: optionalNull(Schema.Number),
candidatesTokenCount: optionalNull(Schema.Number),
totalTokenCount: optionalNull(Schema.Number),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
type GeminiUsage = Schema.Schema.Type<typeof GeminiUsage>
const GeminiCandidate = Schema.Struct({
@@ -178,7 +181,7 @@ const GeminiCandidate = Schema.Struct({
const GeminiEvent = Schema.Struct({
candidates: optionalArray(GeminiCandidate),
usageMetadata: Schema.optional(GeminiUsage),
usageMetadata: optionalNull(GeminiUsage),
})
type GeminiEvent = Schema.Schema.Type<typeof GeminiEvent>
@@ -422,23 +425,25 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
// `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) => {
const mapUsage = (usage: GeminiUsage | null | undefined) => {
if (!usage) return undefined
const cached = usage.cachedContentTokenCount
const nonCached = ProviderShared.subtractTokens(usage.promptTokenCount, cached)
const input = usage.promptTokenCount ?? undefined
const cached = input === undefined ? undefined : (usage.cachedContentTokenCount ?? undefined)
const visible = usage.candidatesTokenCount ?? undefined
const thoughts = visible === undefined ? undefined : (usage.thoughtsTokenCount ?? undefined)
const nonCached = ProviderShared.subtractTokens(input, 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
const outputTokens = visible === undefined ? undefined : visible + (thoughts ?? 0)
return new Usage({
inputTokens: usage.promptTokenCount,
inputTokens: input,
outputTokens,
nonCachedInputTokens: nonCached,
cacheReadInputTokens: cached,
reasoningTokens: usage.thoughtsTokenCount,
totalTokens: ProviderShared.totalTokens(usage.promptTokenCount, outputTokens, usage.totalTokenCount),
reasoningTokens: thoughts,
totalTokens: ProviderShared.totalTokens(input, outputTokens, usage.totalTokenCount ?? undefined),
providerMetadata: { google: usage },
})
}
+8 -6
View File
@@ -183,12 +183,12 @@ const OpenResponsesUsage = Schema.Struct({
input_tokens: Schema.optional(Schema.Number),
input_tokens_details: optionalNull(
Schema.Struct({
cached_tokens: Schema.optional(Schema.Number),
cache_write_tokens: Schema.optional(Schema.Number),
cached_tokens: optionalNull(Schema.Number),
cache_write_tokens: optionalNull(Schema.Number),
}),
),
output_tokens: Schema.optional(Schema.Number),
output_tokens_details: optionalNull(Schema.Struct({ reasoning_tokens: Schema.optional(Schema.Number) })),
output_tokens_details: optionalNull(Schema.Struct({ reasoning_tokens: optionalNull(Schema.Number) })),
total_tokens: Schema.optional(Schema.Number),
})
type OpenResponsesUsage = Schema.Schema.Type<typeof OpenResponsesUsage>
@@ -592,9 +592,11 @@ export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* (req
// non-cached breakdown.
const mapUsage = (usage: OpenResponsesUsage | null | undefined, providerMetadataKey: string) => {
if (!usage) return undefined
const cached = usage.input_tokens_details?.cached_tokens
const cacheWrite = usage.input_tokens_details?.cache_write_tokens
const reasoning = usage.output_tokens_details?.reasoning_tokens
const cached = usage.input_tokens === undefined ? undefined : (usage.input_tokens_details?.cached_tokens ?? undefined)
const cacheWrite =
usage.input_tokens === undefined ? undefined : (usage.input_tokens_details?.cache_write_tokens ?? undefined)
const reasoning =
usage.output_tokens === undefined ? undefined : (usage.output_tokens_details?.reasoning_tokens ?? undefined)
const nonCached = ProviderShared.subtractTokens(usage.input_tokens, ProviderShared.sumTokens(cached, cacheWrite))
return new Usage({
inputTokens: usage.input_tokens,
+23 -20
View File
@@ -146,18 +146,18 @@ export type OpenAIChatBody = Schema.Schema.Type<typeof OpenAIChatBody>
// byte stream into strings, then `Protocol.jsonEvent` decodes each string into
// this provider-native event shape.
const OpenAIChatUsage = Schema.Struct({
prompt_tokens: Schema.optional(Schema.Number),
completion_tokens: Schema.optional(Schema.Number),
total_tokens: Schema.optional(Schema.Number),
prompt_tokens: optionalNull(Schema.Number),
completion_tokens: optionalNull(Schema.Number),
total_tokens: optionalNull(Schema.Number),
prompt_tokens_details: optionalNull(
Schema.Struct({
cached_tokens: Schema.optional(Schema.Number),
cache_write_tokens: Schema.optional(Schema.Number),
cached_tokens: optionalNull(Schema.Number),
cache_write_tokens: optionalNull(Schema.Number),
}),
),
completion_tokens_details: optionalNull(
Schema.Struct({
reasoning_tokens: Schema.optional(Schema.Number),
reasoning_tokens: optionalNull(Schema.Number),
}),
),
})
@@ -168,7 +168,7 @@ const OpenAIChatToolCallDeltaFunction = Schema.Struct({
})
const OpenAIChatToolCallDelta = Schema.Struct({
index: Schema.Number,
index: optionalNull(Schema.Number),
id: optionalNull(Schema.String),
function: optionalNull(OpenAIChatToolCallDeltaFunction),
})
@@ -559,18 +559,20 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
// satisfied on both sides.
const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
if (!usage) return undefined
const cached = usage.prompt_tokens_details?.cached_tokens
const cacheWrite = usage.prompt_tokens_details?.cache_write_tokens
const reasoning = usage.completion_tokens_details?.reasoning_tokens
const nonCached = ProviderShared.subtractTokens(usage.prompt_tokens, ProviderShared.sumTokens(cached, cacheWrite))
const input = usage.prompt_tokens ?? undefined
const output = usage.completion_tokens ?? undefined
const cached = input === undefined ? undefined : (usage.prompt_tokens_details?.cached_tokens ?? undefined)
const cacheWrite = input === undefined ? undefined : (usage.prompt_tokens_details?.cache_write_tokens ?? undefined)
const reasoning = output === undefined ? undefined : (usage.completion_tokens_details?.reasoning_tokens ?? undefined)
const nonCached = ProviderShared.subtractTokens(input, ProviderShared.sumTokens(cached, cacheWrite))
return new Usage({
inputTokens: usage.prompt_tokens,
outputTokens: usage.completion_tokens,
inputTokens: input,
outputTokens: output,
nonCachedInputTokens: nonCached,
cacheReadInputTokens: cached,
cacheWriteInputTokens: cacheWrite,
reasoningTokens: reasoning,
totalTokens: ProviderShared.totalTokens(usage.prompt_tokens, usage.completion_tokens, usage.total_tokens),
totalTokens: ProviderShared.totalTokens(input, output, usage.total_tokens ?? undefined),
providerMetadata: { openai: usage },
})
}
@@ -694,24 +696,25 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
}
for (const tool of toolDeltas) {
const current = tools[tool.index]
const pending = pendingTools[tool.index]
for (const [position, tool] of toolDeltas.entries()) {
const index = tool.index ?? position
const current = tools[index]
const pending = pendingTools[index]
const id = current?.id ?? pending?.id ?? (tool.id || undefined)
const name = current?.name ?? pending?.name ?? (tool.function?.name || undefined)
const text = `${pending?.input ?? ""}${tool.function?.arguments ?? ""}`
if (!current && (!id || !name)) {
pendingTools = { ...pendingTools, [tool.index]: { id: id || undefined, name: name || undefined, input: text } }
pendingTools = { ...pendingTools, [index]: { id: id || undefined, name: name || undefined, input: text } }
continue
}
if (pending) {
pendingTools = { ...pendingTools }
delete pendingTools[tool.index]
delete pendingTools[index]
}
const result = ToolStream.appendOrStart(
ADAPTER,
tools,
tool.index,
index,
{ id: id || undefined, name: name || undefined, text },
"OpenAI Chat tool call delta is missing id or name",
)
@@ -488,7 +488,7 @@ describe("Anthropic Messages route", () => {
{
type: "message_delta",
delta: { stop_reason: "end_turn", stop_sequence: "\n\nHuman:" },
usage: { output_tokens: 2 },
usage: { input_tokens: null, output_tokens: 2 },
},
{ type: "message_stop" },
)
@@ -388,12 +388,30 @@ describe("Bedrock Converse route", () => {
Effect.gen(function* () {
const body = eventStreamBody(
["messageStop", { stopReason: "end_turn" }],
["metadata", { usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 } }],
["metadata", { metrics: { latencyMs: 100 } }],
[
"metadata",
{
usage: {
inputTokens: 5,
outputTokens: 2,
totalTokens: 7,
cacheReadInputTokens: null,
cacheWriteInputTokens: null,
},
},
],
["metadata", { usage: null }],
["metadata", null],
)
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, totalTokens: 7 })
expect(response.usage).toMatchObject({
inputTokens: 5,
outputTokens: 2,
totalTokens: 7,
cacheReadInputTokens: undefined,
cacheWriteInputTokens: undefined,
})
}),
)
+27 -4
View File
@@ -722,14 +722,37 @@ describe("Gemini route", () => {
}),
)
it.effect("leaves total usage undefined when component counts are missing", () =>
it.effect("keeps partial usage only in provider metadata", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ usageMetadata: { thoughtsTokenCount: 1 } }))),
Effect.provide(
fixedResponse(
sseEvents({
usageMetadata: {
promptTokenCount: null,
candidatesTokenCount: null,
totalTokenCount: null,
thoughtsTokenCount: null,
cachedContentTokenCount: 1,
promptTokensDetails: [{ modality: "TEXT", tokenCount: 5 }],
candidatesTokensDetails: [{ modality: "TEXT", tokenCount: 2 }],
},
}),
),
),
)
expect(response.usage).toMatchObject({ reasoningTokens: 1 })
expect(response.usage?.totalTokens).toBeUndefined()
expect(response.usage).toMatchObject({
inputTokens: undefined,
outputTokens: undefined,
cacheReadInputTokens: undefined,
providerMetadata: {
google: {
promptTokensDetails: [{ modality: "TEXT", tokenCount: 5 }],
candidatesTokensDetails: [{ modality: "TEXT", tokenCount: 2 }],
},
},
})
}),
)
@@ -596,6 +596,37 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("accepts nullable usage counters", () =>
Effect.gen(function* () {
const body = sseEvents(
deltaChunk({ role: "assistant", content: "Hello" }),
deltaChunk({}, "stop"),
usageChunk({
prompt_tokens: null,
completion_tokens: null,
total_tokens: null,
prompt_tokens_details: { cached_tokens: 1, cache_write_tokens: null },
completion_tokens_details: { reasoning_tokens: null },
}),
)
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
expect(response.usage).toEqual(
new Usage({
providerMetadata: {
openai: {
prompt_tokens: null,
completion_tokens: null,
total_tokens: null,
prompt_tokens_details: { cached_tokens: 1, cache_write_tokens: null },
completion_tokens_details: { reasoning_tokens: null },
},
},
}),
)
}),
)
it.effect("parses and replays OpenAI-compatible reasoning fields", () =>
Effect.gen(function* () {
const fields = ["reasoning_content", "reasoning", "reasoning_text"] as const
@@ -1048,6 +1079,36 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("assembles indexless streamed tool calls", () =>
Effect.gen(function* () {
const body = sseEvents(
deltaChunk({
tool_calls: [
{ id: "call_1", function: { name: "lookup", arguments: '{"query"' } },
{ index: null, id: "call_2", function: { name: "lookup", arguments: '{"query"' } },
],
}),
deltaChunk({
tool_calls: [
{ function: { arguments: ':"weather"}' } },
{ index: null, function: { arguments: ':"time"}' } },
],
}),
deltaChunk({}, "tool_calls"),
)
const response = yield* LLMClient.generate(
LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
}),
).pipe(Effect.provide(fixedResponse(body)))
expect(response.toolCalls).toMatchObject([
{ id: "call_1", name: "lookup", input: { query: "weather" } },
{ id: "call_2", name: "lookup", input: { query: "time" } },
])
}),
)
it.effect("ignores empty identity fields on later tool call deltas", () =>
Effect.gen(function* () {
const body = sseEvents(
@@ -885,6 +885,40 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("accepts nullable token details", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
type: "response.completed",
response: {
id: "resp_1",
usage: {
input_tokens: 5,
output_tokens: 2,
total_tokens: 7,
input_tokens_details: { cached_tokens: null, cache_write_tokens: null },
output_tokens_details: { reasoning_tokens: null },
},
},
}),
),
),
)
expect(response.usage).toMatchObject({
inputTokens: 5,
outputTokens: 2,
nonCachedInputTokens: 5,
totalTokens: 7,
cacheReadInputTokens: undefined,
cacheWriteInputTokens: undefined,
reasoningTokens: undefined,
})
}),
)
it.effect("preserves and replays assistant message phases", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
+17 -1
View File
@@ -3,7 +3,7 @@ export * as Bus from "./bus"
import { Cause, Context, DateTime, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
import { Event } from "@opencode-ai/schema/event"
import type { EventLog } from "@opencode-ai/schema/event-log"
import { and, asc, eq, gt, lte, sql } from "drizzle-orm"
import { and, asc, eq, gt, inArray, lte, sql } from "drizzle-orm"
import { Database } from "./database/database"
import { EventSequenceTable, EventTable } from "./event/sql"
import { Location } from "./location"
@@ -134,6 +134,8 @@ export interface Interface {
readonly after?: number
readonly follow?: boolean
}) => Stream.Stream<LogItem>
/** Latest committed seq per aggregate. Aggregates without events are absent. */
readonly sequences: (aggregateIDs: ReadonlyArray<string>) => Effect.Effect<ReadonlyMap<string, Event.Seq>>
/** @deprecated Use `subscribe()` and consume the returned stream. */
readonly listen: (listener: Subscriber) => Effect.Effect<Unsubscribe>
readonly project: <D extends Event.Definition>(definition: D, projector: Subscriber<D>) => Effect.Effect<void>
@@ -655,6 +657,19 @@ export const layerWith = (options?: LayerOptions) =>
}),
)
const sequences = (aggregateIDs: ReadonlyArray<string>): Effect.Effect<ReadonlyMap<string, Event.Seq>> => {
if (aggregateIDs.length === 0) return Effect.succeed(new Map())
return db
.select({ aggregateID: EventSequenceTable.aggregate_id, seq: EventSequenceTable.seq })
.from(EventSequenceTable)
.where(inArray(EventSequenceTable.aggregate_id, Array.from(aggregateIDs)))
.all()
.pipe(
Effect.orDie,
Effect.map((rows) => new Map(rows.map((row) => [row.aggregateID, Event.Seq.make(row.seq)]))),
)
}
const listen = (listener: Subscriber): Effect.Effect<Unsubscribe> =>
Effect.sync(() => {
listeners.push(listener)
@@ -676,6 +691,7 @@ export const layerWith = (options?: LayerOptions) =>
publish,
subscribe,
log,
sequences,
listen,
project,
replay,
+28 -2
View File
@@ -1,6 +1,6 @@
export * as Formatter from "./formatter"
import { Context, Effect, Layer } from "effect"
import { Context, Effect, Layer, Schema } from "effect"
import { ChildProcess } from "effect/unstable/process"
import path from "path"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
@@ -11,7 +11,16 @@ import { Config } from "./config"
import { Location } from "./location"
import { make, type Info } from "./formatter/builtins"
export const Status = Schema.Struct({
name: Schema.String,
extensions: Schema.Array(Schema.String),
enabled: Schema.Boolean,
}).annotate({ identifier: "FormatterStatus" })
export type Status = typeof Status.Type
export interface Interface {
readonly init: () => Effect.Effect<void>
readonly status: () => Effect.Effect<Status[]>
readonly file: (filepath: string) => Effect.Effect<boolean>
}
@@ -75,6 +84,23 @@ const layer = Layer.effect(
return result
})
const init = Effect.fn("Formatter.init")(function* () {
yield* load
})
const status = Effect.fn("Formatter.status")(function* () {
yield* load
return yield* Effect.forEach(formatters, (formatter) =>
command(formatter).pipe(
Effect.map((enabled) => ({
name: formatter.name,
extensions: [...formatter.extensions],
enabled: enabled !== false,
})),
),
)
})
const file = Effect.fn("Formatter.file")(function* (filepath: string) {
yield* load
const matching = formatters.filter((formatter) =>
@@ -117,7 +143,7 @@ const layer = Layer.effect(
return false
})
return Service.of({ file })
return Service.of({ init, status, file })
}),
)
+152 -1
View File
@@ -1,7 +1,7 @@
export * as Git from "./git"
import path from "path"
import { Context, Effect, Layer, Schema } from "effect"
import { Context, Effect, Layer, Schema, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { AbsolutePath, RelativePath } from "./schema"
import { FSUtil } from "@opencode-ai/util/fs-util"
@@ -35,6 +35,9 @@ const snapshotConfig = `[core]
threads = true
`
export const ChangeSet = Schema.String.pipe(Schema.brand("Git.ChangeSet"))
export type ChangeSet = typeof ChangeSet.Type
export const TreeID = Schema.String.pipe(Schema.brand("Git.TreeID"))
export type TreeID = typeof TreeID.Type
@@ -69,6 +72,13 @@ export class WorktreeError extends Schema.TaggedErrorClass<WorktreeError>()("Git
cause: Schema.optional(Schema.Defect()),
}) {}
export class PatchError extends Schema.TaggedErrorClass<PatchError>()("Git.PatchError", {
operation: Schema.Literals(["capture", "apply", "reset"]),
directory: AbsolutePath,
message: Schema.String,
cause: Schema.optional(Schema.Defect()),
}) {}
export interface Interface {
readonly repo: {
readonly discover: (input: AbsolutePath) => Effect.Effect<Repository | undefined>
@@ -105,6 +115,20 @@ export interface Interface {
) => Effect.Effect<void, OperationError>
readonly resetHard: (repository: Repository, revision: string) => Effect.Effect<void, OperationError>
}
readonly change: {
readonly capture: (input: { repository: Repository; path: AbsolutePath }) => Effect.Effect<ChangeSet, PatchError>
readonly apply: (input: {
repository: Repository
path: AbsolutePath
changes: ChangeSet
}) => Effect.Effect<void, PatchError>
readonly discard: (input: {
repository: Repository
path: AbsolutePath
index: "preserve" | "reset"
untracked: "preserve" | "remove"
}) => Effect.Effect<void, PatchError>
}
readonly worktree: {
readonly create: (input: {
repository: Repository
@@ -654,6 +678,132 @@ const layer = Layer.effect(
),
)
const capture = Effect.fn("Git.change.capture")(function* (input: { repository: Repository; path: AbsolutePath }) {
const scope = path.relative(input.repository.worktree, input.path).replaceAll("\\", "/") || "."
const tracked = yield* execute(
input.repository.worktree,
proc,
)(["diff", "--binary", "HEAD", "--", scope]).pipe(
Effect.mapError(
(cause) => new PatchError({ operation: "capture", directory: input.path, message: cause.message, cause }),
),
)
if (tracked.exitCode !== 0) {
return yield* new PatchError({
operation: "capture",
directory: input.path,
message: tracked.stderr.trim() || tracked.text.trim() || "Failed to capture tracked changes",
})
}
const untracked = yield* execute(
input.repository.worktree,
proc,
)(["ls-files", "--others", "--exclude-standard", "-z", "--", scope]).pipe(
Effect.mapError(
(cause) => new PatchError({ operation: "capture", directory: input.path, message: cause.message, cause }),
),
)
if (untracked.exitCode !== 0) {
return yield* new PatchError({
operation: "capture",
directory: input.path,
message: untracked.stderr.trim() || untracked.text.trim() || "Failed to list untracked changes",
})
}
const created = yield* Effect.forEach(untracked.text.split("\0").filter(Boolean), (file) =>
execute(
input.repository.worktree,
proc,
)(["diff", "--binary", "--no-index", "--", "/dev/null", file]).pipe(
Effect.mapError(
(cause) => new PatchError({ operation: "capture", directory: input.path, message: cause.message, cause }),
),
Effect.flatMap((result) =>
// git diff --no-index returns 1 when differences were found.
result.exitCode === 0 || result.exitCode === 1
? Effect.succeed(result.text)
: Effect.fail(
new PatchError({
operation: "capture",
directory: input.path,
message:
result.stderr.trim() || result.text.trim() || `Failed to capture untracked change: ${file}`,
}),
),
),
),
)
return ChangeSet.make([tracked.text, ...created].filter(Boolean).join("\n"))
})
const apply = Effect.fn("Git.change.apply")(function* (input: {
repository: Repository
path: AbsolutePath
changes: ChangeSet
}) {
const result = yield* proc
.run(
ChildProcess.make("git", ["apply", "-"], {
cwd: input.path,
extendEnv: true,
stdin: Stream.make(new TextEncoder().encode(input.changes)),
}),
)
.pipe(
Effect.mapError(
(cause) => new PatchError({ operation: "apply", directory: input.path, message: cause.message, cause }),
),
)
if (result.exitCode === 0) return
return yield* new PatchError({
operation: "apply",
directory: input.path,
message:
result.stderr.toString("utf8").trim() || result.stdout.toString("utf8").trim() || "Failed to apply changes",
})
})
const discard = Effect.fn("Git.change.discard")(function* (input: {
repository: Repository
path: AbsolutePath
index: "preserve" | "reset"
untracked: "preserve" | "remove"
}) {
const scope = path.relative(input.repository.worktree, input.path).replaceAll("\\", "/") || "."
const restore = yield* execute(
input.repository.worktree,
proc,
)(input.index === "reset" ? ["checkout", "HEAD", "--", scope] : ["checkout", "--", scope]).pipe(
Effect.mapError(
(cause) => new PatchError({ operation: "reset", directory: input.path, message: cause.message, cause }),
),
)
if (restore.exitCode !== 0) {
return yield* new PatchError({
operation: "reset",
directory: input.path,
message: restore.stderr.trim() || restore.text.trim() || "Failed to restore tracked changes",
})
}
if (input.untracked === "preserve") return
const clean = yield* execute(
input.repository.worktree,
proc,
)(["clean", "-fd", "--", scope]).pipe(
Effect.mapError(
(cause) => new PatchError({ operation: "reset", directory: input.path, message: cause.message, cause }),
),
)
if (clean.exitCode === 0) return
return yield* new PatchError({
operation: "reset",
directory: input.path,
message: clean.stderr.trim() || clean.text.trim() || "Failed to clean untracked changes",
})
})
const worktreeRun = Effect.fnUntraced(function* (
operation: "create" | "remove" | "list",
repository: Repository,
@@ -729,6 +879,7 @@ const layer = Layer.effect(
remote: { get: remote },
history: { head, branch, defaultRemoteBranch: remoteHead, rootCommits: roots },
sync: { fetchRemotes: fetch, fetchBranch, checkoutRemoteBranch: checkout, resetHard: reset },
change: { capture, apply, discard },
worktree: { create: worktreeCreate, remove: worktreeRemove, list: worktreeList },
index: { refresh, ignored },
tree: {
+34
View File
@@ -1,12 +1,15 @@
export * as ShellSelect from "./select"
import path from "path"
import { spawn, type ChildProcess } from "child_process"
import { readFile } from "fs/promises"
import { statSync } from "fs"
import { setTimeout } from "node:timers/promises"
import { Schema } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { which } from "../util/which"
const SIGKILL_TIMEOUT_MS = 200
const META: Record<string, { deny?: boolean; login?: boolean; posix?: boolean; ps?: boolean }> = {
bash: { login: true, posix: true },
dash: { login: true, posix: true },
@@ -30,6 +33,37 @@ export const Options = Schema.Struct({
})
export type Options = typeof Options.Type
export async function killTree(proc: ChildProcess, opts?: { exited?: () => boolean }): Promise<void> {
const pid = proc.pid
if (!pid || opts?.exited?.()) return
if (process.platform === "win32") {
await new Promise<void>((resolve) => {
const killer = spawn("taskkill", ["/pid", String(pid), "/f", "/t"], {
stdio: "ignore",
windowsHide: true,
})
killer.once("exit", () => resolve())
killer.once("error", () => resolve())
})
return
}
try {
process.kill(-pid, "SIGTERM")
await setTimeout(SIGKILL_TIMEOUT_MS)
if (!opts?.exited?.()) {
process.kill(-pid, "SIGKILL")
}
} catch {
proc.kill("SIGTERM")
await setTimeout(SIGKILL_TIMEOUT_MS)
if (!opts?.exited?.()) {
proc.kill("SIGKILL")
}
}
}
function stat(file: string) {
return statSync(file, { throwIfNoEntry: false }) ?? undefined
}
+20
View File
@@ -1298,4 +1298,24 @@ describe("Bus", () => {
}),
)
it.effect("sequences returns the latest committed seq per aggregate and omits unknown aggregates", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const first = Session.ID.create()
const second = Session.ID.create()
yield* bus.publish(DurableMessage, durableData(first, "zero"))
yield* bus.publish(DurableMessage, durableData(first, "one"))
yield* bus.publish(DurableMessage, durableData(second, "zero"))
const sequences = yield* bus.sequences([first, second, Session.ID.create()])
expect(sequences).toEqual(
new Map([
[first, Event.Seq.make(1)],
[second, Event.Seq.make(0)],
]),
)
expect(yield* bus.sequences([])).toEqual(new Map())
}),
)
})
+57 -34
View File
@@ -56,22 +56,52 @@ function withTemp<A, E, R>(body: (directory: string) => Effect.Effect<A, E, R>)
}
describe("Formatter", () => {
it.live("does not run formatters marked as disabled in config", () =>
it.live("status() returns empty list when no formatters are configured", () =>
withTemp((directory) =>
Effect.gen(function* () {
const file = path.join(directory, "test.disabled")
expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(false)
}).pipe(
Effect.provide(
formatterLayer(directory, {
disabled: {
disabled: true,
command: [process.execPath, "-e", "process.exit(0)", "$FILE"],
extensions: [".disabled"],
},
}),
),
),
Formatter.Service.use((formatter) => formatter.status()).pipe(Effect.provide(formatterLayer(directory))),
),
)
it.live("status() returns built-in formatters when formatter is true", () =>
withTemp((directory) =>
Formatter.Service.use((formatter) =>
Effect.gen(function* () {
const statuses = yield* formatter.status()
const gofmt = statuses.find((item) => item.name === "gofmt")
expect(gofmt).toBeDefined()
expect(gofmt?.extensions).toContain(".go")
}),
).pipe(Effect.provide(formatterLayer(directory, true))),
),
)
it.live("status() keeps built-in formatters when config object is provided", () =>
withTemp((directory) =>
Formatter.Service.use((formatter) =>
Effect.gen(function* () {
const statuses = yield* formatter.status()
expect(statuses.find((item) => item.name === "gofmt")?.extensions).toContain(".go")
expect(statuses.find((item) => item.name === "mix")).toBeDefined()
}),
).pipe(Effect.provide(formatterLayer(directory, { gofmt: {} }))),
),
)
it.live("status() excludes formatters marked as disabled in config", () =>
withTemp((directory) =>
Formatter.Service.use((formatter) =>
Effect.gen(function* () {
const statuses = yield* formatter.status()
expect(statuses.find((item) => item.name === "gofmt")).toBeUndefined()
expect(statuses.find((item) => item.name === "mix")).toBeDefined()
}),
).pipe(Effect.provide(formatterLayer(directory, { gofmt: { disabled: true } }))),
),
)
it.live("service initializes without error", () =>
withTemp((directory) =>
Formatter.Service.use((formatter) => formatter.init()).pipe(Effect.provide(formatterLayer(directory))),
),
)
@@ -85,29 +115,22 @@ describe("Formatter", () => {
),
)
it.live("loads formatter state per directory", () =>
withTemp((off) =>
withTemp((on) =>
it.live("status() initializes formatter state per directory", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([off, on]) =>
Effect.gen(function* () {
const offFile = path.join(off, "test.isolated")
const onFile = path.join(on, "test.isolated")
const disabled = yield* Formatter.Service.use((formatter) => formatter.file(offFile)).pipe(
Effect.provide(formatterLayer(off, false)),
const disabled = yield* Formatter.Service.use((formatter) => formatter.status()).pipe(
Effect.provide(formatterLayer(off.path, false)),
)
const enabled = yield* Formatter.Service.use((formatter) => formatter.file(onFile)).pipe(
Effect.provide(
formatterLayer(on, {
isolated: {
command: [process.execPath, "-e", "process.exit(0)", "$FILE"],
extensions: [".isolated"],
},
}),
),
const enabled = yield* Formatter.Service.use((formatter) => formatter.status()).pipe(
Effect.provide(formatterLayer(on.path, true)),
)
expect(disabled).toBe(false)
expect(enabled).toBe(true)
expect(disabled).toEqual([])
expect(enabled.find((item) => item.name === "gofmt")).toBeDefined()
}),
),
(directories) =>
Effect.promise(() => Promise.all(directories.map((tmp) => tmp[Symbol.asyncDispose]())).then(() => undefined)),
),
)
+3 -1
View File
@@ -41,15 +41,17 @@ describe("Session.log", () => {
it.effect("replays public session events and marks synced at the aggregate watermark", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const created = yield* session.create({ location })
yield* session.rename({ sessionID: created.id, title: "session.renamed" })
const items = Array.from(yield* Stream.runCollect(session.log({ sessionID: created.id })))
const watermark = (yield* bus.sequences([created.id])).get(created.id)
// Session creation commits a non-public durable event, so the marker's
// seq covers more of the aggregate than the public events emitted.
expect(items.map((item) => item.type)).toEqual(["session.renamed", "log.synced"])
expect(items.at(-1)).toEqual({ type: "log.synced", aggregateID: created.id, seq: Event.Seq.make(1) })
expect(items.at(-1)).toEqual({ type: "log.synced", aggregateID: created.id, seq: watermark })
}),
)
+17
View File
@@ -2,6 +2,10 @@ export * as ServerAuth from "./auth"
import { Context, Layer, Option, Redacted } from "effect"
export type Credentials = {
password?: string
}
export type DecodedCredentials = {
readonly username: string
readonly password: Redacted.Redacted
@@ -33,3 +37,16 @@ export function authorized(credentials: DecodedCredentials, config: Info) {
Redacted.value(credentials.password) === config.password.value
)
}
export function header(credentials?: Credentials) {
const password = credentials?.password
if (!password) return undefined
return `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
}
export function headers(credentials?: Credentials) {
const authorization = header(credentials)
if (!authorization) return undefined
return { Authorization: authorization }
}
+4
View File
@@ -7,3 +7,7 @@ test("accepts only the fixed opencode username", () => {
expect(ServerAuth.authorized({ username: "opencode", password: Redacted.make("secret") }, config)).toBe(true)
expect(ServerAuth.authorized({ username: "custom", password: Redacted.make("secret") }, config)).toBe(false)
})
test("encodes the fixed opencode username", () => {
expect(ServerAuth.header({ password: "secret" })).toBe(`Basic ${Buffer.from("opencode:secret").toString("base64")}`)
})
@@ -96,7 +96,6 @@ import { findMessageBoundary, messageNavigationSlack } from "./message-navigatio
import { stringWidth } from "../../util/string-width"
import { useArgs } from "../../context/args"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { installSyntaxHighlightCache } from "../../util/syntax-highlight-cache"
addDefaultParsers(parsers.parsers)
@@ -129,7 +128,6 @@ function use() {
}
export function Session() {
installSyntaxHighlightCache()
const setEpilogue = useEpilogue()
const clipboard = useClipboard()
const writeExport = async (file: string, content: string) => {
@@ -1,38 +0,0 @@
import { getTreeSitterClient, type TreeSitterClient } from "@opentui/core"
const CACHE_SIZE = 500
const installed = new WeakSet<TreeSitterClient>()
export function installSyntaxHighlightCache() {
const client = getTreeSitterClient()
if (installed.has(client)) return
installed.add(client)
client.highlightOnce = cacheHighlights(client.highlightOnce.bind(client))
}
export function cacheHighlights(highlight: TreeSitterClient["highlightOnce"], capacity = CACHE_SIZE) {
const cache = new Map<string, ReturnType<TreeSitterClient["highlightOnce"]>>()
return (content: string, filetype: string) => {
const key = `${filetype}\0${content}`
const cached = cache.get(key)
if (cached) {
cache.delete(key)
cache.set(key, cached)
return cached
}
const result = highlight(content, filetype)
cache.set(key, result)
if (cache.size > capacity) cache.delete(cache.keys().next().value!)
void result
.then((value) => {
if (value.error && cache.get(key) === result) cache.delete(key)
})
.catch(() => {
if (cache.get(key) === result) cache.delete(key)
})
return result
}
}
@@ -1,69 +0,0 @@
import { describe, expect, test } from "bun:test"
import { cacheHighlights } from "../../src/util/syntax-highlight-cache"
describe("syntax highlight cache", () => {
test("reuses completed and in-flight highlights", async () => {
let calls = 0
const highlight = cacheHighlights(async () => {
calls++
return { highlights: [[0, 5, "keyword"]] }
})
const first = highlight("const", "typescript")
const second = highlight("const", "typescript")
expect(second).toBe(first)
expect(await second).toEqual({ highlights: [[0, 5, "keyword"]] })
expect(await highlight("const", "typescript")).toEqual({ highlights: [[0, 5, "keyword"]] })
expect(calls).toBe(1)
})
test("evicts least recently used highlights", async () => {
let calls = 0
const highlight = cacheHighlights(async () => {
calls++
return { highlights: [] }
}, 2)
await highlight("one", "text")
await highlight("two", "text")
await highlight("one", "text")
await highlight("three", "text")
await highlight("two", "text")
expect(calls).toBe(4)
})
test("retries failed highlights", async () => {
let calls = 0
const highlight = cacheHighlights(async () => {
calls++
if (calls === 1) return { error: "parser unavailable" }
return { highlights: [] }
})
await highlight("const", "typescript")
await highlight("const", "typescript")
expect(calls).toBe(2)
})
test("an evicted failure does not delete its replacement", async () => {
const pending = Promise.withResolvers<{ highlights: [] }>()
let calls = 0
const highlight = cacheHighlights(() => {
calls++
if (calls === 1) return pending.promise
return Promise.resolve({ highlights: [] })
}, 1)
const stale = highlight("one", "text")
await highlight("two", "text")
const current = highlight("one", "text")
pending.reject(new Error("parser unavailable"))
await expect(stale).rejects.toThrow("parser unavailable")
expect(highlight("one", "text")).toBe(current)
expect(calls).toBe(3)
})
})