Compare commits

...
51 changed files with 2162 additions and 506 deletions
+3 -3
View File
@@ -54,9 +54,9 @@ ultimate source of truth.
loop headers, so reads before initialization and self- or cross-referential initializers observe the JavaScript
temporal dead zone.
- [ ] Hoist function declarations accepted directly in switch cases.
- [ ] Computed object destructuring keys such as `const { [field]: value } = record`.
- [ ] Object destructuring from arrays, such as `const { length } = values`.
- [ ] Array destructuring from supported non-array iterables: strings, Maps, Sets, and URLSearchParams.
- [x] Computed object destructuring keys such as `const { [field]: value } = record`.
- [x] Object destructuring from arrays, such as `const { length } = values`.
- [x] Array destructuring from supported non-array iterables: strings, Maps, Sets, and URLSearchParams.
## Statements and control flow
+50 -40
View File
@@ -777,9 +777,9 @@ export class Interpreter<R> {
}
if (pattern.type === "ObjectPattern") {
if (value === null || typeof value !== "object" || Array.isArray(value) || isRuntimeReference(value)) {
if (value === null || typeof value !== "object" || isRuntimeReference(value)) {
throw new InterpreterRuntimeError(
"Object destructuring requires a data object value.",
"Object destructuring requires a data object or array value.",
pattern,
"InvalidDataValue",
)
@@ -798,38 +798,35 @@ export class Interpreter<R> {
continue
}
if (
property.type !== "Property" ||
getBoolean(property, "computed") ||
getString(property, "kind") !== "init"
) {
throw new InterpreterRuntimeError("Only named object destructuring properties are supported.", property)
const key = yield* self.destructuringPropertyKey(property)
if (isBlockedMember(String(key))) {
throw new InterpreterRuntimeError(`Property '${String(key)}' is not available in CodeMode.`, property)
}
const keyNode = getNode(property, "key")
const key = keyNode.type === "Identifier" ? getString(keyNode, "name") : String(keyNode.value)
if (isBlockedMember(key)) {
throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, keyNode)
}
consumed.add(key)
yield* self.declarePattern(getNode(property, "value"), (value as SafeObject)[key], mutable, property)
consumed.add(String(key))
yield* self.declarePattern(
getNode(property, "value"),
self.destructuringPropertyValue(value as SafeObject | Array<unknown>, key),
mutable,
property,
)
}
return
}
if (pattern.type === "ArrayPattern") {
if (!Array.isArray(value)) {
throw new InterpreterRuntimeError("Array destructuring requires an array value.", pattern)
const items = spreadItems(value)
if (items === undefined) {
throw new InterpreterRuntimeError("Array destructuring requires a supported iterable value.", pattern)
}
for (const [index, item] of getArray(pattern, "elements").entries()) {
if (item === null) continue
const element = asNode(item, `elements[${index}]`)
if (element.type === "RestElement") {
yield* self.declarePattern(getNode(element, "argument"), value.slice(index), mutable, element)
yield* self.declarePattern(getNode(element, "argument"), items.slice(index), mutable, element)
break
}
yield* self.declarePattern(element, value[index], mutable, pattern)
yield* self.declarePattern(element, items[index], mutable, pattern)
}
return
}
@@ -858,15 +855,15 @@ export class Interpreter<R> {
}
if (pattern.type === "ObjectPattern") {
if (value === null || typeof value !== "object" || Array.isArray(value) || isRuntimeReference(value)) {
if (value === null || typeof value !== "object" || isRuntimeReference(value)) {
throw new InterpreterRuntimeError(
"Object destructuring requires a data object value.",
"Object destructuring requires a data object or array value.",
pattern,
"InvalidDataValue",
)
}
const source = value as SafeObject
const source = value as SafeObject | Array<unknown>
const consumed = new Set<string>()
for (const propertyValue of getArray(pattern, "properties")) {
const property = asNode(propertyValue, "properties")
@@ -878,36 +875,29 @@ export class Interpreter<R> {
yield* self.assignPattern(getNode(property, "argument"), rest, property)
continue
}
if (
property.type !== "Property" ||
getBoolean(property, "computed") ||
getString(property, "kind") !== "init"
) {
throw new InterpreterRuntimeError("Only named object destructuring properties are supported.", property)
const key = yield* self.destructuringPropertyKey(property)
if (isBlockedMember(String(key))) {
throw new InterpreterRuntimeError(`Property '${String(key)}' is not available in CodeMode.`, property)
}
const keyNode = getNode(property, "key")
const key = keyNode.type === "Identifier" ? getString(keyNode, "name") : String(keyNode.value)
if (isBlockedMember(key)) {
throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, keyNode)
}
consumed.add(key)
yield* self.assignPattern(getNode(property, "value"), source[key], property)
consumed.add(String(key))
yield* self.assignPattern(getNode(property, "value"), self.destructuringPropertyValue(source, key), property)
}
return
}
if (pattern.type === "ArrayPattern") {
if (!Array.isArray(value)) {
throw new InterpreterRuntimeError("Array destructuring requires an array value.", pattern)
const items = spreadItems(value)
if (items === undefined) {
throw new InterpreterRuntimeError("Array destructuring requires a supported iterable value.", pattern)
}
for (const [index, item] of getArray(pattern, "elements").entries()) {
if (item === null) continue
const element = asNode(item, `elements[${index}]`)
if (element.type === "RestElement") {
yield* self.assignPattern(getNode(element, "argument"), value.slice(index), element)
yield* self.assignPattern(getNode(element, "argument"), items.slice(index), element)
break
}
yield* self.assignPattern(element, value[index], pattern)
yield* self.assignPattern(element, items[index], pattern)
}
return
}
@@ -916,6 +906,26 @@ export class Interpreter<R> {
})
}
private destructuringPropertyKey(property: AstNode): Effect.Effect<string | number, unknown, R> {
if (property.type !== "Property" || getString(property, "kind") !== "init") {
throw new InterpreterRuntimeError("Unsupported object destructuring property.", property)
}
const keyNode = getNode(property, "key")
if (getBoolean(property, "computed")) {
return Effect.map(this.evaluateExpression(keyNode), (value) => this.toPropertyKey(value, keyNode))
}
return Effect.succeed(keyNode.type === "Identifier" ? getString(keyNode, "name") : String(keyNode.value))
}
private destructuringPropertyValue(source: SafeObject | Array<unknown>, key: string | number): unknown {
if (!Array.isArray(source)) return source[String(key)]
if (key === "length") return source.length
if (typeof key === "number") return source[key]
if (Object.hasOwn(source, key)) return (source as Record<string, unknown> & Array<unknown>)[key]
if (arrayMethods.has(key)) return new IntrinsicReference(source, key)
return undefined
}
private evaluateExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
switch (node.type) {
case "Literal": {
+67
View File
@@ -548,4 +548,71 @@ describe("destructuring assignment", () => {
test("returns the assigned value", async () => {
expect(await value(`let a = 0; const result = ([a] = [7]); return [a, result]`)).toEqual([7, [7]])
})
test("supports computed object keys and evaluates them once", async () => {
expect(
await value(`
let calls = 0
const field = () => { calls++; return "name" }
const { [field()]: name, ...rest } = { name: "Ada", role: "engineer" }
return { calls, name, rest }
`),
).toEqual({ calls: 1, name: "Ada", rest: { role: "engineer" } })
})
test("supports object patterns over arrays", async () => {
expect(
await value(`
const { 0: first, length, slice, ...rest } = ["a", "b", "c"]
return { first, length, sliced: slice(1), rest }
`),
).toEqual({ first: "a", length: 3, sliced: ["b", "c"], rest: { 1: "b", 2: "c" } })
})
test("preserves exact computed property names on arrays", async () => {
expect(
await value(`
const { ["01"]: item, ...rest } = [10, 20]
return { missing: item === undefined, rest }
`),
).toEqual({ missing: true, rest: { 0: 10, 1: 20 } })
})
test("supports array patterns over strings, Maps, Sets, and URLSearchParams", async () => {
expect(
await value(`
const [letter, ...letters] = "A😀B"
const [[mapKey, mapValue]] = new Map([["key", 1]])
const [setFirst, setSecond] = new Set([2, 3])
const [[queryKey, queryValue]] = new URLSearchParams("q=test&page=2")
return { letter, letters, mapKey, mapValue, setFirst, setSecond, queryKey, queryValue }
`),
).toEqual({
letter: "A",
letters: ["😀", "B"],
mapKey: "key",
mapValue: 1,
setFirst: 2,
setSecond: 3,
queryKey: "q",
queryValue: "test",
})
})
test("supports iterable patterns in assignment and parameters", async () => {
expect(
await value(`
let first
let rest
;[first, ...rest] = new Set([1, 2, 3])
const read = ([[key, value]]) => key + value
return { first, rest, entry: read(new Map([["a", 4]])) }
`),
).toEqual({ first: 1, rest: [2, 3], entry: "a4" })
})
test("rejects computed keys that are not confined property keys", async () => {
const err = await error(`const key = {}; const { [key]: value } = {}`)
expect(err.message).toContain("Property key must be a string or number")
})
})
+32 -8
View File
@@ -2,8 +2,9 @@ export * as Config from "./config"
import { makeLocationNode } from "./effect/app-node"
import path from "path"
import { isDeepStrictEqual } from "node:util"
import { type ParseError, parse } from "jsonc-parser"
import { Context, Effect, Fiber, Layer, Option, PubSub, Schema, Stream } from "effect"
import { Context, Effect, Fiber, Layer, Option, PubSub, Schema, Semaphore, Stream } from "effect"
import { Permission } from "@opencode-ai/schema/permission"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { Integration } from "@opencode-ai/schema/integration"
@@ -165,6 +166,7 @@ const layer = Layer.effect(
const credentials = yield* Credential.Service
const wellknown = yield* WellKnown.Service
const names = ["opencode.json", "opencode.jsonc"]
const reloadLock = Semaphore.makeUnsafe(1)
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
const decodeV1Info = Schema.decodeUnknownOption(ConfigV1.Info, decodeOptions)
@@ -326,12 +328,17 @@ const layer = Layer.effect(
}
})
const reload = Effect.fn("Config.reload")(function* () {
const next = yield* discover()
configs = next
yield* reconcile(next)
yield* events.publish(ConfigSchema.Event.Updated, {})
})
const reload = Effect.fn("Config.reload")(() =>
reloadLock.withPermit(
Effect.gen(function* () {
const next = yield* discover()
if (isDeepStrictEqual(configs, next)) return
configs = next
yield* reconcile(next)
yield* events.publish(ConfigSchema.Event.Updated, {})
}),
),
)
yield* Stream.fromPubSub(updates).pipe(
Stream.debounce("100 millis"),
@@ -354,12 +361,29 @@ const layer = Layer.effect(
),
Effect.forkScoped({ startImmediately: true }),
)
yield* wellknown.changes.pipe(
yield* events.subscribe(WellKnown.Event.Updated).pipe(
Stream.runForEach(() =>
reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload wellknown sources", { cause }))),
),
Effect.forkScoped({ startImmediately: true }),
)
yield* Effect.sleep("10 minutes").pipe(
Effect.andThen(
Effect.suspend(() => {
if (!wellknown.snapshot().length) return Effect.void
return Effect.gen(function* () {
const changed = yield* wellknown.refresh().pipe(
Effect.catch((error) =>
Effect.logWarning("failed to refresh wellknown manifests", { error }).pipe(Effect.as(false)),
),
)
if (!changed) yield* reload()
}).pipe(Effect.catchCause((cause) => Effect.logWarning("failed to refresh wellknown config", { cause })))
}),
),
Effect.forever,
Effect.forkScoped({ startImmediately: true }),
)
yield* reconcile(initial)
return Service.of({
+4
View File
@@ -2,9 +2,13 @@ export * as ConfigExperimental from "./experimental"
import { Schema } from "effect"
import { NonNegativeInt } from "../schema"
import { ConfigPolicy } from "./policy"
export class Info extends Schema.Class<Info>("ConfigExperimental.Info")({
subagent_depth: NonNegativeInt.pipe(Schema.optional).annotate({
description: "Maximum subagent nesting depth. Defaults to 1.",
}),
policies: ConfigPolicy.Info.pipe(Schema.Array, Schema.optional).annotate({
description: "Ordered policies controlling access to configured resources",
}),
}) {}
+35
View File
@@ -0,0 +1,35 @@
export * as ConfigPolicyPlugin from "./policy"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Effect, Stream } from "effect"
import { Config } from "../../config"
import { Wildcard } from "../../util/wildcard"
export const Plugin = define({
id: "opencode.config.policy",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const loaded = { entries: yield* config.entries() }
yield* ctx.catalog.transform((catalog) => {
// User-global policy takes priority over policy authored by a repository.
const policies = loaded.entries
.filter((entry): entry is Config.Document => entry.type === "document")
.toReversed()
.flatMap((entry) => entry.info.experimental?.policies ?? [])
for (const record of catalog.provider.list()) {
const policy = policies.findLast((policy) => Wildcard.match(record.provider.id, policy.resource))
if (policy?.effect === "deny") catalog.provider.remove(record.provider.id)
}
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() =>
config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(ctx.catalog.reload()),
),
),
Effect.forkScoped({ startImmediately: true }),
)
}),
})
+13
View File
@@ -0,0 +1,13 @@
export * as ConfigPolicy from "./policy"
import { Schema } from "effect"
export const Effect = Schema.Literals(["allow", "deny"])
export type Effect = typeof Effect.Type
export const Info = Schema.Struct({
action: Schema.Literal("provider.use"),
resource: Schema.String,
effect: Effect,
})
export type Info = typeof Info.Type
+2
View File
@@ -39,6 +39,7 @@ import { InstructionDiscovery } from "./instruction-discovery"
import { InstructionBuiltIns } from "./instructions/builtins"
import { InstructionEntry } from "./session/instruction-entry"
import { SessionInstructions } from "./session/instructions"
import { SessionGenerateNode } from "./session/generate-node"
import { McpTool } from "./tool/mcp"
import { ReadToolFileSystem } from "./tool/read-filesystem"
import { ToolRegistry } from "./tool/registry"
@@ -81,6 +82,7 @@ const locationServiceNodes = [
Form.node,
QuestionV2.node,
Generate.node,
SessionGenerateNode.node,
ReadToolFileSystem.node,
McpTool.node,
SessionInstructions.node,
+2
View File
@@ -10,6 +10,7 @@ import { Config } from "../config"
import { ConfigAgentPlugin } from "../config/plugin/agent"
import { ConfigCommandPlugin } from "../config/plugin/command"
import { ConfigProviderPlugin } from "../config/plugin/provider"
import { ConfigPolicyPlugin } from "../config/plugin/policy"
import { ConfigReferencePlugin } from "../config/plugin/reference"
import { ConfigSkillPlugin } from "../config/plugin/skill"
import { EventV2 } from "../event"
@@ -151,6 +152,7 @@ const post = [
ConfigSkillPlugin.Plugin,
ConfigProviderPlugin.Plugin,
VariantPlugin.Plugin,
ConfigPolicyPlugin.Plugin,
] as const satisfies readonly InternalPlugin[]
export const list = Effect.fn("PluginInternal.list")(function* () {
+12
View File
@@ -33,6 +33,7 @@ import { LocationServiceMap } from "./location-service-map"
import { MessageDecodeError } from "./session/error"
import { SessionEvent } from "./session/event"
import { SessionPending } from "./session/pending"
import { SessionGenerate } from "./session/generate"
import { Snapshot } from "./snapshot"
import { SessionRevert } from "./session/revert"
import { Session } from "@opencode-ai/schema/session"
@@ -176,6 +177,7 @@ export type Error =
| CommandV2.NotFoundError
| CommandV2.EvaluationError
| MessageNotFoundError
| SessionGenerate.Error
export interface Interface {
readonly list: (input?: ListInput) => Effect.Effect<{
@@ -244,6 +246,11 @@ export interface Interface {
delivery?: SessionPending.Delivery
resume?: boolean
}) => Effect.Effect<SessionPending.User, NotFoundError | PromptConflictError | AttachmentError>
/** Generates text from current Session context without admitting input or mutating history. */
readonly generate: (input: {
sessionID: SessionSchema.ID
prompt: string
}) => Effect.Effect<string, NotFoundError | SessionGenerate.Error>
readonly command: (input: {
id?: SessionMessage.ID
sessionID: SessionSchema.ID
@@ -569,6 +576,11 @@ const layer = Layer.effect(
}),
),
),
generate: Effect.fn("V2Session.generate")(function* (input) {
const session = yield* result.get(input.sessionID)
const generate = yield* SessionGenerate.Service.pipe(Effect.provide(locations.get(session.location)))
return yield* generate.generate(input)
}),
command: Effect.fn("V2Session.command")(function* (input) {
const session = yield* result.get(input.sessionID)
const commands = yield* CommandV2.Service.pipe(Effect.provide(locations.get(session.location)))
@@ -0,0 +1,69 @@
export * as SessionGenerateNode from "./generate-node"
import { LLM, LLMClient, Message, SystemPart } from "@opencode-ai/ai"
import { Effect, Layer } from "effect"
import { Database } from "../database/database"
import { makeLocationNode } from "../effect/app-node"
import { llmClient } from "../effect/app-node-platform"
import { PluginHooks } from "../plugin/hooks"
import { SessionContext } from "./context"
import { SessionGenerate } from "./generate"
import { SessionHistory } from "./history"
import { SessionModelHeaders } from "./model-headers"
import { SessionRunnerModel } from "./runner/model"
import PROMPT_DEFAULT from "./runner/prompt/base.txt"
import { toLLMMessages } from "./runner/to-llm-message"
const layer = Layer.effect(
SessionGenerate.Service,
Effect.gen(function* () {
const context = yield* SessionContext.Service
const database = yield* Database.Service
const hooks = yield* PluginHooks.Service
const llm = yield* LLMClient.Service
const models = yield* SessionRunnerModel.Service
return SessionGenerate.Service.of({
generate: Effect.fn("SessionGenerate.generate")(function* (input) {
const selection = yield* context.select(input.sessionID)
const model = yield* models.resolve(selection.session)
const history = yield* SessionHistory.preview(database.db, selection.session.id, selection.instructions)
const providerMetadataKey = model.model.route.providerMetadataKey ?? model.model.provider
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(selection.session.id)
? selection.session.id.slice(4)
: selection.session.id
const contextEvent = yield* hooks.trigger("session", "context", {
sessionID: selection.session.id,
agent: selection.agent.id,
model: model.ref,
system: [selection.agent.info.system ? selection.agent.info.system : PROMPT_DEFAULT, history.initial]
.filter((part) => part.length > 0)
.map(SystemPart.make),
messages: [
...toLLMMessages(history.messages, model.ref, providerMetadataKey),
...(history.instructionUpdate ? [Message.system(history.instructionUpdate)] : []),
Message.user(input.prompt),
],
tools: {},
})
return (yield* llm.generate(
LLM.request({
model: model.model,
http: { headers: SessionModelHeaders.make(selection.session) },
providerOptions: { openai: { promptCacheKey } },
system: contextEvent.system,
messages: contextEvent.messages,
tools: [],
toolChoice: "none",
}),
)).text
}),
})
}),
)
export const node = makeLocationNode({
service: SessionGenerate.Service,
layer,
deps: [SessionContext.node, Database.node, PluginHooks.node, SessionRunnerModel.node, llmClient],
})
+21
View File
@@ -0,0 +1,21 @@
export * as SessionGenerate from "./generate"
import type { LLMError } from "@opencode-ai/ai"
import { Context, type Effect } from "effect"
import type { Instructions } from "../instructions"
import type { AgentNotFoundError } from "./error"
import type { SessionRunnerModel } from "./runner/model"
import type { SessionSchema } from "./schema"
export type Error = AgentNotFoundError | Instructions.InitializationBlocked | SessionRunnerModel.Error | LLMError
export interface Interface {
/** Generates text from current Session context without mutating the Session. */
readonly generate: (input: {
readonly sessionID: SessionSchema.ID
readonly prompt: string
}) => Effect.Effect<string, Error>
}
/** Location-scoped transient generation from Session context. */
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionGenerate") {}
+36 -8
View File
@@ -60,13 +60,17 @@ const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =>
),
)
export const load = Effect.fn("SessionHistory.load")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
return yield* Effect.forEach(
yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID)),
decodeMessageRow,
const messageEntries = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
const rows = yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID))
return yield* Effect.forEach(rows, (row) =>
decodeMessageRow(row).pipe(Effect.map((message) => ({ seq: row.seq, message }))),
)
})
export const load = Effect.fn("SessionHistory.load")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
return (yield* messageEntries(db, sessionID)).map((entry) => entry.message)
})
export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
@@ -75,10 +79,7 @@ export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(fun
return yield* db
.transaction(() =>
Effect.gen(function* () {
const rows = yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID))
const messages = yield* Effect.forEach(rows, (row) =>
decodeMessageRow(row).pipe(Effect.map((message) => ({ seq: row.seq, message }))),
)
const messages = yield* messageEntries(db, sessionID)
const assembled = yield* InstructionState.assemble(db, sessionID, instructions)
return {
initial: assembled.initial,
@@ -89,6 +90,33 @@ export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(fun
.pipe(Effect.orDie)
})
export const preview = Effect.fn("SessionHistory.preview")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
instructions: Instructions.Instructions,
) {
const observed = yield* Instructions.read(instructions)
return yield* db
.transaction(() =>
Effect.gen(function* () {
const messages = yield* messageEntries(db, sessionID)
// An active assistant may contain an unresolved tool call, so only preview the settled prefix.
const unsettled = messages.findIndex(
(entry) => entry.message.type === "assistant" && entry.message.time.completed === undefined,
)
const settled = unsettled === -1 ? messages : messages.slice(0, unsettled)
const assembled = yield* InstructionState.preview(db, sessionID, instructions, observed)
const entries = [...settled, ...assembled.updates].toSorted((a, b) => a.seq - b.seq)
return {
initial: assembled.initial,
messages: entries.map((entry) => entry.message),
instructionUpdate: assembled.update,
}
}),
)
.pipe(Effect.catch((error) => (error instanceof Instructions.InitializationBlocked ? error : Effect.die(error))))
})
/** Returns the session's sole user message, or `undefined` once a second one exists. */
export const firstUserMessageIfOnly = Effect.fn("SessionHistory.firstUserMessageIfOnly")(function* (
db: DatabaseService,
+83 -23
View File
@@ -29,12 +29,11 @@ export const observe = Effect.fn("InstructionState.observe")(function* (
const [observed, stored] = yield* Effect.all([Instructions.read(instructions), ensure(db, sessionID)], {
concurrency: "unbounded",
})
const admission = yield* Instructions.diff(observed, stored?.current_values)
const result = yield* observeAgainst(observed, stored?.current_values)
return {
sessionID,
initial: !stored,
current: Instructions.applyHashDelta(stored?.current_values ?? {}, admission.delta),
...admission,
...result,
}
})
@@ -123,28 +122,21 @@ export const rebuild = Effect.fn("InstructionState.rebuild")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
) {
const folded = fold(yield* instructionEvents(db, sessionID))
if (!folded) {
const state = yield* stateFromEvents(db, sessionID)
if (!state) {
yield* reset(db, sessionID)
return undefined
}
const state = {
session_id: sessionID,
epoch_start: folded.epochStart,
through_seq: folded.throughSeq,
initial_values: folded.initial,
current_values: folded.current,
}
yield* db
.insert(InstructionStateTable)
.values(state)
.onConflictDoUpdate({
target: InstructionStateTable.session_id,
set: {
epoch_start: folded.epochStart,
through_seq: folded.throughSeq,
initial_values: folded.initial,
current_values: folded.current,
epoch_start: state.epoch_start,
through_seq: state.through_seq,
initial_values: state.initial_values,
current_values: state.current_values,
},
})
.run()
@@ -152,13 +144,12 @@ export const rebuild = Effect.fn("InstructionState.rebuild")(function* (
return state
})
export const assemble = Effect.fn("InstructionState.assemble")(function* (
const assembleState = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
instructions: Instructions.Instructions,
state: typeof InstructionStateTable.$inferSelect,
) {
const state = yield* find(db, sessionID)
if (!state) return yield* Effect.die(new Error(`Instruction state not found during assembly: ${sessionID}`))
const rows = yield* instructionUpdatesAfter(db, sessionID, state.epoch_start)
const updates = rows.map((row) => ({
row,
@@ -188,7 +179,49 @@ export const assemble = Effect.fn("InstructionState.assemble")(function* (
})
values = Instructions.applyDelta(values, delta)
}
return { initial: Instructions.renderInitial(instructions, valuesAtStart), updates: result }
return { initial: Instructions.renderInitial(instructions, valuesAtStart), updates: result, current: values }
})
export const assemble = Effect.fn("InstructionState.assemble")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
instructions: Instructions.Instructions,
) {
const state = yield* find(db, sessionID)
if (!state) return yield* Effect.die(new Error(`Instruction state not found during assembly: ${sessionID}`))
const assembled = yield* assembleState(db, sessionID, instructions, state)
return { initial: assembled.initial, updates: assembled.updates }
})
export const preview = Effect.fn("InstructionState.preview")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
instructions: Instructions.Instructions,
observed: Instructions.ReadResult,
) {
const state = yield* readState(db, sessionID)
const result = yield* observeAgainst(observed, state?.current_values)
const blobs = new Map<Instructions.Hash, Schema.Json>(
Object.entries(result.blobs).map(([hash, value]) => [Instructions.Hash.make(hash), value]),
)
if (!state) {
const values = dereference(result.current, blobs)
return { initial: Instructions.renderInitial(instructions, values), updates: [], update: "" }
}
const assembled = yield* assembleState(db, sessionID, instructions, state)
return {
initial: assembled.initial,
updates: assembled.updates,
update: Instructions.renderUpdate(instructions, assembled.current, dereferenceDelta(result.delta, blobs)),
}
})
const observeAgainst = Effect.fnUntraced(function* (observed: Instructions.ReadResult, previous?: Instructions.Values) {
const admission = yield* Instructions.diff(observed, previous)
return {
current: Instructions.applyHashDelta(previous ?? {}, admission.delta),
...admission,
}
})
const find = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
@@ -203,7 +236,26 @@ const find = Effect.fnUntraced(function* (db: DatabaseService, sessionID: Sessio
const ensure = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
const stored = yield* find(db, sessionID)
if (!stored) return yield* rebuild(db, sessionID)
const latest = yield* db
const latest = yield* latestRelevantSequence(db, sessionID)
if (!latest || latest.seq <= stored.through_seq) return stored
return yield* rebuild(db, sessionID)
})
const readState = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
const stored = yield* find(db, sessionID)
if (!stored) return yield* stateFromEvents(db, sessionID)
const latest = yield* latestRelevantSequence(db, sessionID)
if (!latest || latest.seq <= stored.through_seq) return stored
return yield* stateFromEvents(db, sessionID)
})
const stateFromEvents = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
const folded = fold(yield* instructionEvents(db, sessionID))
return folded ? foldedState(sessionID, folded) : undefined
})
const latestRelevantSequence = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
return yield* db
.select({ seq: EventTable.seq })
.from(EventTable)
.where(and(eq(EventTable.aggregate_id, sessionID), inArray(EventTable.type, relevantEventTypes)))
@@ -211,8 +263,6 @@ const ensure = Effect.fnUntraced(function* (db: DatabaseService, sessionID: Sess
.limit(1)
.get()
.pipe(Effect.orDie)
if (!latest || latest.seq <= stored.through_seq) return stored
return yield* rebuild(db, sessionID)
})
const insertBlobs = Effect.fnUntraced(function* (db: DatabaseService, blobs: Readonly<Record<string, Schema.Json>>) {
@@ -364,3 +414,13 @@ function fold(rows: ReadonlyArray<InstructionEventRow>) {
: { epochStart: row.seq, throughSeq: row.seq, initial: current, current }
}, undefined)
}
function foldedState(sessionID: SessionSchema.ID, folded: NonNullable<ReturnType<typeof fold>>) {
return {
session_id: sessionID,
epoch_start: folded.epochStart,
through_seq: folded.throughSeq,
initial_values: folded.initial,
current_values: folded.current,
}
}
+46 -10
View File
@@ -1,14 +1,14 @@
export * as State from "./state"
import { Context, Effect, Scope, Semaphore } from "effect"
import { Clock, Context, Deferred, Effect, Scope, Semaphore } from "effect"
/**
* A replayable transform applied to a draft during reload.
*
* Domain drafts expose readable and writable state while preserving concise
* plugin/config code. Transforms may perform Effects before returning.
* plugin/config code. Transforms synchronously rebuild derived state.
*/
type TransformCallback<DraftApi> = (draft: DraftApi) => Effect.Effect<void> | void
type TransformCallback<DraftApi> = (draft: DraftApi) => void
export type MakeDraft<State, DraftApi> = (state: State) => DraftApi
export interface Registration {
@@ -34,6 +34,7 @@ type Batch = {
const CurrentBatch = Context.Reference<Batch | undefined>("@opencode/State/CurrentBatch", {
defaultValue: () => undefined,
})
const reloadDebounce = 500
export function batch<A, E, R>(effect: Effect.Effect<A, E, R>) {
return Effect.gen(function* () {
@@ -73,6 +74,10 @@ export interface Interface<State, DraftApi> extends Transformable<DraftApi> {
export function create<State, DraftApi>(options: Options<State, DraftApi>): Interface<State, DraftApi> {
let state = options.initial()
let transforms: { run: TransformCallback<DraftApi> }[] = []
let generation = 0
let requestedAt = 0
let running = false
let waiters: { generation: number; done: Deferred.Deferred<void> }[] = []
const semaphore = Semaphore.makeUnsafe(1)
const commit = Effect.fn("State.commit")(function* (next: State) {
@@ -82,9 +87,8 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
})
const apply = (transform: TransformCallback<DraftApi>, draft: DraftApi) =>
Effect.suspend(() => {
const result = transform(draft)
return Effect.isEffect(result) ? Effect.asVoid(result).pipe(Effect.orDie) : Effect.void
Effect.sync(() => {
transform(draft)
})
const materialize = Effect.fnUntraced(function* () {
@@ -97,7 +101,39 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
yield* commit(next)
})
const reload = () => semaphore.withPermit(materialize())
const materializeReload = () => semaphore.withPermit(materialize())
const rebuild = (): Effect.Effect<void> =>
Effect.gen(function* () {
const clock = yield* Clock.Clock
const remaining = requestedAt + reloadDebounce - clock.currentTimeMillisUnsafe()
if (remaining > 0) yield* Effect.sleep(remaining)
if (clock.currentTimeMillisUnsafe() < requestedAt + reloadDebounce) return yield* rebuild()
const target = generation
const exit = yield* materializeReload().pipe(Effect.exit)
const completed = waiters.filter((waiter) => waiter.generation <= target)
waiters = waiters.filter((waiter) => waiter.generation > target)
yield* Effect.forEach(completed, (waiter) => Deferred.done(waiter.done, exit), {
concurrency: "unbounded",
discard: true,
})
if (generation > target) return yield* rebuild()
running = false
})
const reload = Effect.fnUntraced(function* () {
const done = Deferred.makeUnsafe<void>()
const clock = yield* Clock.Clock
generation++
requestedAt = clock.currentTimeMillisUnsafe()
waiters.push({ generation, done })
if (!running) {
running = true
yield* rebuild().pipe(Effect.forkDetach)
}
return yield* Deferred.await(done)
})
const result: Interface<State, DraftApi> = {
get: () => state,
@@ -117,7 +153,7 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
return Effect.gen(function* () {
const batch = yield* CurrentBatch
if (batch?.active) {
batch.reloads.add(reload)
batch.reloads.add(materializeReload)
return
}
yield* materialize()
@@ -132,8 +168,8 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
)
yield* Scope.addFinalizer(scope, dispose)
const batch = yield* CurrentBatch
if (batch?.active) batch.reloads.add(reload)
else yield* reload()
if (batch?.active) batch.reloads.add(materializeReload)
else yield* materializeReload()
return { dispose }
}),
)
+19 -1
View File
@@ -17,6 +17,7 @@ export const name = "shell"
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
export const MAX_TIMEOUT_MS = 10 * 60 * 1_000
export const MAX_CAPTURE_BYTES = 1024 * 1024
export const PROGRESS_LINES = 25
const BACKGROUND_STARTED = "The command was moved to the background."
const BACKGROUND_INSTRUCTION =
@@ -210,6 +211,23 @@ export const Plugin = {
}
})
const captureProgress = Effect.fn("ShellTool.captureProgress")(function* () {
const latest = yield* shell.output(info.id, { cursor: Number.MAX_SAFE_INTEGER })
const start = Math.max(0, latest.size - MAX_CAPTURE_BYTES)
const page = yield* shell.output(info.id, { cursor: start, limit: MAX_CAPTURE_BYTES })
const trailingNewline = page.output.endsWith("\n")
const lines = trailingNewline ? page.output.split("\n").slice(0, -1) : page.output.split("\n")
const truncated = start > 0 || lines.length > PROGRESS_LINES
const output = lines.slice(-PROGRESS_LINES).join("\n") + (trailingNewline ? "\n" : "")
const notice = truncated
? `[output truncated; showing last ${PROGRESS_LINES} lines. Full output saved to: ${info.file}]\n\n`
: ""
return {
output: `${notice}${output || "(no output)"}`,
truncated,
}
})
const settleShell = Effect.fn("ShellTool.settleShell")(function* () {
const final = yield* shell.wait(info.id)
@@ -258,7 +276,7 @@ export const Plugin = {
const progress = yield* Effect.sleep("1 second").pipe(
Effect.andThen(
captureShell().pipe(
captureProgress().pipe(
Effect.flatMap((capture) =>
context.progress({
structured: { truncated: capture.truncated },
+26 -4
View File
@@ -77,10 +77,7 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
commands: commands(info.command),
instructions: info.instructions,
references: info.references ?? info.reference,
experimental:
info.experimental?.subagent_depth === undefined
? undefined
: { subagent_depth: info.experimental.subagent_depth },
experimental: experimental(info),
plugins: info.plugin?.map((plugin) =>
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
),
@@ -88,6 +85,31 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
}
}
function experimental(info: typeof ConfigV1.Info.Type) {
const policies = [
...(info.enabled_providers === undefined
? []
: [
{ action: "provider.use" as const, resource: "*", effect: "deny" as const },
...info.enabled_providers.map((resource) => ({
action: "provider.use" as const,
resource,
effect: "allow" as const,
})),
]),
...(info.disabled_providers ?? []).map((resource) => ({
action: "provider.use" as const,
resource,
effect: "deny" as const,
})),
]
if (info.experimental?.subagent_depth === undefined && !policies.length) return
return {
subagent_depth: info.experimental?.subagent_depth,
policies: policies.length ? policies : undefined,
}
}
function permissions(info?: ConfigPermissionV1.Info, tools?: Readonly<Record<string, boolean>>) {
const rules: Array<{ action: string; resource: string; effect: ConfigPermissionV1.Action }> = Object.entries(
tools ?? {},
+35 -7
View File
@@ -1,10 +1,12 @@
export * as WellKnown from "./wellknown"
import { Integration } from "@opencode-ai/schema/integration"
import { Context, Effect, Layer, PubSub, Ref, Schema, Semaphore, Stream } from "effect"
import { Context, Effect, Layer, Ref, Schema, Semaphore } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { isDeepStrictEqual } from "node:util"
import { makeGlobalNode } from "./effect/app-node"
import { httpClient } from "./effect/app-node-platform"
import { EventV2 } from "./event"
import { KV } from "./kv"
export interface Auth extends Schema.Schema.Type<typeof Auth> {}
@@ -43,14 +45,18 @@ export interface Entry {
export interface Interface {
readonly entries: () => Effect.Effect<readonly Entry[], Error>
readonly snapshot: () => readonly Entry[]
readonly refresh: () => Effect.Effect<boolean, Error>
readonly add: (origin: string) => Effect.Effect<Entry, Error>
readonly remove: (origin: string) => Effect.Effect<void>
readonly changes: Stream.Stream<void>
readonly resolve: (entry: Entry, variables: Readonly<Record<string, string>>) => Effect.Effect<Config[], Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/WellKnown") {}
export const Event = {
Updated: EventV2.ephemeral({ type: "wellknown.updated", schema: {} }),
}
export const inspect = Effect.fn("WellKnown.inspect")(function* (origin: string) {
const url = `${origin.replace(/\/+$/, "")}/.well-known/opencode`
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
@@ -97,8 +103,8 @@ const layer = Layer.effect(
Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
const kv = yield* KV.Service
const events = yield* EventV2.Service
const cache = yield* Ref.make(new Map<string, Entry>())
const changes = yield* PubSub.unbounded<void>()
const lock = Semaphore.makeUnsafe(1)
const load = Effect.fn("WellKnown.load")(function* () {
@@ -117,9 +123,32 @@ const layer = Layer.effect(
return entries
})
const refresh = Effect.fn("WellKnown.refresh")(function* () {
return yield* lock.withPermit(
Effect.gen(function* () {
const value = yield* kv.get(sourcesKey)
const origins = Schema.is(Sources)(value) ? value : []
if (!origins.length) return false
const entries = yield* Effect.forEach(origins, (origin) =>
inspect(origin).pipe(
Effect.provideService(HttpClient.HttpClient, http),
Effect.map((manifest) => ({ origin, integrationID: Integration.ID.make(origin), manifest })),
),
)
const next = new Map(entries.map((entry) => [entry.origin, entry]))
const changed = !isDeepStrictEqual(Ref.getUnsafe(cache), next)
if (!changed) return false
yield* Ref.set(cache, next)
yield* events.publish(Event.Updated, {})
return true
}),
)
})
return Service.of({
entries: load,
snapshot: () => Array.from(Ref.getUnsafe(cache).values()),
refresh,
add: Effect.fn("WellKnown.add")(function* (value) {
return yield* lock.withPermit(
Effect.gen(function* () {
@@ -131,7 +160,7 @@ const layer = Layer.effect(
const origins = Schema.is(Sources)(sources) ? sources : []
yield* kv.set(sourcesKey, Array.from(new Set([...origins, origin])))
yield* Ref.update(cache, (current) => new Map(current).set(origin, entry))
yield* PubSub.publish(changes, undefined)
yield* events.publish(Event.Updated, {})
return entry
}),
)
@@ -151,11 +180,10 @@ const layer = Layer.effect(
next.delete(origin)
return next
})
yield* PubSub.publish(changes, undefined)
yield* events.publish(Event.Updated, {})
}),
)
}),
changes: Stream.fromPubSub(changes),
resolve: Effect.fn("WellKnown.resolveEntry")(function* (entry, variables) {
return yield* resolveEntry(entry, variables).pipe(Effect.provideService(HttpClient.HttpClient, http))
}),
@@ -163,4 +191,4 @@ const layer = Layer.effect(
}),
)
export const node = makeGlobalNode({ service: Service, layer, deps: [httpClient, KV.node] })
export const node = makeGlobalNode({ service: Service, layer, deps: [httpClient, KV.node, EventV2.node] })
+3 -1
View File
@@ -2,11 +2,13 @@ export * as WellKnownPlugin from "./plugin"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Effect, Stream } from "effect"
import { EventV2 } from "../event"
import { WellKnown } from "../wellknown"
export const Plugin = define({
id: "opencode.wellknown",
effect: Effect.fn(function* (ctx) {
const events = yield* EventV2.Service
const wellknown = yield* WellKnown.Service
yield* wellknown.entries().pipe(Effect.orDie)
yield* ctx.integration.transform((draft) => {
@@ -26,7 +28,7 @@ export const Plugin = define({
})
})
})
yield* wellknown.changes.pipe(
yield* events.subscribe(WellKnown.Event.Updated).pipe(
Stream.runForEach(() => ctx.integration.reload()),
Effect.forkScoped({ startImmediately: true }),
)
+4 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
import { TestClock } from "effect/testing"
import { AgentV2 } from "@opencode-ai/core/agent"
import { EventV2 } from "@opencode-ai/core/event"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -76,7 +77,9 @@ describe("AgentV2", () => {
)
description = "New description"
hidden = false
yield* agent.reload()
const reload = yield* agent.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(reload)
expect(yield* agent.get(id)).toMatchObject({ description: "New description", hidden: false })
}),
+4 -1
View File
@@ -1,6 +1,7 @@
import { describe, expect } from "bun:test"
import { Money } from "@opencode-ai/schema/money"
import { Effect, Fiber, Layer, Stream } from "effect"
import { TestClock } from "effect/testing"
import { Catalog } from "@opencode-ai/core/catalog"
import { Integration } from "@opencode-ai/core/integration"
import { Credential } from "@opencode-ai/core/credential"
@@ -261,7 +262,9 @@ describe("CatalogV2", () => {
expect((yield* catalog.model.default())?.id).toBe(old)
configured = false
yield* catalog.reload()
const reload = yield* catalog.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(reload)
expect((yield* catalog.model.default())?.id).toBe(newest)
}),
)
+21 -2
View File
@@ -53,9 +53,9 @@ const emptyWellknownNode = makeGlobalNode({
WellKnown.Service.of({
entries: () => Effect.succeed([]),
snapshot: () => [],
refresh: () => Effect.succeed(false),
add: () => Effect.die("unused Wellknown.add"),
remove: () => Effect.die("unused Wellknown.remove"),
changes: Stream.empty,
resolve: () => Effect.die("unused Wellknown.resolve"),
}),
),
@@ -216,9 +216,9 @@ describe("Config", () => {
WellKnown.Service.of({
entries: () => Effect.succeed([entry]),
snapshot: () => [entry],
refresh: () => Effect.succeed(false),
add: () => Effect.die("unused Wellknown.add"),
remove: () => Effect.die("unused Wellknown.remove"),
changes: Stream.empty,
resolve: (_entry, variables) => Effect.succeed([{ shell: variables.TOKEN }]),
}),
),
@@ -289,6 +289,25 @@ describe("Config", () => {
}),
)
it.effect("migrates v1 provider lists to policies", () =>
Effect.sync(() => {
expect(
ConfigMigrateV1.migrate({
enabled_providers: ["anthropic", "openai"],
disabled_providers: ["openai"],
}).experimental?.policies,
).toEqual([
{ action: "provider.use", resource: "*", effect: "deny" },
{ action: "provider.use", resource: "anthropic", effect: "allow" },
{ action: "provider.use", resource: "openai", effect: "allow" },
{ action: "provider.use", resource: "openai", effect: "deny" },
])
expect(ConfigMigrateV1.migrate({ enabled_providers: [] }).experimental?.policies).toEqual([
{ action: "provider.use", resource: "*", effect: "deny" },
])
}),
)
it.effect("migrates v1 provider setup options into AISDK settings", () =>
Effect.sync(() => {
const migrated = ConfigMigrateV1.migrate({
+95
View File
@@ -0,0 +1,95 @@
import { describe, expect } from "bun:test"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { Catalog } from "@opencode-ai/core/catalog"
import { Config } from "@opencode-ai/core/config"
import { ConfigPolicyPlugin } from "@opencode-ai/core/config/plugin/policy"
import { EventV2 } from "@opencode-ai/core/event"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Effect, Schema } from "effect"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "../plugin/fixture"
const it = testEffect(PluginTestLayer)
const decode = Schema.decodeUnknownSync(Config.Info)
const policies = (...items: { effect: "allow" | "deny"; resource: string }[]) =>
new Config.Document({
type: "document",
info: decode({
experimental: {
policies: items.map((item) => ({ action: "provider.use", ...item })),
},
}),
})
const addPlugin = Effect.fn(function* (entries: () => Config.Entry[]) {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make(plugin)
yield* ConfigPolicyPlugin.Plugin.effect(host).pipe(
Effect.provideService(Config.Service, Config.Service.of({ entries: () => Effect.sync(entries) })),
)
})
describe("ConfigPolicyPlugin.Plugin", () => {
it.effect("filters plugin-provided providers with ordered wildcard policies", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
catalog.provider.update(ProviderV2.ID.openai, () => {})
catalog.provider.update(ProviderV2.ID.anthropic, () => {})
catalog.provider.update(ProviderV2.ID.make("company-internal"), () => {})
})
yield* addPlugin(() => [
policies(
{ effect: "deny", resource: "*" },
{ effect: "allow", resource: "anthropic" },
{ effect: "allow", resource: "company-*" },
),
])
expect(yield* catalog.provider.get(ProviderV2.ID.openai)).toBeUndefined()
expect(yield* catalog.provider.get(ProviderV2.ID.anthropic)).toBeDefined()
expect(yield* catalog.provider.get(ProviderV2.ID.make("company-internal"))).toBeDefined()
}),
)
it.effect("prevents project policy from overriding user-global policy", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.openai, () => {}))
yield* addPlugin(() => [
policies({ effect: "deny", resource: "openai" }),
policies({ effect: "allow", resource: "openai" }),
])
expect(yield* catalog.provider.get(ProviderV2.ID.openai)).toBeUndefined()
}),
)
it.live("reloads changed policies", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const events = yield* EventV2.Service
let entries: Config.Entry[] = [policies({ effect: "deny", resource: "openai" })]
yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.openai, () => {}))
yield* addPlugin(() => entries)
expect(yield* catalog.provider.get(ProviderV2.ID.openai)).toBeUndefined()
entries = [policies({ effect: "allow", resource: "openai" })]
yield* events.publish(ConfigSchema.Event.Updated, {})
yield* waitUntil(
catalog.provider.get(ProviderV2.ID.openai).pipe(Effect.map((provider) => provider !== undefined)),
)
}),
)
})
const waitUntil = Effect.fnUntraced(function* (condition: Effect.Effect<boolean>) {
for (let attempt = 0; attempt < 200; attempt++) {
if (yield* condition) return
yield* Effect.sleep("10 millis")
}
return yield* Effect.die("Timed out waiting for policy reload")
})
@@ -63,6 +63,11 @@ const instructionEvents = (db: Database.Interface["db"], sessionID: SessionSchem
.all()
.pipe(Effect.orDie)
const preview = (db: Database.Interface["db"], sessionID: SessionSchema.ID, instructions: Instructions.Instructions) =>
Instructions.read(instructions).pipe(
Effect.flatMap((observed) => InstructionState.preview(db, sessionID, instructions, observed)),
)
describe("InstructionState", () => {
it.effect("observes each source once without publishing events or inserting blobs", () =>
Effect.gen(function* () {
@@ -227,6 +232,134 @@ describe("InstructionState", () => {
}),
)
it.effect("assembles a fresh private update without repairing a missing cache", () =>
Effect.gen(function* () {
const sessionID = SessionSchema.ID.make("ses_instruction_generate")
const { db, events } = yield* setup(sessionID)
let value = "Initial context"
const instructions = source(
"test/context",
Effect.sync(() => value),
)
yield* InstructionState.prepare(db, events, instructions, sessionID)
yield* db
.delete(InstructionStateTable)
.where(eq(InstructionStateTable.session_id, sessionID))
.run()
.pipe(Effect.orDie)
value = "Changed context"
const beforeEvents = yield* instructionEvents(db, sessionID)
const beforeBlobs = yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)
const assembled = yield* preview(db, sessionID, instructions)
expect(assembled).toEqual({ initial: "Initial context", updates: [], update: "Changed context" })
expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents)
expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs)
expect(
yield* db
.select()
.from(InstructionStateTable)
.where(eq(InstructionStateTable.session_id, sessionID))
.get()
.pipe(Effect.orDie),
).toBeUndefined()
}),
)
it.effect("reads through a stale cache without repairing it", () =>
Effect.gen(function* () {
const sessionID = SessionSchema.ID.make("ses_instruction_generate_stale")
const { db, events } = yield* setup(sessionID)
let value = "Initial context"
const instructions = source(
"test/context",
Effect.sync(() => value),
)
yield* InstructionState.prepare(db, events, instructions, sessionID)
value = "Committed update"
yield* InstructionState.prepare(db, events, instructions, sessionID)
yield* db
.update(InstructionStateTable)
.set({ through_seq: 0, current_values: { "test/context": Instructions.hash("Initial context") } })
.where(eq(InstructionStateTable.session_id, sessionID))
.run()
.pipe(Effect.orDie)
value = "Private update"
const beforeEvents = yield* instructionEvents(db, sessionID)
const beforeBlobs = yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)
const beforeState = yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)
const assembled = yield* preview(db, sessionID, instructions)
expect(assembled.initial).toBe("Initial context")
expect(assembled.updates.map((entry) => entry.message.text)).toEqual(["Committed update"])
expect(assembled.update).toBe("Private update")
expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents)
expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs)
expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toEqual(beforeState)
}),
)
it.effect("assembles initial instructions without persisting a baseline", () =>
Effect.gen(function* () {
const sessionID = SessionSchema.ID.make("ses_instruction_generate_initial")
const { db } = yield* setup(sessionID)
const instructions = source("test/context", Effect.succeed("Initial context"))
expect(yield* preview(db, sessionID, instructions)).toEqual({
initial: "Initial context",
updates: [],
update: "",
})
expect(yield* instructionEvents(db, sessionID)).toEqual([])
expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual([])
expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toBeUndefined()
}),
)
it.effect("retains a committed value when fresh instructions are unavailable", () =>
Effect.gen(function* () {
const sessionID = SessionSchema.ID.make("ses_instruction_generate_unavailable")
const { db, events } = yield* setup(sessionID)
let value: string | Instructions.Unavailable = "Committed context"
const instructions = source(
"test/context",
Effect.sync(() => value),
)
yield* InstructionState.prepare(db, events, instructions, sessionID)
value = Instructions.unavailable
const beforeEvents = yield* instructionEvents(db, sessionID)
const beforeBlobs = yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)
const beforeState = yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)
expect(yield* preview(db, sessionID, instructions)).toEqual({
initial: "Committed context",
updates: [],
update: "",
})
expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents)
expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs)
expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toEqual(beforeState)
}),
)
it.effect("blocks an unavailable initial instruction without persisting a baseline", () =>
Effect.gen(function* () {
const sessionID = SessionSchema.ID.make("ses_instruction_generate_blocked")
const { db } = yield* setup(sessionID)
const instructions = source("test/context", Effect.succeed(Instructions.unavailable))
const error = yield* preview(db, sessionID, instructions).pipe(Effect.flip)
expect(error).toBeInstanceOf(Instructions.InitializationBlocked)
expect(error.keys).toEqual([Instructions.Key.make("test/context")])
expect(yield* instructionEvents(db, sessionID)).toEqual([])
expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual([])
expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toBeUndefined()
}),
)
it.effect("keeps prepare equivalent to observe followed by commit", () =>
Effect.gen(function* () {
const observedSessionID = SessionSchema.ID.make("ses_instruction_composed")
+303
View File
@@ -0,0 +1,303 @@
import { expect } from "bun:test"
import { LLMClient, LLMEvent, LLMResponse, Model, SystemPart, type LLMRequest } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { llmClient } from "@opencode-ai/core/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { EventTable } from "@opencode-ai/core/event/sql"
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
import { Instructions } from "@opencode-ai/core/instructions"
import { InstructionBuiltIns } from "@opencode-ai/core/instructions/builtins"
import { Location } from "@opencode-ai/core/location"
import { McpInstructions } from "@opencode-ai/core/mcp/instructions"
import { ModelV2 } from "@opencode-ai/core/model"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ReferenceInstructions } from "@opencode-ai/core/reference/instructions"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionGenerate } from "@opencode-ai/core/session/generate"
import { SessionGenerateNode } from "@opencode-ai/core/session/generate-node"
import { InstructionState } from "@opencode-ai/core/session/instruction-state"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionSchema } from "@opencode-ai/core/session/schema"
import {
InstructionBlobTable,
InstructionStateTable,
SessionMessageTable,
SessionPendingTable,
SessionTable,
} from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { SkillInstructions } from "@opencode-ai/core/skill/instructions"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { asc, eq } from "drizzle-orm"
import { Effect, Layer, Schema, Stream } from "effect"
import { testEffect } from "./lib/effect"
const requests: LLMRequest[] = []
let instruction: string | Instructions.Unavailable = "Initial context"
const sessionID = SessionSchema.ID.make("ses_generate_test")
const model = Model.make({ id: "generate-model", provider: "test", route: OpenAIChat.route })
const client = Layer.mock(LLMClient.Service)({
prepare: () => Effect.die(new Error("unused")),
stream: () => Stream.die(new Error("unused")),
generate: (request) =>
Effect.sync(() => {
requests.push(request)
const response = LLMResponse.fromEvents([
LLMEvent.stepStart({ index: 0 }),
LLMEvent.textStart({ id: "generate" }),
LLMEvent.textDelta({ id: "generate", text: "Transient answer" }),
LLMEvent.textEnd({ id: "generate" }),
LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 100, outputTokens: 10 } }),
LLMEvent.finish({ reason: "stop" }),
])
if (!response) throw new Error("Incomplete generate response")
return response
}),
})
const models = SessionRunnerModel.layerWith(() => Effect.succeed(SessionRunnerModel.resolved(model)))
const builtins = Layer.mock(InstructionBuiltIns.Service, {
load: () =>
Effect.succeed(
Instructions.make({
key: Instructions.Key.make("test/context"),
codec: Schema.toCodecJson(Schema.String),
read: Effect.sync(() => instruction),
render: {
initial: String,
changed: (_previous, current) => current,
},
}),
),
})
const discovery = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) })
const skills = Layer.mock(SkillInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const references = Layer.mock(ReferenceInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const mcp = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
const plugins = Layer.mock(PluginSupervisor.Service, { flush: Effect.void })
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
Database.node,
EventV2.node,
SessionProjector.node,
SessionStore.node,
AgentV2.node,
InstructionBuiltIns.node,
PluginHooks.node,
SessionGenerateNode.node,
]),
[
[llmClient, client],
[SessionRunnerModel.node, models],
[InstructionBuiltIns.node, builtins],
[InstructionDiscovery.node, discovery],
[SkillInstructions.node, skills],
[ReferenceInstructions.node, references],
[McpInstructions.node, mcp],
[PluginSupervisor.node, plugins],
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
],
),
)
const durableState = (db: Database.Interface["db"], sessionID: SessionSchema.ID) =>
Effect.all({
sequence: EventV2.latestSequence(db, sessionID),
events: db
.select()
.from(EventTable)
.where(eq(EventTable.aggregate_id, sessionID))
.orderBy(asc(EventTable.seq))
.all()
.pipe(Effect.orDie),
messages: db
.select()
.from(SessionMessageTable)
.where(eq(SessionMessageTable.session_id, sessionID))
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie),
pending: db
.select()
.from(SessionPendingTable)
.where(eq(SessionPendingTable.session_id, sessionID))
.orderBy(asc(SessionPendingTable.admitted_seq))
.all()
.pipe(Effect.orDie),
instructions: db
.select()
.from(InstructionStateTable)
.where(eq(InstructionStateTable.session_id, sessionID))
.get()
.pipe(Effect.orDie),
blobs: db.select().from(InstructionBlobTable).orderBy(asc(InstructionBlobTable.hash)).all().pipe(Effect.orDie),
session: db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie),
})
const userTexts = (request: LLMRequest) =>
request.messages.flatMap((message) =>
message.role === "user"
? message.content.flatMap((content) => (content.type === "text" ? [content.text] : []))
: [],
)
const setup = Effect.gen(function* () {
const { db } = yield* Database.Service
const events = yield* EventV2.Service
const agents = yield* AgentV2.Service
const instructionBuiltIns = yield* InstructionBuiltIns.Service
yield* agents.transform((draft) =>
draft.update(AgentV2.ID.make("build"), (agent) => {
agent.mode = "primary"
}),
)
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "generate-test",
directory: "/project",
title: "Generate test",
version: "test",
agent: AgentV2.ID.make("build"),
})
.run()
.pipe(Effect.orDie)
return { db, events, instructions: yield* instructionBuiltIns.load(sessionID) }
})
it.effect("generates from fresh settled Session context without durable mutation", () =>
Effect.gen(function* () {
requests.length = 0
instruction = "Initial context"
const { db, events, instructions } = yield* setup
yield* InstructionState.prepare(db, events, instructions, sessionID)
const existing = SessionMessage.ID.create()
yield* events.publish(SessionEvent.InputAdmitted, {
sessionID,
inputID: existing,
input: { type: "user", data: { text: "Existing durable context" }, delivery: "steer" },
})
yield* events.publish(SessionEvent.InputPromoted, { sessionID, inputID: existing })
const settledAssistant = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID: settledAssistant,
agent: AgentV2.ID.make("build"),
model: { id: ModelV2.ID.make("generate-model"), providerID: ProviderV2.ID.make("test") },
})
yield* events.publish(SessionEvent.Text.Started, {
sessionID,
assistantMessageID: settledAssistant,
ordinal: 0,
})
yield* events.publish(SessionEvent.Text.Ended, {
sessionID,
assistantMessageID: settledAssistant,
ordinal: 0,
text: "Settled partial answer",
})
const activeAssistant = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID: activeAssistant,
agent: AgentV2.ID.make("build"),
model: { id: ModelV2.ID.make("generate-model"), providerID: ProviderV2.ID.make("test") },
})
yield* events.publish(SessionEvent.Tool.Input.Started, {
sessionID,
assistantMessageID: activeAssistant,
callID: "active-call",
name: "echo",
})
yield* events.publish(SessionEvent.Tool.Input.Ended, {
sessionID,
assistantMessageID: activeAssistant,
callID: "active-call",
text: "{}",
})
yield* events.publish(SessionEvent.Tool.Called, {
sessionID,
assistantMessageID: activeAssistant,
callID: "active-call",
input: {},
executed: false,
})
yield* events.publish(SessionEvent.InputAdmitted, {
sessionID,
inputID: SessionMessage.ID.create(),
input: { type: "user", data: { text: "Queued input must remain invisible" }, delivery: "queue" },
})
instruction = "Changed context"
const before = yield* durableState(db, sessionID)
const hooks = yield* PluginHooks.Service
yield* hooks.register("session", "context", (event) =>
Effect.sync(() => {
event.system = [SystemPart.make("Hooked system"), ...event.system]
}),
)
const generate = yield* SessionGenerate.Service
const result = yield* generate.generate({ sessionID, prompt: "Summarize privately" })
expect(result).toBe("Transient answer")
expect(requests).toHaveLength(1)
expect(requests[0]?.model).toBe(model)
expect(requests[0]?.system[0]?.text).toBe("Hooked system")
expect(requests[0]?.system.map((part) => part.text)).toContain("Initial context")
expect(requests[0]?.http?.headers).toMatchObject({ "X-Session-Id": sessionID })
expect(requests[0]?.providerOptions).toMatchObject({ openai: { promptCacheKey: sessionID } })
expect(
requests[0]?.messages.flatMap((message) =>
message.role === "system"
? message.content.flatMap((content) => (content.type === "text" ? [content.text] : []))
: [],
),
).toEqual(["Changed context"])
expect(userTexts(requests[0])).toEqual(["Existing durable context", "Summarize privately"])
expect(
requests[0]?.messages.flatMap((message) =>
message.role === "assistant"
? message.content.flatMap((content) => (content.type === "text" ? [content.text] : []))
: [],
),
).toEqual(["Settled partial answer"])
expect(requests[0]?.tools).toEqual([])
expect(requests[0]?.toolChoice).toMatchObject({ type: "none" })
expect(yield* durableState(db, sessionID)).toEqual(before)
}),
)
it.effect("blocks unavailable initial instructions before generation", () =>
Effect.gen(function* () {
requests.length = 0
instruction = Instructions.unavailable
const { db } = yield* setup
const before = yield* durableState(db, sessionID)
const generate = yield* SessionGenerate.Service
const error = yield* generate.generate({ sessionID, prompt: "Summarize privately" }).pipe(Effect.flip)
expect(error).toBeInstanceOf(Instructions.InitializationBlocked)
expect(requests).toEqual([])
expect(yield* durableState(db, sessionID)).toEqual(before)
}),
)
+34 -7
View File
@@ -1,6 +1,7 @@
import { describe, expect } from "bun:test"
import { State } from "@opencode-ai/core/state"
import { Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
import { TestClock } from "effect/testing"
import { testEffect } from "./lib/effect"
const it = testEffect(Layer.empty)
@@ -35,7 +36,7 @@ describe("State", () => {
}),
)
it.effect("runs effectful transforms during every reload", () =>
it.effect("runs transforms during every reload", () =>
Effect.gen(function* () {
let value = "first"
const state = State.create({
@@ -43,15 +44,15 @@ describe("State", () => {
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
})
yield* state.transform((editor) =>
Effect.sync(() => {
editor.add(value)
}),
)
yield* state.transform((editor) => {
editor.add(value)
})
expect(state.get().values).toEqual(["first"])
value = "second"
yield* state.reload()
const reload = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("500 millis")
yield* Fiber.join(reload)
expect(state.get().values).toEqual(["second"])
}),
)
@@ -112,4 +113,30 @@ describe("State", () => {
expect(finalized).toBe(2)
}),
)
it.effect("debounces reload bursts", () =>
Effect.gen(function* () {
let finalized = 0
const state = State.create({
initial: () => ({ values: [] as string[] }),
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
finalize: () => Effect.sync(() => finalized++),
})
yield* state.transform((draft) => {
draft.add("value")
})
finalized = 0
const first = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("250 millis")
const second = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
yield* TestClock.adjust("499 millis")
expect(finalized).toBe(0)
yield* TestClock.adjust("1 millis")
yield* Fiber.join(first)
yield* Fiber.join(second)
expect(finalized).toBe(1)
}),
)
})
+11 -8
View File
@@ -166,10 +166,10 @@ const overflowCommand = (bytes: number) =>
isWindows
? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 100`
: `head -c ${bytes} /dev/zero | tr '\\0' 'x'`
const progressOverflowCommand = (bytes: number) =>
const progressLinesCommand = (lines: number) =>
isWindows
? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 1500`
: `head -c ${bytes} /dev/zero | tr '\\0' 'x'; sleep 1.5`
? `1..${lines} | ForEach-Object { [Console]::Out.WriteLine(('line {0:d2}' -f $_)) }; Start-Sleep -Milliseconds 1500`
: `i=1; while [ $i -le ${lines} ]; do printf 'line %02d\\n' "$i"; i=$((i+1)); done; sleep 1.5`
const withSession = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) =>
Effect.gen(function* () {
@@ -417,17 +417,17 @@ describe("ShellTool", () => {
),
)
it.live("reports bounded output progress for a running command", () =>
it.live("reports the latest output lines for a running command", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const bytes = ShellTool.MAX_CAPTURE_BYTES + 1024
const lines = ShellTool.PROGRESS_LINES + 10
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const progress: ToolRegistry.Progress[] = []
yield* settleTool(registry, {
...call({ command: progressOverflowCommand(bytes) }, "call-progress"),
...call({ command: progressLinesCommand(lines) }, "call-progress"),
progress: (update) => Effect.sync(() => progress.push(update)),
})
@@ -436,9 +436,12 @@ describe("ShellTool", () => {
const content = progress[0]?.content[0]
expect(content?.type).toBe("text")
if (content?.type !== "text") return
expect(content.text.indexOf("\n\n[output truncated; full output saved to:")).toBe(
ShellTool.MAX_CAPTURE_BYTES,
expect(content.text).toStartWith(
`[output truncated; showing last ${ShellTool.PROGRESS_LINES} lines. Full output saved to:`,
)
expect(content.text).not.toContain("line 10\n")
expect(content.text).toContain("line 11\n")
expect(content.text).toContain(`line ${lines}\n`)
}),
)
},
+39 -2
View File
@@ -3,11 +3,12 @@ import { Effect, Fiber, Stream } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { KV } from "@opencode-ai/core/kv"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { WellKnown } from "@opencode-ai/core/wellknown"
import { testEffect } from "./lib/effect"
const it = testEffect(FetchHttpClient.layer)
const serviceIt = testEffect(LayerNode.compile(LayerNode.group([WellKnown.node, KV.node])))
const serviceIt = testEffect(LayerNode.compile(LayerNode.group([WellKnown.node, KV.node, EventV2.node])))
it.live("loads embedded and remote configuration", () =>
Effect.acquireUseRelease(
@@ -65,7 +66,10 @@ serviceIt.live("persists sources in one KV value", () =>
Effect.gen(function* () {
const wellknown = yield* WellKnown.Service
const kv = yield* KV.Service
const changed = yield* wellknown.changes.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
const events = yield* EventV2.Service
const changed = yield* events
.subscribe(WellKnown.Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
const entry = yield* wellknown.add(`${server.url.origin}/`)
expect(entry.origin).toBe(server.url.origin)
@@ -80,3 +84,36 @@ serviceIt.live("persists sources in one KV value", () =>
(server) => Effect.promise(() => server.stop(true)),
),
)
serviceIt.live("refreshes changed manifests", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
let command = "first"
return {
server: Bun.serve({
port: 0,
fetch: () => Response.json({ auth: { command: [command], env: "TOKEN" } }),
}),
update: () => {
command = "second"
},
}
}),
({ server, update }) =>
Effect.gen(function* () {
const wellknown = yield* WellKnown.Service
const events = yield* EventV2.Service
yield* wellknown.add(server.url.origin)
expect(yield* wellknown.refresh()).toBe(false)
const changed = yield* events
.subscribe(WellKnown.Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
update()
expect(yield* wellknown.refresh()).toBe(true)
expect(yield* Fiber.join(changed)).toHaveLength(1)
expect(wellknown.snapshot()[0]?.manifest.auth?.command).toEqual(["second"])
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
)
+8 -4
View File
@@ -134,11 +134,13 @@ export const Definitions = {
messages_line_down: keybind("ctrl+alt+e", "Scroll messages down by one line"),
messages_half_page_up: keybind("ctrl+alt+u", "Scroll messages up by half page"),
messages_half_page_down: keybind("ctrl+alt+d", "Scroll messages down by half page"),
messages_first: keybind("ctrl+g,home", "Navigate to first message"),
messages_first: keybind("ctrl+g,home,alt+home", "Navigate to first message"),
messages_last: keybind("ctrl+alt+g,end", "Navigate to last message"),
messages_next: keybind("none", "Navigate to next message"),
messages_previous: keybind("none", "Navigate to previous message"),
messages_last_user: keybind("none", "Navigate to last user message"),
messages_next: keybind("alt+down", "Navigate to next message"),
messages_previous: keybind("alt+up", "Navigate to previous message"),
messages_next_user: keybind("alt+shift+down", "Navigate to next user message"),
messages_previous_user: keybind("alt+shift+up", "Navigate to previous user message"),
messages_last_user: keybind("alt+end", "Navigate to last user message"),
messages_copy: keybind("<leader>y", "Copy message"),
messages_undo: keybind("<leader>u", "Undo message"),
messages_redo: keybind("<leader>r", "Redo message"),
@@ -335,6 +337,8 @@ export const CommandMap = {
messages_last: "session.last",
messages_next: "session.message.next",
messages_previous: "session.message.previous",
messages_next_user: "session.message.user.next",
messages_previous_user: "session.message.user.previous",
messages_last_user: "session.messages_last_user",
messages_copy: "messages.copy",
messages_undo: "session.undo",
+22 -11
View File
@@ -1,4 +1,5 @@
import { createStore } from "solid-js/store"
import { dedupeWith } from "effect/Array"
import { createSimpleContext } from "./helper"
import { batch, createEffect, createMemo } from "solid-js"
import { useEvent } from "./event"
@@ -54,7 +55,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const data = useData()
const client = useClient()
const toast = useToast()
const { theme, themeV2 } = useTheme()
const { theme, themeV2, mode } = useTheme()
const route = useRoute()
const paths = useTuiPaths()
const args = useArgs()
@@ -83,15 +84,20 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const [agentStore, setAgentStore] = createStore({
current: undefined as string | undefined,
})
const colors = createMemo(() => [
themeV2.hue.accent(500),
theme.accent,
theme.success,
theme.warning,
theme.primary,
theme.error,
theme.info,
])
const colors = createMemo(() => {
const step = mode() === "light" ? 800 : 200
return dedupeWith(
[
themeV2.hue.blue(step),
themeV2.hue.purple(step),
themeV2.hue.green(step),
themeV2.hue.orange(step),
themeV2.hue.red(step),
themeV2.hue.cyan(step),
],
(first, second) => first.equals(second),
)
})
return {
list() {
return agents()
@@ -441,7 +447,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
const slots = createMemo(() => {
const existing = new Set(data.session.list().filter((x) => x.parentID === undefined).map((x) => x.id))
const existing = new Set(
data.session
.list()
.filter((x) => x.parentID === undefined)
.map((x) => x.id),
)
return sessionStore.pinned.filter((id) => existing.has(id)).slice(0, 9)
})
@@ -13,7 +13,6 @@ export function ShellTab(props: { sessionID: string }) {
const location = useLocation()
const client = useClient()
const { themeV2 } = useTheme()
const fg = themeV2.text.action.primary("focused")
const composer = useComposerTab()
const shortcuts = Keymap.useShortcuts()
@@ -96,11 +95,7 @@ export function ShellTab(props: { sessionID: string }) {
return (
<Show when={composer.active("shell")}>
<scrollbox
scrollbarOptions={{ visible: false }}
maxHeight={5}
ref={(r: ScrollBoxRenderable) => (scroll = r)}
>
<scrollbox scrollbarOptions={{ visible: false }} maxHeight={5} ref={(r: ScrollBoxRenderable) => (scroll = r)}>
<Show when={entries().length > 0} fallback={<text fg={themeV2.text.subdued()}> No shell commands</text>}>
<For each={entries()}>
{(shell, index) => {
@@ -110,11 +105,11 @@ export function ShellTab(props: { sessionID: string }) {
flexDirection="row"
paddingLeft={1}
paddingRight={1}
backgroundColor={active() ? themeV2.background.action.primary() : RGBA.fromInts(0, 0, 0, 0)}
backgroundColor={active() ? themeV2.background.action.primary("selected") : RGBA.fromInts(0, 0, 0, 0)}
onMouseOver={() => setStore("selected", index())}
>
<text
fg={active() ? fg : themeV2.text()}
fg={themeV2.text.action.primary(active() ? "focused" : "default")}
attributes={active() ? TextAttributes.BOLD : undefined}
wrapMode="none"
>
@@ -22,7 +22,6 @@ export function SubagentsTab(props: { sessionID: string }) {
const data = useData()
const client = useClient()
const { themeV2 } = useTheme()
const fg = themeV2.text.action.primary("focused")
const navigate = useRoute().navigate
const composer = useComposerTab()
const shortcuts = Keymap.useShortcuts()
@@ -39,7 +38,11 @@ export function SubagentsTab(props: { sessionID: string }) {
const siblings = data.session.list().filter((s) => s.parentID === current.parentID)
for (const sibling of siblings) {
const agentMatch = sibling.title.match(/@(\w+) subagent/)
const agent = sibling.agent ? Locale.titlecase(sibling.agent) : agentMatch ? Locale.titlecase(agentMatch[1]) : "Subagent"
const agent = sibling.agent
? Locale.titlecase(sibling.agent)
: agentMatch
? Locale.titlecase(agentMatch[1])
: "Subagent"
const name = agentMatch ? sibling.title.replace(agentMatch[0], "").trim() || sibling.title : sibling.title
result.push({
sessionID: sibling.id,
@@ -53,7 +56,11 @@ export function SubagentsTab(props: { sessionID: string }) {
const children = data.session.list().filter((s) => s.parentID === props.sessionID)
for (const child of children) {
const agentMatch = child.title.match(/@(\w+) subagent/)
const agent = child.agent ? Locale.titlecase(child.agent) : agentMatch ? Locale.titlecase(agentMatch[1]) : "Subagent"
const agent = child.agent
? Locale.titlecase(child.agent)
: agentMatch
? Locale.titlecase(agentMatch[1])
: "Subagent"
const name = agentMatch ? child.title.replace(agentMatch[0], "").trim() || child.title : child.title
result.push({
sessionID: child.id,
@@ -195,11 +202,7 @@ export function SubagentsTab(props: { sessionID: string }) {
return (
<Show when={composer.active("subagents")}>
<scrollbox
scrollbarOptions={{ visible: false }}
maxHeight={5}
ref={(r: ScrollBoxRenderable) => (scroll = r)}
>
<scrollbox scrollbarOptions={{ visible: false }} maxHeight={5} ref={(r: ScrollBoxRenderable) => (scroll = r)}>
<Show when={entries().length > 0} fallback={<text fg={themeV2.text.subdued()}> No subagents</text>}>
<For each={entries()}>
{(entry, index) => {
@@ -213,7 +216,7 @@ export function SubagentsTab(props: { sessionID: string }) {
flexDirection="row"
paddingLeft={1}
paddingRight={1}
backgroundColor={active() ? themeV2.background.action.primary() : RGBA.fromInts(0, 0, 0, 0)}
backgroundColor={themeV2.background.action.primary(active() ? "focused" : "default")}
onMouseOver={() => setStore("selected", index())}
onMouseUp={() => {
setStore("selected", index())
@@ -222,7 +225,9 @@ export function SubagentsTab(props: { sessionID: string }) {
>
<box flexGrow={1} minWidth={0} flexDirection="row">
<text
fg={active() ? fg : entry.current ? themeV2.background.action.primary() : themeV2.text()}
fg={themeV2.text.action.primary(
active() ? "focused" : entry.current ? "selected" : "default",
)}
attributes={active() ? TextAttributes.BOLD : undefined}
wrapMode="none"
>
@@ -230,7 +235,7 @@ export function SubagentsTab(props: { sessionID: string }) {
</text>
</box>
<Show when={status()}>
<text fg={active() ? fg : themeV2.text.subdued()} wrapMode="none">
<text fg={active() ? themeV2.text.action.primary() : themeV2.text.subdued()} wrapMode="none">
{status()}
</text>
</Show>
+100 -49
View File
@@ -71,11 +71,15 @@ import { usePluginRuntime } from "../../plugin/runtime"
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap"
import { usePathFormatter } from "../../context/path-format"
import { useLocation } from "../../context/location"
import { createSessionRows, resolvePart, type PartRef, type SessionRow } from "./rows"
import { createSessionRows, messageBoundaryIDs, resolvePart, type PartRef, type SessionRow } from "./rows"
import { switchLabel } from "../../util/model"
import { findMessageBoundary, messageNavigationSlack } from "./message-navigation"
addDefaultParsers(parsers.parsers)
// Exclude temporary bottom space when measuring the real transcript height.
const NAVIGATION_SLACK_ID = "session-navigation-slack"
const context = createContext<{
width: number
sessionID: string
@@ -180,6 +184,23 @@ export function Session() {
const client = useClient()
const editor = useEditorContext()
const rows = createSessionRows(() => route.sessionID)
const boundaries = createMemo(() => messageBoundaryIDs(rows, messages()))
const [navigationMessage, setNavigationMessage] = createSignal<string>()
const [navigationSlack, setNavigationSlack] = createSignal(0)
const clearMessageNavigation = () => {
setNavigationSlack(0)
setNavigationMessage(undefined)
}
createEffect(
on(
() => [dimensions().width, dimensions().height] as const,
(_, previous) => {
if (previous) clearMessageNavigation()
},
),
)
createEffect(
on([descendantSessionIDs, () => client.connection.status()], ([sessionIDs, status]) => {
@@ -239,53 +260,55 @@ export function Session() {
dialog.clear()
}
// Helper: Find next visible message boundary in direction
const findNextVisibleMessage = (direction: "next" | "prev"): string | null => {
const children = scroll.getChildren()
const messagesList = messages()
const scrollTop = scroll.y
// Get visible messages sorted by position, filtering for valid non-synthetic, non-ignored content
const visibleMessages = children
.filter((c) => {
if (!c.id) return false
const message = messagesList.find((m) => m.id === c.id)
if (!message) return false
if (message.type === "user") return Boolean(message.text.trim())
return (
message.type === "assistant" &&
message.content.some((content) => content.type === "text" && content.text.trim())
)
const alignMessage = (messageID: string, top: number) => {
scroll.stickyScroll = false
setNavigationMessage(messageID)
setNavigationSlack(
messageNavigationSlack({
top,
viewportHeight: scroll.viewport.height,
scrollHeight: scroll.scrollHeight,
currentSlack: scroll.getRenderable(NAVIGATION_SLACK_ID)?.height ?? 0,
}),
)
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (scroll.isDestroyed || navigationMessage() !== messageID) return
scroll.scrollTo(top)
})
.sort((a, b) => a.y - b.y)
if (visibleMessages.length === 0) return null
if (direction === "next") {
// Find first message below current position
return visibleMessages.find((c) => c.y > scrollTop + 10)?.id ?? null
}
// Find last message above current position
return [...visibleMessages].reverse().find((c) => c.y < scrollTop - 10)?.id ?? null
})
}
// Helper: Scroll to message in direction or fallback to page scroll
const scrollToMessage = (direction: "next" | "prev", dialog: ReturnType<typeof useDialog>) => {
const targetID = findNextVisibleMessage(direction)
const scrollToMessage = (direction: "next" | "prev", dialog: ReturnType<typeof useDialog>, userOnly = false) => {
const target = findMessageBoundary({
direction,
children: scroll.getChildren(),
messages: messages(),
scrollTop: scroll.scrollTop,
viewportY: scroll.viewport.y,
currentID: navigationMessage(),
userOnly,
})
if (!targetID) {
scroll.scrollBy(direction === "next" ? scroll.height : -scroll.height)
if (!target) {
dialog.clear()
return
}
const child = scroll.getChildren().find((c) => c.id === targetID)
if (child) scroll.scrollBy(child.y - scroll.y - 1)
alignMessage(target.id, target.top)
dialog.clear()
}
const jumpToMessage = (messageID: string) => {
const child = scroll.getRenderable(messageID)
if (!child) return
const y = scroll.scrollTop + child.y - scroll.viewport.y
const message = data.session.message.get(route.sessionID, messageID)
alignMessage(messageID, Math.max(0, y - (message?.type === "assistant" ? 1 : 0)))
}
function toBottom() {
clearMessageNavigation()
setTimeout(() => {
if (!scroll || scroll.isDestroyed) return
scroll.scrollTo(scroll.scrollHeight)
@@ -299,6 +322,7 @@ export function Session() {
category: "Session",
hidden: true,
run: () => {
clearMessageNavigation()
scroll.scrollBy(-scroll.height / 2)
dialog.clear()
},
@@ -309,6 +333,7 @@ export function Session() {
category: "Session",
hidden: true,
run: () => {
clearMessageNavigation()
scroll.scrollBy(scroll.height / 2)
dialog.clear()
},
@@ -319,6 +344,7 @@ export function Session() {
category: "Session",
hidden: true,
run: () => {
clearMessageNavigation()
scroll.scrollBy(-1)
dialog.clear()
},
@@ -329,6 +355,7 @@ export function Session() {
category: "Session",
hidden: true,
run: () => {
clearMessageNavigation()
scroll.scrollBy(1)
dialog.clear()
},
@@ -339,6 +366,7 @@ export function Session() {
category: "Session",
hidden: true,
run: () => {
clearMessageNavigation()
scroll.scrollBy(-scroll.height / 4)
dialog.clear()
},
@@ -349,6 +377,7 @@ export function Session() {
category: "Session",
hidden: true,
run: () => {
clearMessageNavigation()
scroll.scrollBy(scroll.height / 4)
dialog.clear()
},
@@ -362,6 +391,7 @@ export function Session() {
category: "Session",
hidden: true,
run: () => {
clearMessageNavigation()
scroll.scrollTo(0)
dialog.clear()
},
@@ -372,6 +402,7 @@ export function Session() {
category: "Session",
hidden: true,
run: () => {
clearMessageNavigation()
scroll.scrollTo(scroll.scrollHeight)
dialog.clear()
},
@@ -412,8 +443,7 @@ export function Session() {
sessionID={route.sessionID}
onMove={(messageID) => {
if (!messageID) return
const child = scroll.getChildren().find((child) => child.id === messageID)
if (child) scroll.scrollBy(child.y - scroll.y - 1)
jumpToMessage(messageID)
}}
/>
))
@@ -574,10 +604,7 @@ export function Session() {
const message = messages[i]
if (!message || message.type !== "user" || !message.text.trim()) continue
{
const child = scroll.getChildren().find((child) => {
return child.id === message.id
})
if (child) scroll.scrollBy(child.y - scroll.y - 1)
jumpToMessage(message.id)
break
}
}
@@ -597,6 +624,20 @@ export function Session() {
hidden: true,
run: () => scrollToMessage("prev", dialog),
},
{
title: "Next user message",
name: "session.message.user.next",
category: "Session",
hidden: true,
run: () => scrollToMessage("next", dialog, true),
},
{
title: "Previous user message",
name: "session.message.user.previous",
category: "Session",
hidden: true,
run: () => scrollToMessage("prev", dialog, true),
},
{
title: "Copy last assistant message",
name: "messages.copy",
@@ -810,7 +851,10 @@ export function Session() {
createEffect(
on(
() => route.sessionID,
() => setComposer("open", false),
() => {
setComposer("open", false)
clearMessageNavigation()
},
),
)
@@ -846,16 +890,17 @@ export function Session() {
foregroundColor: themeV2.border(),
},
}}
stickyScroll={true}
stickyScroll={!navigationMessage()}
stickyStart="bottom"
flexGrow={1}
scrollAcceleration={scrollAcceleration()}
>
<For each={rows}>
{(row) => (
{(row, index) => (
<SessionRowView
row={row}
message={(messageID) => data.session.message.get(route.sessionID, messageID)}
boundaryID={boundaries()[index()]}
/>
)}
</For>
@@ -870,6 +915,9 @@ export function Session() {
files={session()!.revert!.files ?? []}
/>
</Show>
<Show when={navigationSlack()}>
{(height) => <box id={NAVIGATION_SLACK_ID} height={height()} flexShrink={0} />}
</Show>
</scrollbox>
<box flexShrink={0}>
<Composer
@@ -942,9 +990,13 @@ export function Session() {
)
}
function SessionRowView(props: { row: SessionRow; message: (messageID: string) => SessionMessageInfo | undefined }) {
function SessionRowView(props: {
row: SessionRow
message: (messageID: string) => SessionMessageInfo | undefined
boundaryID?: string
}) {
return (
<box marginTop={1} flexShrink={0}>
<box id={props.boundaryID} marginTop={1} flexShrink={0}>
<Switch>
<Match when={props.row.type === "message" ? props.row : undefined}>
{(row) => (
@@ -1563,7 +1615,6 @@ function UserMessage(props: { message: SessionMessageUser }) {
return (
<Show when={props.message.text.trim() || files().length}>
<box
id={props.message.id}
border={["left"]}
borderColor={queued() ? themeV2.border() : color()}
customBorderChars={SplitBorder.customBorderChars}
@@ -2279,7 +2330,7 @@ function BlockTool(props: {
paddingLeft={2}
gap={1}
backgroundColor={
hover() ? themeV2.background.action.secondary() : themeV2.background()
hover() ? themeV2.background.action.secondary("focused") : themeV2.background()
}
customBorderChars={SplitBorder.customBorderChars}
borderColor={themeV2.background()}
@@ -0,0 +1,48 @@
import type { SessionMessageInfo } from "@opencode-ai/client"
type MessageChild = {
readonly id?: string
readonly y: number
}
export function messageNavigationSlack(input: {
top: number
viewportHeight: number
scrollHeight: number
currentSlack: number
}) {
const contentHeight = input.scrollHeight - input.currentSlack
return Math.max(0, Math.ceil(input.top + input.viewportHeight - contentHeight))
}
export function findMessageBoundary(input: {
direction: "next" | "prev"
children: readonly MessageChild[]
messages: readonly SessionMessageInfo[]
scrollTop: number
viewportY: number
currentID?: string
userOnly?: boolean
}) {
const messages = new Map(input.messages.map((message) => [message.id, message]))
const visible = input.children
.flatMap((child) => {
if (!child.id) return []
const message = messages.get(child.id)
if (!message) return []
if (message.type === "user" && message.text.trim()) {
const y = input.scrollTop + child.y - input.viewportY
return [{ id: child.id, y, top: y }]
}
if (input.userOnly || message.type !== "assistant") return []
if (!message.content.some((content) => content.type === "text" && content.text.trim())) return []
const y = input.scrollTop + child.y - input.viewportY
return [{ id: child.id, y, top: Math.max(0, y - 1) }]
})
.sort((a, b) => a.y - b.y)
const current = visible.findIndex((child) => child.id === input.currentID)
if (current !== -1) return visible[current + (input.direction === "next" ? 1 : -1)] ?? null
if (input.direction === "next") return visible.find((child) => child.y > input.scrollTop) ?? null
return visible.findLast((child) => child.y < input.scrollTop) ?? null
}
@@ -656,7 +656,7 @@ function Prompt<const T extends Record<string, string>>(props: {
<box
backgroundColor={themeV2.background()}
border={["left"]}
borderColor={themeV2.text.feedback.warning()}
borderColor={themeV2.background.action.primary("focused")}
customBorderChars={SplitBorder.customBorderChars}
{...(store.expanded
? { top: dimensions().height * -1 + 1, bottom: 1, left: 2, right: 2, position: "absolute" }
+30
View File
@@ -285,6 +285,36 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S
}, [])
}
export function messageBoundaryIDs(rows: SessionRow[], messages: SessionMessageInfo[]) {
const byID = new Map(messages.map((message) => [message.id, message]))
const seen = new Set<string>()
return rows.map((row) => {
const id = rowBoundaryMessageID(row, byID)
if (!id || seen.has(id)) return undefined
seen.add(id)
return id
})
}
function rowBoundaryMessageID(row: SessionRow, messages: Map<string, SessionMessageInfo>) {
if (row.type === "message") {
const message = messages.get(row.messageID)
if (message?.type === "user" && message.text.trim()) return message.id
return undefined
}
const messageID =
row.type === "part"
? row.ref.messageID
: row.type === "group"
? row.refs[0]?.messageID
: row.type === "assistant-footer"
? row.messageID
: undefined
if (!messageID) return undefined
const message = messages.get(messageID)
if (message?.type === "assistant") return message.id
}
export function resolvePart(message: SessionMessageAssistant, partID: string) {
const tool = message.content.find((part) => part.type === "tool" && part.id === partID)
if (tool) return tool
+1
View File
@@ -25,6 +25,7 @@ export function createComponentTheme(current: Accessor<ResolvedThemeView>) {
blue: (step: HueStep) => current().hue.blue[step],
purple: (step: HueStep) => current().hue.purple[step],
accent: (step: HueStep) => current().hue.accent[step],
interactive: (step: HueStep) => current().hue.interactive[step],
neutral: (step: HueStep) => current().hue.neutral[step],
}
const text = Object.assign(() => current().text.default, {
+156 -132
View File
@@ -93,79 +93,91 @@ export const DEFAULT_THEME = {
900: "#581c87",
},
accent: "$hue.blue",
interactive: "$hue.blue",
neutral: "$hue.gray",
},
text: {
default: "$hue.neutral.900",
subdued: "$hue.neutral.600",
action: {
primary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" },
secondary: { default: "$hue.neutral.900", $disabled: "$hue.neutral.500" },
destructive: { default: "$hue.red.100", $disabled: "$hue.neutral.500" },
},
formfield: {
default: "$hue.neutral.900",
subdued: "$hue.neutral.600",
action: {
primary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" },
secondary: { default: "$hue.neutral.900", $disabled: "$hue.neutral.500" },
destructive: { default: "$hue.red.100", $disabled: "$hue.neutral.500" },
$focused: "$text.action.primary.default",
$pressed: "$hue.neutral.100",
$disabled: "$hue.neutral.500",
$selected: "$hue.interactive.600",
},
feedback: {
error: { default: "$hue.red.700", subdued: "$hue.red.600" },
warning: { default: "$hue.yellow.800", subdued: "$hue.yellow.700" },
success: { default: "$hue.green.700", subdued: "$hue.green.600" },
info: { default: "$hue.cyan.700", subdued: "$hue.cyan.600" },
},
},
background: {
default: "$hue.neutral.100",
surface: {
offset: "$hue.neutral.200",
overlay: "$hue.neutral.300",
},
action: {
primary: {
default: "$hue.interactive.600",
$focused: "$hue.interactive.700",
$pressed: "$hue.interactive.800",
$selected: "$hue.interactive.700",
$disabled: "$hue.neutral.300",
},
formfield: {
default: "$hue.neutral.900",
$focused: "$text.action.primary.default",
$pressed: "$hue.neutral.100",
$disabled: "$hue.neutral.500",
$selected: "$hue.accent.600",
secondary: {
default: "$hue.neutral.200",
$focused: "$hue.neutral.300",
$pressed: "$hue.neutral.400",
$selected: "$hue.neutral.300",
$disabled: "$hue.neutral.200",
},
feedback: {
error: { default: "$hue.red.700", subdued: "$hue.red.600" },
warning: { default: "$hue.yellow.800", subdued: "$hue.yellow.700" },
success: { default: "$hue.green.700", subdued: "$hue.green.600" },
info: { default: "$hue.cyan.700", subdued: "$hue.cyan.600" },
destructive: {
default: "$hue.red.600",
$focused: "$hue.red.700",
$pressed: "$hue.red.800",
$selected: "$hue.red.700",
$disabled: "$hue.neutral.300",
},
},
background: {
default: "$hue.neutral.100",
surface: {
offset: "$hue.neutral.200",
overlay: "$hue.neutral.300",
},
action: {
primary: {
default: "$hue.accent.600", $focused: "$hue.accent.700", $pressed: "$hue.accent.800",
$disabled: "$hue.neutral.300",
},
secondary: {
default: "$hue.neutral.200", $focused: "$hue.neutral.300", $pressed: "$hue.neutral.400",
$disabled: "$hue.neutral.200",
},
destructive: {
default: "$hue.red.600", $focused: "$hue.red.700", $pressed: "$hue.red.800",
$disabled: "$hue.neutral.300",
},
},
formfield: {
default: "$background.default",
$focused: "$background.action.primary.default",
$pressed: "$hue.accent.800",
$disabled: "$background.default",
$selected: "$background.formfield.default",
},
feedback: {
error: { default: "$background.default" },
warning: { default: "$background.default" },
success: { default: "$background.default" },
info: { default: "$background.default" },
},
formfield: {
default: "$background.default",
$focused: "$background.action.primary.default",
$pressed: "$hue.interactive.800",
$disabled: "$background.default",
$selected: "$background.formfield.default",
},
border: { default: "$hue.neutral.300" },
scrollbar: { default: "$hue.neutral.400" },
diff: {
text: {
added: "$hue.green.700", removed: "$hue.red.700", context: "$hue.neutral.900",
hunkHeader: "$hue.purple.600",
},
background: { added: "$hue.green.100", removed: "$hue.red.100", context: "$hue.neutral.100" },
highlight: { added: "$hue.green.600", removed: "$hue.red.600" },
lineNumber: {
text: "$hue.neutral.600",
background: { added: "$hue.green.200", removed: "$hue.red.200" },
},
feedback: {
error: { default: "$background.default" },
warning: { default: "$background.default" },
success: { default: "$background.default" },
info: { default: "$background.default" },
},
syntax: {
},
border: { default: "$hue.neutral.300" },
scrollbar: { default: "$hue.neutral.400" },
diff: {
text: {
added: "$hue.green.700",
removed: "$hue.red.700",
context: "$hue.neutral.900",
hunkHeader: "$hue.purple.600",
},
background: { added: "$hue.green.100", removed: "$hue.red.100", context: "$hue.neutral.100" },
highlight: { added: "$hue.green.600", removed: "$hue.red.600" },
lineNumber: {
text: "$hue.neutral.600",
background: { added: "$hue.green.200", removed: "$hue.red.200" },
},
},
syntax: {
comment: "$hue.neutral.600",
keyword: "$hue.purple.600",
function: "$hue.accent.600",
@@ -176,7 +188,7 @@ export const DEFAULT_THEME = {
operator: "$hue.cyan.600",
punctuation: "$hue.neutral.900",
},
markdown: {
markdown: {
text: "$hue.neutral.900",
heading: "$hue.purple.600",
link: "$hue.accent.600",
@@ -196,14 +208,14 @@ export const DEFAULT_THEME = {
text: { action: { primary: { default: "$hue.neutral.100" } } },
background: {
default: "$background.surface.offset",
action: { primary: { default: "$hue.accent.500" } },
action: { primary: { default: "$hue.interactive.500" } },
},
},
"@context:overlay": {
text: { action: { primary: { default: "$hue.neutral.100" } } },
background: {
default: "$background.surface.overlay",
action: { primary: { default: "$hue.accent.500" } },
action: { primary: { default: "$hue.interactive.500" } },
},
},
},
@@ -298,79 +310,91 @@ export const DEFAULT_THEME = {
900: "#581c87",
},
accent: "$hue.blue",
interactive: "$hue.blue",
neutral: "$hue.gray",
},
text: {
default: "$hue.neutral.100",
subdued: "$hue.neutral.400",
action: {
primary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" },
secondary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" },
destructive: { default: "$hue.red.100", $disabled: "$hue.neutral.500" },
},
formfield: {
default: "$hue.neutral.100",
subdued: "$hue.neutral.400",
action: {
primary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" },
secondary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" },
destructive: { default: "$hue.red.100", $disabled: "$hue.neutral.500" },
$focused: "$text.action.primary.default",
$pressed: "$hue.neutral.100",
$disabled: "$hue.neutral.500",
$selected: "$hue.interactive.500",
},
feedback: {
error: { default: "$hue.red.300", subdued: "$hue.red.400" },
warning: { default: "$hue.yellow.200", subdued: "$hue.yellow.300" },
success: { default: "$hue.green.300", subdued: "$hue.green.400" },
info: { default: "$hue.cyan.300", subdued: "$hue.cyan.400" },
},
},
background: {
default: "$hue.neutral.900",
surface: {
offset: "$hue.neutral.800",
overlay: "$hue.neutral.700",
},
action: {
primary: {
default: "$hue.interactive.500",
$focused: "$hue.interactive.600",
$pressed: "$hue.interactive.800",
$selected: "$hue.interactive.600",
$disabled: "$hue.neutral.800",
},
formfield: {
default: "$hue.neutral.100",
$focused: "$text.action.primary.default",
$pressed: "$hue.neutral.100",
$disabled: "$hue.neutral.500",
$selected: "$hue.accent.500",
secondary: {
default: "$hue.neutral.800",
$focused: "$hue.neutral.700",
$pressed: "$hue.neutral.900",
$selected: "$hue.neutral.700",
$disabled: "$hue.neutral.900",
},
feedback: {
error: { default: "$hue.red.300", subdued: "$hue.red.400" },
warning: { default: "$hue.yellow.200", subdued: "$hue.yellow.300" },
success: { default: "$hue.green.300", subdued: "$hue.green.400" },
info: { default: "$hue.cyan.300", subdued: "$hue.cyan.400" },
destructive: {
default: "$hue.red.600",
$focused: "$hue.red.700",
$pressed: "$hue.red.800",
$selected: "$hue.red.700",
$disabled: "$hue.neutral.800",
},
},
background: {
default: "$hue.neutral.900",
surface: {
offset: "$hue.neutral.800",
overlay: "$hue.neutral.700",
},
action: {
primary: {
default: "$hue.accent.500", $focused: "$hue.accent.600", $pressed: "$hue.accent.800",
$disabled: "$hue.neutral.800",
},
secondary: {
default: "$hue.neutral.800", $focused: "$hue.neutral.700", $pressed: "$hue.neutral.900",
$disabled: "$hue.neutral.900",
},
destructive: {
default: "$hue.red.600", $focused: "$hue.red.700", $pressed: "$hue.red.800",
$disabled: "$hue.neutral.800",
},
},
formfield: {
default: "$background.default",
$focused: "$background.action.primary.default",
$pressed: "$hue.accent.800",
$disabled: "$background.default",
$selected: "$background.formfield.default",
},
feedback: {
error: { default: "$background.default" },
warning: { default: "$background.default" },
success: { default: "$background.default" },
info: { default: "$background.default" },
},
formfield: {
default: "$background.default",
$focused: "$background.action.primary.default",
$pressed: "$hue.interactive.800",
$disabled: "$background.default",
$selected: "$background.formfield.default",
},
border: { default: "$hue.neutral.700" },
scrollbar: { default: "$hue.neutral.600" },
diff: {
text: {
added: "$hue.green.300", removed: "$hue.red.300", context: "$hue.neutral.100",
hunkHeader: "$hue.purple.400",
},
background: { added: "$hue.green.900", removed: "$hue.red.900", context: "$hue.neutral.900" },
highlight: { added: "$hue.green.400", removed: "$hue.red.400" },
lineNumber: {
text: "$hue.neutral.400",
background: { added: "$hue.green.800", removed: "$hue.red.800" },
},
feedback: {
error: { default: "$background.default" },
warning: { default: "$background.default" },
success: { default: "$background.default" },
info: { default: "$background.default" },
},
syntax: {
},
border: { default: "$hue.neutral.700" },
scrollbar: { default: "$hue.neutral.600" },
diff: {
text: {
added: "$hue.green.300",
removed: "$hue.red.300",
context: "$hue.neutral.100",
hunkHeader: "$hue.purple.400",
},
background: { added: "$hue.green.900", removed: "$hue.red.900", context: "$hue.neutral.900" },
highlight: { added: "$hue.green.400", removed: "$hue.red.400" },
lineNumber: {
text: "$hue.neutral.400",
background: { added: "$hue.green.800", removed: "$hue.red.800" },
},
},
syntax: {
comment: "$hue.neutral.400",
keyword: "$hue.purple.400",
function: "$hue.accent.400",
@@ -381,7 +405,7 @@ export const DEFAULT_THEME = {
operator: "$hue.cyan.400",
punctuation: "$hue.neutral.100",
},
markdown: {
markdown: {
text: "$hue.neutral.100",
heading: "$hue.purple.400",
link: "$hue.accent.400",
@@ -401,14 +425,14 @@ export const DEFAULT_THEME = {
text: { action: { primary: { default: "$hue.neutral.100" } } },
background: {
default: "$background.surface.offset",
action: { primary: { default: "$hue.accent.400" } },
action: { primary: { default: "$hue.interactive.400" } },
},
},
"@context:overlay": {
text: { action: { primary: { default: "$hue.neutral.900" } } },
background: {
default: "$background.surface.overlay",
action: { primary: { default: "$hue.accent.400" } },
action: { primary: { default: "$hue.interactive.400" } },
},
},
},
-1
View File
@@ -157,7 +157,6 @@ function resolveHue(definition: HueDefinition) {
if (stack.includes(name)) throw new Error(`Circular hue reference: ${[...stack, name].join(" -> ")}`)
const value = source[name]
if (typeof value === "string") {
if ((BaseHue.literals as readonly string[]).includes(name)) throw new Error(`Base hue "${name}" must be a scale`)
const match = /^\$hue\.([^.]+)$/.exec(value)
if (!match?.[1]) throw new Error(`Hue alias "${value}" must reference a hue scale`)
const result = resolve(match[1], [...stack, name])
+26 -23
View File
@@ -6,13 +6,13 @@ export type HueStep = Schema.Schema.Type<typeof HueStep>
export const BaseHue = Schema.Literals(["gray", "red", "orange", "yellow", "green", "cyan", "blue", "purple"])
export type BaseHue = Schema.Schema.Type<typeof BaseHue>
export const HueAlias = Schema.Literals(["accent", "neutral"])
export const HueAlias = Schema.Literals(["accent", "interactive", "neutral"])
export type HueAlias = Schema.Schema.Type<typeof HueAlias>
export const ActionVariant = Schema.Literals(["primary", "secondary", "destructive"])
export type ActionVariant = Schema.Schema.Type<typeof ActionVariant>
export const ActionState = Schema.Literals(["focused", "pressed", "disabled"])
export const ActionState = Schema.Literals(["focused", "pressed", "selected", "disabled"])
export type ActionState = Schema.Schema.Type<typeof ActionState>
export type ActionStateKey = `$${ActionState}`
@@ -41,33 +41,35 @@ const ContextKey = Schema.Literals(["@context:elevated", "@context:overlay"])
export type ContextKey = Schema.Schema.Type<typeof ContextKey>
const HueScaleDefinition = Schema.Record(HueStep, HexColor)
const HueAliasDefinition = Schema.Union([Schema.TemplateLiteral(["$hue.", HueName]), HueScaleDefinition])
const HueValueDefinition = Schema.Union([Schema.TemplateLiteral(["$hue.", HueName]), HueScaleDefinition])
const HueDefinition = Schema.Struct({
gray: HueScaleDefinition,
red: HueScaleDefinition,
orange: HueScaleDefinition,
yellow: HueScaleDefinition,
green: HueScaleDefinition,
cyan: HueScaleDefinition,
blue: HueScaleDefinition,
purple: HueScaleDefinition,
accent: HueAliasDefinition,
neutral: HueAliasDefinition,
gray: HueValueDefinition,
red: HueValueDefinition,
orange: HueValueDefinition,
yellow: HueValueDefinition,
green: HueValueDefinition,
cyan: HueValueDefinition,
blue: HueValueDefinition,
purple: HueValueDefinition,
accent: HueValueDefinition,
interactive: HueValueDefinition,
neutral: HueValueDefinition,
})
export type HueDefinition = Schema.Schema.Type<typeof HueDefinition>
const HueOverrideDefinition = Schema.Struct({
gray: Schema.optional(HueScaleDefinition),
red: Schema.optional(HueScaleDefinition),
orange: Schema.optional(HueScaleDefinition),
yellow: Schema.optional(HueScaleDefinition),
green: Schema.optional(HueScaleDefinition),
cyan: Schema.optional(HueScaleDefinition),
blue: Schema.optional(HueScaleDefinition),
purple: Schema.optional(HueScaleDefinition),
accent: Schema.optional(HueAliasDefinition),
neutral: Schema.optional(HueAliasDefinition),
gray: Schema.optional(HueValueDefinition),
red: Schema.optional(HueValueDefinition),
orange: Schema.optional(HueValueDefinition),
yellow: Schema.optional(HueValueDefinition),
green: Schema.optional(HueValueDefinition),
cyan: Schema.optional(HueValueDefinition),
blue: Schema.optional(HueValueDefinition),
purple: Schema.optional(HueValueDefinition),
accent: Schema.optional(HueValueDefinition),
interactive: Schema.optional(HueValueDefinition),
neutral: Schema.optional(HueValueDefinition),
})
export type HueOverrideDefinition = Schema.Schema.Type<typeof HueOverrideDefinition>
@@ -75,6 +77,7 @@ const StatefulColorDefinition = Schema.Struct({
default: Schema.optional(ColorValue),
$focused: Schema.optional(ColorValue),
$pressed: Schema.optional(ColorValue),
$selected: Schema.optional(ColorValue),
$disabled: Schema.optional(ColorValue),
})
export type StatefulColorDefinition = Schema.Schema.Type<typeof StatefulColorDefinition>
+135 -52
View File
@@ -1,9 +1,16 @@
import { RGBA } from "@opentui/core"
import { oklchToHex, rgbToOklch } from "@opencode-ai/ui/theme/color"
import type { Theme, ThemeJson } from "../index"
import { DEFAULT_THEME } from "./defaults"
import type { ThemeFile } from "./index"
import { HueStep } from "./schema"
type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
type ChromaticHue = "red" | "orange" | "yellow" | "green" | "cyan" | "blue" | "purple"
const chromaticHues: readonly ChromaticHue[] = ["red", "orange", "yellow", "green", "cyan", "blue", "purple"]
const minimumChroma = 0.03
const lightThreshold = 0.6
export function migrateV1(theme: ThemeJson): ThemeFile {
return {
@@ -18,33 +25,49 @@ function migrateMode(theme: Theme, mode: "light" | "dark"): ThemeFile["light"] {
const color = (key: ThemeColor) => hex(theme[key])
const selected = hex(selectedForeground(theme, theme.primary))
const destructive = hex(selectedForeground(theme, theme.error))
const hues = inferHues(theme, mode)
const text = mode === "light" ? "$hue.neutral.900" : "$hue.neutral.100"
const textMuted = mode === "light" ? "$hue.neutral.700" : "$hue.neutral.300"
const primary = mode === "light" ? "$hue.interactive.900" : "$hue.interactive.100"
const background = mode === "light" ? "$hue.neutral.100" : "$hue.neutral.900"
const backgroundPanel = mode === "light" ? "$hue.neutral.200" : "$hue.neutral.800"
const backgroundMenu = mode === "light" ? "$hue.neutral.300" : "$hue.neutral.700"
return {
hue: {
...DEFAULT_THEME[mode].hue,
accent: hueScale(theme.secondary),
gray: neutralScale(theme, mode),
...Object.fromEntries(
chromaticHues.map((name) => {
const match = hues[name]
return [name, match ? hueScale(match.color, mode) : "$hue.gray"]
}),
),
accent: ambiguous(theme.accent) ? "$hue.gray" : hueScale(theme.accent, mode),
interactive: ambiguous(theme.primary) ? "$hue.gray" : hueScale(theme.primary, mode),
neutral: "$hue.gray",
},
text: {
default: color("text"),
subdued: color("textMuted"),
default: text,
subdued: textMuted,
action: {
primary: {
default: selected,
$disabled: color("textMuted"),
default: "$text.default",
$disabled: textMuted,
$focused: selected,
$selected: primary,
},
secondary: {
default: "$text.default",
$disabled: color("textMuted"),
$disabled: textMuted,
},
destructive: { default: destructive, $disabled: color("textMuted") },
destructive: { default: destructive, $disabled: textMuted },
},
formfield: {
default: color("text"),
$focused: color("primary"),
$pressed: color("primary"),
$disabled: color("textMuted"),
$selected: color("primary"),
default: text,
$focused: primary,
$pressed: primary,
$disabled: textMuted,
$selected: primary,
},
feedback: {
error: { default: color("error") },
@@ -54,13 +77,13 @@ function migrateMode(theme: Theme, mode: "light" | "dark"): ThemeFile["light"] {
},
},
background: {
default: color("background"),
default: background,
surface: {
offset: color("backgroundPanel"),
overlay: color("backgroundMenu"),
offset: backgroundPanel,
overlay: backgroundMenu,
},
action: {
primary: { default: color("primary"), $focused: color("primary") },
primary: { default: "transparent", $focused: primary, $selected: primary },
secondary: {
default: "$background.default",
$focused: color("backgroundElement"),
@@ -128,24 +151,43 @@ function migrateMode(theme: Theme, mode: "light" | "dark"): ThemeFile["light"] {
imageText: color("markdownImageText"),
codeBlock: color("markdownCodeBlock"),
},
"@context:elevated": {
background: {
default: "$background.surface.offset",
action: {
primary: {
default: color("primary"),
$focused: color("primary"),
},
secondary: {
default: "$background.surface.offset",
},
},
},
},
"@context:elevated": { background: { default: "$background.surface.offset" } },
"@context:overlay": { background: { default: "$background.surface.overlay" } },
}
}
function inferHues(theme: Theme, mode: "light" | "dark") {
return [theme.accent, theme.success, theme.warning, theme.primary, theme.error, theme.info, theme.secondary].reduce<
Partial<Record<ChromaticHue, { color: RGBA; distance: number }>>
>((result, color) => {
const value = toOklch(color)
if (ambiguous(color, value.c)) return result
const anchor = inferenceAnchor(value.l)
const nearest = chromaticHues
.map((name) => ({
name,
distance: hueDistance(value.h, toOklch(RGBA.fromHex(DEFAULT_THEME[mode].hue[name][anchor])).h),
}))
.sort((first, second) => first.distance - second.distance)[0]
const current = result[nearest.name]
if (current && current.distance <= nearest.distance) return result
return { ...result, [nearest.name]: { color, distance: nearest.distance } }
}, {})
}
function inferenceAnchor(lightness: number): HueStep {
return lightness >= lightThreshold ? 300 : 700
}
function hueDistance(first: number, second: number) {
const difference = Math.abs(first - second)
return Math.min(difference, 360 - difference)
}
function ambiguous(color: RGBA, chroma = toOklch(color).c) {
return color.toInts()[3] === 0 || chroma < minimumChroma
}
function resolveV1(theme: ThemeJson, mode: "dark" | "light"): Theme {
const defs = theme.defs ?? {}
@@ -192,28 +234,66 @@ function selectedForeground(theme: Theme, background: RGBA) {
: RGBA.fromInts(255, 255, 255)
}
function hueScale(color: RGBA) {
return {
100: mix(color, 255, 0.8),
200: mix(color, 255, 0.6),
300: mix(color, 255, 0.4),
400: mix(color, 255, 0.2),
500: hex(color),
600: mix(color, 0, 0.15),
700: mix(color, 0, 0.3),
800: mix(color, 0, 0.45),
900: mix(color, 0, 0.6),
}
function hueScale(color: RGBA, mode: "light" | "dark") {
const value = toOklch(color)
const anchor = mode === "light" ? 900 : 100
const endpoint = mode === "light" ? Math.max(0.97, value.l) : Math.min(0.18, value.l)
const alpha = color.toInts()[3]
return Object.fromEntries(
HueStep.literals.map((step) => {
if (step === anchor) return [step, hex(color)]
const progress = mode === "light" ? (900 - step) / 800 : (step - 100) / 800
const generated = oklchToHex({
l: value.l + (endpoint - value.l) * progress,
c: value.c * (1 - progress * 0.5),
h: value.h,
})
return [step, alpha === 255 ? generated : `${generated}${byte(alpha)}`]
}),
) as Record<HueStep, string>
}
function mix(color: RGBA, target: number, amount: number) {
const [r, g, b, a] = color.toInts()
return hexInts(
Math.round(r + (target - r) * amount),
Math.round(g + (target - g) * amount),
Math.round(b + (target - b) * amount),
a,
)
function neutralScale(theme: Theme, mode: "light" | "dark") {
const anchors = neutralAnchors(theme, mode)
return Object.fromEntries(
HueStep.literals.map((step) => {
const exact = anchors.find((anchor) => anchor.step === step)
if (exact) return [step, hex(exact.color)]
const lower = anchors.filter((anchor) => anchor.step < step).at(-1)!
const upper = anchors.find((anchor) => anchor.step > step)!
return [step, interpolate(lower.color, upper.color, (step - lower.step) / (upper.step - lower.step))]
}),
) as Record<HueStep, string>
}
function neutralAnchors(theme: Theme, mode: "light" | "dark") {
const light: { step: HueStep; color: RGBA }[] = [
{ step: 100, color: theme.background },
{ step: 200, color: theme.backgroundPanel },
{ step: 300, color: theme.backgroundMenu },
{ step: 700, color: theme.textMuted },
{ step: 900, color: theme.text },
]
if (mode === "light") return light
return light.toReversed().map((source) => ({ ...source, step: (1000 - source.step) as HueStep }))
}
function interpolate(first: RGBA, second: RGBA, amount: number) {
const start = toOklch(first)
const end = toOklch(second)
const hue = ((((end.h - start.h) % 360) + 540) % 360) - 180
const generated = oklchToHex({
l: start.l + (end.l - start.l) * amount,
c: start.c + (end.c - start.c) * amount,
h: start.h + hue * amount,
})
const alpha = Math.round(first.toInts()[3] + (second.toInts()[3] - first.toInts()[3]) * amount)
return alpha === 255 ? generated : `${generated}${byte(alpha)}`
}
function toOklch(color: RGBA) {
const [red, green, blue] = color.toInts()
return rgbToOklch(red / 255, green / 255, blue / 255)
}
function hex(color: RGBA) {
@@ -221,10 +301,13 @@ function hex(color: RGBA) {
}
function hexInts(r: number, g: number, b: number, a: number) {
const byte = (value: number) => value.toString(16).padStart(2, "0")
return `#${byte(r)}${byte(g)}${byte(b)}${a === 255 ? "" : byte(a)}`
}
function byte(value: number) {
return value.toString(16).padStart(2, "0")
}
function ansi(code: number) {
if (code < 16) {
const colors = [
@@ -0,0 +1,164 @@
import { expect, test } from "bun:test"
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
import { findMessageBoundary, messageNavigationSlack } from "../../../src/routes/session/message-navigation"
const messages: SessionMessageInfo[] = [
{ type: "user", id: "user-1", text: "First", time: { created: 0 } },
assistant("assistant-1", "Response"),
{ type: "user", id: "user-2", text: "Second", time: { created: 2 } },
]
const children = [
{ id: "user-1", y: 0 },
{ id: "assistant-1", y: 20 },
{ id: "user-2", y: 40 },
]
test("adds only enough slack to align the selected message", () => {
expect(messageNavigationSlack({ top: 80, viewportHeight: 50, scrollHeight: 100, currentSlack: 0 })).toBe(30)
expect(messageNavigationSlack({ top: 20, viewportHeight: 50, scrollHeight: 130, currentSlack: 30 })).toBe(0)
})
test("finds the next user message without stopping at an assistant message", () => {
expect(
findMessageBoundary({
direction: "next",
children,
messages,
scrollTop: 0,
viewportY: 0,
userOnly: true,
}),
).toEqual({ id: "user-2", y: 40, top: 40 })
})
test("finds the previous user message without stopping at an assistant message", () => {
expect(
findMessageBoundary({
direction: "prev",
children: children.map((child) => ({ ...child, y: child.y - 35 })),
messages,
scrollTop: 35,
viewportY: 0,
userOnly: true,
}),
).toEqual({ id: "user-1", y: 0, top: 0 })
})
test("preserves navigation across both user and assistant messages", () => {
expect(
findMessageBoundary({
direction: "next",
children,
messages,
scrollTop: 0,
viewportY: 0,
}),
).toEqual({ id: "assistant-1", y: 20, top: 19 })
expect(
findMessageBoundary({
direction: "prev",
children: children.map((child) => ({ ...child, y: child.y - 35 })),
messages,
scrollTop: 35,
viewportY: 0,
}),
).toEqual({ id: "assistant-1", y: 20, top: 19 })
})
test("uses the selected message when the viewport is too tall to scroll", () => {
expect(
findMessageBoundary({
direction: "next",
children,
messages,
scrollTop: 0,
viewportY: 0,
currentID: "user-1",
userOnly: true,
}),
).toEqual({ id: "user-2", y: 40, top: 40 })
expect(
findMessageBoundary({
direction: "prev",
children,
messages,
scrollTop: 0,
viewportY: 0,
currentID: "user-2",
userOnly: true,
}),
).toEqual({ id: "user-1", y: 0, top: 0 })
})
test("stops at the first and last selected user message", () => {
expect(
findMessageBoundary({
direction: "next",
children,
messages,
scrollTop: 0,
viewportY: 0,
currentID: "user-2",
userOnly: true,
}),
).toBeNull()
expect(
findMessageBoundary({
direction: "prev",
children,
messages,
scrollTop: 0,
viewportY: 0,
currentID: "user-1",
userOnly: true,
}),
).toBeNull()
})
test("keeps the logical boundary when layout temporarily moves it outside the viewport", () => {
expect(
findMessageBoundary({
direction: "next",
children,
messages,
scrollTop: 0,
viewportY: 0,
currentID: "user-2",
userOnly: true,
}),
).toBeNull()
})
test("stops at the first and last message", () => {
expect(
findMessageBoundary({
direction: "next",
children,
messages,
scrollTop: 0,
viewportY: 0,
currentID: "user-2",
}),
).toBeNull()
expect(
findMessageBoundary({
direction: "prev",
children,
messages,
scrollTop: 0,
viewportY: 0,
currentID: "user-1",
}),
).toBeNull()
})
function assistant(id: string, text: string): SessionMessageAssistant {
return {
type: "assistant",
id,
agent: "build",
model: { providerID: "test", id: "test" },
content: [{ type: "text", text }],
time: { created: 1, completed: 1 },
}
}
+15 -1
View File
@@ -1,6 +1,20 @@
import { expect, test } from "bun:test"
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
import { reduceSessionRows } from "../../../src/routes/session/rows"
import { messageBoundaryIDs, reduceSessionRows } from "../../../src/routes/session/rows"
test("assigns assistant boundaries to the first rendered row instead of the first text row", () => {
const messages: SessionMessageInfo[] = [
{ type: "user", id: "user-1", text: "Question", time: { created: 0 } },
assistant("assistant-1", [
{ type: "reasoning", text: "Thinking" },
{ type: "text", text: "First" },
{ type: "text", text: "Second" },
]),
]
const rows = reduceSessionRows(messages)
expect(messageBoundaryIDs(rows, messages)).toEqual(["user-1", "assistant-1", undefined, undefined])
})
test("groups exploration parts across assistant messages until a delimiter", () => {
const messages: SessionMessageInfo[] = [
+11
View File
@@ -86,6 +86,17 @@ test("resolves a session move keybind", () => {
expect(config.keybinds.get("session.move")).toMatchObject([{ key: "ctrl+o" }])
})
test("resolves message navigation defaults", () => {
const config = resolve({}, { terminalSuspend: true })
expect(config.keybinds.get("session.first")).toMatchObject([{ key: "ctrl+g,home,alt+home" }])
expect(config.keybinds.get("session.message.previous")).toMatchObject([{ key: "alt+up" }])
expect(config.keybinds.get("session.message.next")).toMatchObject([{ key: "alt+down" }])
expect(config.keybinds.get("session.message.user.previous")).toMatchObject([{ key: "alt+shift+up" }])
expect(config.keybinds.get("session.message.user.next")).toMatchObject([{ key: "alt+shift+down" }])
expect(config.keybinds.get("session.messages_last_user")).toMatchObject([{ key: "alt+end" }])
})
test("opens the subagent picker with down", () => {
const config = resolve({}, { terminalSuspend: true })
+63 -4
View File
@@ -1,9 +1,10 @@
/** @jsxImportSource @opentui/solid */
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { createBindingLookup } from "@opentui/keymap/extras"
import { type TextareaRenderable } from "@opentui/core"
import { testRender, useRenderer } from "@opentui/solid"
import { expect, test } from "bun:test"
import { onCleanup } from "solid-js"
import { onCleanup, onMount } from "solid-js"
import { TuiKeybind } from "../src/config/keybind"
import {
formatKeySequence,
@@ -110,6 +111,64 @@ test("formats navigation keys as arrows", async () => {
}
})
test("dispatches message navigation while the composer is focused", async () => {
for (const kittyKeyboard of [false, true]) {
const counts = {
"session.first": 0,
"session.message.previous": 0,
"session.message.next": 0,
"session.messages_last_user": 0,
}
function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
const config = createResolvedKeymapConfig()
const offKeymap = registerOpencodeKeymap(keymap, renderer, config)
const commands = Object.keys(counts) as (keyof typeof counts)[]
const offLayer = keymap.registerLayer({
commands: commands.map((name) => ({
name,
run() {
counts[name]++
},
})),
bindings: commands.flatMap((command) => config.keybinds.get(command)),
})
let textarea: TextareaRenderable
onMount(() => textarea.focus())
onCleanup(() => {
offLayer()
offKeymap()
})
return (
<OpencodeKeymapProvider keymap={keymap}>
<textarea ref={(value) => (textarea = value)} />
</OpencodeKeymapProvider>
)
}
const app = await testRender(() => <Harness />, { kittyKeyboard })
try {
await app.renderOnce()
app.mockInput.pressArrow("up", { meta: true })
app.mockInput.pressArrow("down", { meta: true })
app.mockInput.pressKey("HOME", { meta: true })
app.mockInput.pressKey("END", { meta: true })
expect(counts).toEqual({
"session.first": 1,
"session.message.previous": 1,
"session.message.next": 1,
"session.messages_last_user": 1,
})
} finally {
app.renderer.currentFocusedEditor?.blur()
app.renderer.destroy()
}
}
})
test("mode-less bindings stay active when opencode mode changes", async () => {
const counts: Record<string, Record<string, number>> = {}
@@ -169,13 +228,13 @@ test("mode-less bindings stay active when opencode mode changes", async () => {
const app = await testRender(() => <Harness />)
try {
expect(counts).toEqual({
base: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 2, "model.list": 1 },
question: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 2, "model.list": 0 },
base: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 3, "model.list": 1 },
question: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 3, "model.list": 0 },
autocomplete: {
"session.list": 1,
"session.new": 1,
"session.page.up": 2,
"session.first": 2,
"session.first": 3,
"model.list": 0,
},
})
@@ -16,10 +16,13 @@ test("provides reactive property, variant, state, and context accessors", () =>
expect(theme.text()).toBe(resolved().text.default)
expect(theme.hue.accent(500)).toBe(resolved().hue.accent[500])
expect(theme.hue.interactive(500)).toBe(resolved().hue.interactive[500])
expect(theme.hue.gray(200)).toBe(resolved().hue.gray[200])
expect(theme.text.subdued()).toBe(resolved().text.subdued)
expect(theme.text.action()).toBe(resolved().text.action.primary.default)
expect(theme.text.action.primary("pressed")).toBe(resolved().text.action.primary.pressed)
expect(theme.text.action.primary("selected")).toBe(resolved().text.action.primary.selected)
expect(theme.background.action.primary("selected")).toBe(resolved().background.action.primary.selected)
expect(theme.background.action.secondary("disabled")).toBe(
resolved().background.action.secondary.disabled,
)
+28 -4
View File
@@ -13,6 +13,7 @@ test("resolves independent definitions and hue aliases", () => {
const darkTheme = resolveTheme(dark)
expect(lightTheme.hue.accent).toBe(lightTheme.hue.blue)
expect(lightTheme.hue.interactive).toBe(lightTheme.hue.blue)
expect(lightTheme.hue.neutral).toBe(lightTheme.hue.gray)
expect(lightTheme.text.default).toBeInstanceOf(RGBA)
expect(darkTheme.background.default).toBeInstanceOf(RGBA)
@@ -21,33 +22,54 @@ test("resolves independent definitions and hue aliases", () => {
expect(lightTheme.syntax.keyword).toBeInstanceOf(RGBA)
expect(lightTheme.text.action.primary.default).toBe(lightTheme.hue.neutral[100])
expect(lightTheme.contexts["@context:elevated"]?.background.action.primary.default).toBe(
lightTheme.hue.accent[500],
lightTheme.hue.interactive[500],
)
expect(lightTheme.contexts["@context:elevated"]?.background.default).toBe(lightTheme.background.surface.offset)
expect(lightTheme.contexts["@context:elevated"]?.text.action.primary.default).toBe(
lightTheme.hue.neutral[100],
)
expect(lightTheme.contexts["@context:overlay"]?.background.action.primary.default).toBe(
lightTheme.hue.accent[500],
lightTheme.hue.interactive[500],
)
expect(lightTheme.contexts["@context:overlay"]?.background.default).toBe(lightTheme.background.surface.overlay)
expect(lightTheme.contexts["@context:overlay"]?.text.action.primary.default).toBe(
lightTheme.hue.neutral[100],
)
expect(darkTheme.contexts["@context:elevated"]?.background.action.primary.default).toBe(
darkTheme.hue.accent[400],
darkTheme.hue.interactive[400],
)
expect(darkTheme.contexts["@context:elevated"]?.text.action.primary.default).toBe(
darkTheme.hue.neutral[100],
)
expect(darkTheme.contexts["@context:overlay"]?.background.action.primary.default).toBe(
darkTheme.hue.accent[400],
darkTheme.hue.interactive[400],
)
expect(darkTheme.contexts["@context:overlay"]?.text.action.primary.default).toBe(
darkTheme.hue.neutral[900],
)
})
test("resolves base hue aliases and rejects circular hue aliases", () => {
const aliased = resolveTheme({
...light,
hue: { ...light.hue, blue: "$hue.red", purple: "$hue.blue" },
})
const overridden = resolveThemeFile(
{ version: 2, light: { hue: { blue: "$hue.red" } }, dark: {} },
"light",
)
expect(aliased.hue.blue).toBe(aliased.hue.red)
expect(aliased.hue.purple).toBe(aliased.hue.red)
expect(overridden.hue.blue).toBe(overridden.hue.red)
expect(() =>
resolveTheme({
...light,
hue: { ...light.hue, red: "$hue.blue", blue: "$hue.red" },
}),
).toThrow("Circular hue reference: red -> blue -> red")
})
test("merges partial files with the selected OpenCode defaults", () => {
const theme = resolveThemeFile(
{
@@ -117,7 +139,9 @@ test("resolves matched action variants and states", () => {
const theme = resolveTheme(light)
expect(theme.text.action.primary.pressed).toBeInstanceOf(RGBA)
expect(theme.text.action.primary.selected).toBeInstanceOf(RGBA)
expect(theme.background.action.primary.pressed).toBeInstanceOf(RGBA)
expect(theme.background.action.primary.selected).toBeInstanceOf(RGBA)
expect(theme.text.action.secondary.default).toBeInstanceOf(RGBA)
expect(theme.background.action.destructive.disabled).toBeInstanceOf(RGBA)
})
+7 -2
View File
@@ -19,11 +19,15 @@ const background = {
default: "$hue.neutral.100",
surface: { offset: "$hue.neutral.200", overlay: "$hue.neutral.300" },
action: {
primary: { default: "$hue.accent.600", $pressed: "$hue.accent.800" },
primary: {
default: "$hue.interactive.600",
$pressed: "$hue.interactive.800",
$selected: "$hue.interactive.700",
},
secondary: { default: "$hue.neutral.200" },
destructive: { default: "$hue.red.600" },
},
formfield: { default: "$hue.neutral.100", $selected: "$hue.accent.600" },
formfield: { default: "$hue.neutral.100", $selected: "$hue.interactive.600" },
feedback: { error: { default: "$hue.red.100" } },
} satisfies BackgroundDefinition
@@ -45,6 +49,7 @@ test("supports property-first definitions, variants, states, and contexts", () =
expect(text.action.primary.$pressed).toBe("$hue.neutral.200")
expect(text.formfield.$selected).toBe("$hue.neutral.100")
expect(background.action.destructive.default).toBe("$hue.red.600")
expect(background.action.primary.$selected).toBe("$hue.interactive.700")
expect(background.surface.offset).toBe("$hue.neutral.200")
expect(definition["@context:elevated"].text?.default).toBe("$hue.neutral.800")
expect(definition["@context:overlay"].background?.default).toBe("$hue.neutral.300")
+86 -20
View File
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test"
import { DEFAULT_THEMES, resolveTheme as resolveV1, selectedForeground } from "../../../src/theme"
import { DEFAULT_THEMES, resolveTheme as resolveV1 } from "../../../src/theme"
import { resolveThemeFile } from "../../../src/theme/v2/resolve"
import { migrateV1 } from "../../../src/theme/v2/v1-migrate"
@@ -10,11 +10,23 @@ test("migrates resolved V1 modes into literal V2 tokens", () => {
expect(migrated.standalone).toBeTrue()
expect(migrated.light.hue?.accent).toBeObject()
if (typeof migrated.light.hue?.accent !== "object") throw new Error("Expected a concrete accent scale")
expect(migrated.light.hue.accent[500]).toBe(hex(legacy.secondary))
expect(migrated.light.background?.default).toBe(hex(legacy.background))
expect(migrated.light.background?.action?.primary?.default).toBe(hex(legacy.primary))
expect(migrated.light.text?.action?.primary?.default).toBe(hex(selectedForeground(legacy, legacy.primary)))
expect(migrated.light.hue?.interactive).toBeObject()
if (typeof migrated.light.hue?.accent !== "object" || typeof migrated.light.hue.interactive !== "object") {
throw new Error("Expected concrete accent and interactive scales")
}
expect(migrated.light.hue.accent[900]).toBe(hex(legacy.accent))
expect(migrated.light.hue.interactive[900]).toBe(hex(legacy.primary))
expect(migrated.light.text?.default).toBe("$hue.neutral.900")
expect(migrated.light.text?.subdued).toBe("$hue.neutral.700")
expect(migrated.light.background?.action?.primary?.default).toBe("transparent")
expect(migrated.light.background?.default).toBe("$hue.neutral.100")
expect(migrated.light.background?.surface?.offset).toBe("$hue.neutral.200")
expect(migrated.light.background?.surface?.overlay).toBe("$hue.neutral.300")
expect(migrated.dark.background?.default).toBe("$hue.neutral.900")
expect(migrated.dark.background?.surface?.offset).toBe("$hue.neutral.800")
expect(migrated.dark.background?.surface?.overlay).toBe("$hue.neutral.700")
expect(migrated.light.text?.action?.primary?.default).toBe("$text.default")
expect(migrated.light.background?.action?.primary?.$selected).toBe("$hue.interactive.900")
expect(migrated.light.scrollbar?.default).toBe(hex(legacy.borderActive))
expect(migrated.light.diff?.lineNumber?.background?.removed).toBe(hex(legacy.diffRemovedLineNumberBg))
expect(migrated.light.markdown?.emphasis).toBe(hex(legacy.markdownEmph))
@@ -26,10 +38,10 @@ test("migrates resolved V1 modes into literal V2 tokens", () => {
expect(resolved.text.formfield.default.toInts()).toEqual(legacy.text.toInts())
expect(resolved.text.formfield.selected.toInts()).toEqual(legacy.primary.toInts())
expect(resolved.text.formfield.focused.toInts()).toEqual(legacy.primary.toInts())
expect(resolved.hue.accent[500].toInts()).toEqual(legacy.secondary.toInts())
expect(resolved.hue.accent[300].r + resolved.hue.accent[300].g + resolved.hue.accent[300].b).toBeGreaterThan(
resolved.hue.accent[500].r + resolved.hue.accent[500].g + resolved.hue.accent[500].b,
)
expect(resolved.hue.accent[900].toInts()).toEqual(legacy.accent.toInts())
expect(resolved.hue.interactive[900].toInts()).toEqual(legacy.primary.toInts())
expect(resolved.background.action.primary.selected.toInts()).toEqual(legacy.primary.toInts())
expect(resolved.text.action.primary.selected.toInts()).toEqual(legacy.primary.toInts())
expect(resolved.background.feedback.error.default.toInts()).toEqual(legacy.background.toInts())
expect(resolved.contexts["@context:elevated"]?.background.default.toInts()).toEqual(
legacy.backgroundPanel.toInts(),
@@ -37,29 +49,83 @@ test("migrates resolved V1 modes into literal V2 tokens", () => {
expect(resolved.contexts["@context:elevated"]?.background.action.secondary.default.toInts()).toEqual(
legacy.backgroundPanel.toInts(),
)
expect(resolved.contexts["@context:elevated"]?.background.action.primary.default.toInts()).toEqual(
legacy.primary.toInts(),
)
expect(resolved.contexts["@context:elevated"]?.background.action.primary.default.toInts()).toEqual([0, 0, 0, 0])
expect(resolved.contexts["@context:elevated"]?.text.action.primary.default.toInts()).toEqual(
selectedForeground(legacy, legacy.primary).toInts(),
legacy.text.toInts(),
)
expect(resolved.contexts["@context:overlay"]?.background.default.toInts()).toEqual(
legacy.backgroundMenu.toInts(),
)
expect(resolved.contexts["@context:overlay"]?.background.action.primary.default.toInts()).toEqual(
legacy.primary.toInts(),
)
expect(resolved.contexts["@context:overlay"]?.background.action.primary.default.toInts()).toEqual([0, 0, 0, 0])
})
test("preserves V1 selected foreground behavior on transparent backgrounds", () => {
test("infers chromatic hues, anchors light and dark colors, and aliases ambiguous hues to gray", () => {
const source = structuredClone(DEFAULT_THEMES.opencode)
const ambiguous = { light: "#808080", dark: "#808080" }
source.theme.accent = ambiguous
source.theme.warning = ambiguous
source.theme.primary = ambiguous
source.theme.error = ambiguous
source.theme.info = ambiguous
source.theme.secondary = "transparent"
source.theme.success = { light: "#ff6666", dark: "#450000" }
const migrated = migrateV1(source)
const lightRed = migrated.light.hue?.red
const darkRed = migrated.dark.hue?.red
if (typeof lightRed !== "object" || typeof darkRed !== "object") throw new Error("Expected generated red scales")
expect(lightRed[900]).toBe("#ff6666")
expect(darkRed[100]).toBe("#450000")
expect(migrated.light.hue?.orange).toBe("$hue.gray")
expect(migrated.light.hue?.yellow).toBe("$hue.gray")
expect(migrated.light.hue?.green).toBe("$hue.gray")
expect(migrated.light.hue?.cyan).toBe("$hue.gray")
expect(migrated.light.hue?.blue).toBe("$hue.gray")
expect(migrated.light.hue?.purple).toBe("$hue.gray")
expect(migrated.light.hue?.accent).toBe("$hue.gray")
expect(migrated.light.hue?.interactive).toBe("$hue.gray")
expect(() => resolveThemeFile(migrated, "light")).not.toThrow()
expect(() => resolveThemeFile(migrated, "dark")).not.toThrow()
})
test("builds gray from V1 surfaces and text without using borders", () => {
const source = structuredClone(DEFAULT_THEMES.opencode)
source.theme.backgroundMenu = { light: "#ededed", dark: "#252525" }
const light = resolveV1(source, "light")
const dark = resolveV1(source, "dark")
const migrated = migrateV1(source)
const lightGray = migrated.light.hue?.gray
const darkGray = migrated.dark.hue?.gray
if (typeof lightGray !== "object" || typeof darkGray !== "object") throw new Error("Expected concrete gray scales")
expect(lightGray[100]).toBe(hex(light.background))
expect(lightGray[200]).toBe(hex(light.backgroundPanel))
expect(lightGray[300]).toBe(hex(light.backgroundMenu))
expect(lightGray[700]).toBe(hex(light.textMuted))
expect(lightGray[900]).toBe(hex(light.text))
expect(darkGray[100]).toBe(hex(dark.text))
expect(darkGray[300]).toBe(hex(dark.textMuted))
expect(darkGray[700]).toBe(hex(dark.backgroundMenu))
expect(darkGray[800]).toBe(hex(dark.backgroundPanel))
expect(darkGray[900]).toBe(hex(dark.background))
source.theme.borderSubtle = "#ff00ff"
source.theme.border = "#00ff00"
source.theme.borderActive = "#00ffff"
expect(migrateV1(source).light.hue?.gray).toEqual(lightGray)
expect(migrateV1(source).dark.hue?.gray).toEqual(darkGray)
})
test("uses the default text reference for primary actions on transparent backgrounds", () => {
const source = structuredClone(DEFAULT_THEMES.opencode)
source.theme.background = "transparent"
source.theme.primary = { light: "#ffffff", dark: "#000000" }
delete source.theme.selectedListItemText
const migrated = migrateV1(source)
expect(migrated.light.text?.action?.primary?.default).toBe("#000000")
expect(migrated.dark.text?.action?.primary?.default).toBe("#ffffff")
expect(migrated.light.text?.action?.primary?.default).toBe("$text.default")
expect(migrated.dark.text?.action?.primary?.default).toBe("$text.default")
})
test("retains V1 circular reference errors", () => {
+28 -48
View File
@@ -1,19 +1,16 @@
# Session-Aware One-Shot Generation Plan
Status: **Proposed**
Status: **In progress**
## Decision
Add a Session operation that prepares one request from the Session's active model context, appends a transient prompt, executes exactly one Physical Attempt, returns the assistant text, and leaves the Session unchanged:
```ts
const { text } = await client.session.generate({
sessionID,
text: "Summarize where we left off.",
})
const text = yield * session.generate({ sessionID, prompt: "Summarize where we left off." })
```
The operation belongs to Session because its meaning depends on Session History, selected agent and model, instructions, tools, provider transforms, hooks, and prompt-cache identity. The existing stateless `Generate.text` module remains unaware of Session.
The operation belongs to Session because its meaning depends on Session History, the selected agent and model, instructions, provider transforms, hooks, and prompt-cache identity. The existing stateless `Generate.text` module remains unaware of Session.
This feature should deepen the Session request-preparation module rather than add a second approximation of the runner. The durable runner and `session.generate` must share preparation, then diverge before provider output acquires durable consequences.
@@ -38,14 +35,15 @@ The desired architecture makes request preparation independently callable while
session.prompt
-> SessionAdmission
-> SessionExecution
-> SessionContext.snapshot
-> SessionContext.select/load
-> SessionModelRequest.prepare
-> LLMClient.stream
-> SessionSettlement
session.generate
-> SessionContext.snapshot
-> SessionModelRequest.prepare
-> SessionContext.select
-> SessionHistory.preview
-> SessionGenerate request construction
-> LLMClient.generate
-> return text
```
@@ -54,7 +52,8 @@ The modules have distinct jobs:
- `SessionAdmission` records and promotes durable input.
- `SessionContext` resolves a read-only, internally consistent view of what the selected agent would see.
- `SessionModelRequest` converts that view into the provider request used by a Physical Attempt.
- `SessionModelRequest` converts durable Step context into a provider request paired with tool capability.
- `SessionGenerate` owns the one tool-free transient request shape rather than threading generation through the durable modules.
- `LLMClient` executes one provider request without deciding what becomes durable.
- `SessionSettlement` gives streamed provider events their durable Session meaning, executes local tools, records usage, and decides continuation.
- `SessionCompaction` replaces oversized active history and remains a durable Session operation.
@@ -63,22 +62,18 @@ Durability becomes a property of admission and settlement, not request preparati
## The Core Seam
The first extraction should be one internal Location-scoped module with a small interface. Names are provisional; behavior is not.
The Location-scoped request module keeps its durable Step interface:
```ts
interface SessionModelRequest {
readonly prepare: (input: {
snapshot: SessionContext.Snapshot
operation: { type: "step"; current: number; maximum?: number } | { type: "generate"; prompt: Message.User }
}) => Effect<PreparedSessionModelRequest, SessionModelRequestError>
readonly prepare: (input: { context: SessionContext.Loaded; step: number }) => Effect<PreparedSessionModelRequest>
}
```
```ts
type PreparedSessionModelRequest = {
request: LLM.Request
snapshot: SessionContext.Snapshot
executableTools?: MaterializedTools
resolveToolCall: (name: string) => ToolCallResolution
}
```
@@ -95,14 +90,7 @@ type PreparedSessionModelRequest = {
- provider transforms and Session context hooks;
- Session-based prompt-cache identity.
The operation determines tool capability and request shape:
- `step` advertises tools and returns the execution capability used by durable settlement, except when the agent's Step limit disables tools.
- `generate` advertises the same definitions but returns no execution capability.
`session.generate` keeps normal tool choice so providers retain the normal request and cache shape. Several protocols omit tool definitions entirely when `toolChoice` is `none`, so that setting cannot satisfy cache-shape parity. A generated tool call is collected but never executed or continued.
Avoid a broad bag of booleans or independently selectable tool modes. The operation discriminant derives valid tool materialization and request behavior. Admission, compaction, attempts, and settlement remain separate modules rather than modes hidden inside preparation.
Durable `prepare` advertises permitted tools and returns the capability used by settlement, except when the agent's Step limit disables tools. `SessionGenerate` constructs its request with no tool definitions and tool choice `none`, independently of agent permissions. Avoid a generic `step | generate | compaction` operation union or independently selectable behavior flags; the two operations own their concrete request shapes.
## Read-Only Session Context
@@ -117,17 +105,19 @@ commit the canonical model's instruction state durable
Both a durable Step and `session.generate` resolve and assemble instructions. Only durable execution commits instruction-state changes. A transient request must not make the false durable claim that the canonical Session model saw an instruction update.
The context snapshot should be loaded from one consistent database view and carry a revision, likely the latest aggregate or projected sequence used to assemble it. A concurrent durable Step may advance the Session after that point; the transient request continues against its immutable snapshot.
The active history and committed instruction state are loaded from one consistent database view. A concurrent durable Step may advance the Session afterward; the already prepared request remains immutable. No reusable snapshot or revision protocol is needed for this operation.
Pending inputs remain excluded. They are not Session History until promotion, and `session.generate` must not alter admission order or expose queued work early.
If the Session currently has an unsettled assistant message, generation stops history at that boundary. In particular, it never appends the transient user prompt after an unresolved tool call.
## One Physical Attempt Without Settlement
Use the existing `LLMClient` interface directly. The durable runner consumes `llm.stream(prepared.request)`, while `session.generate` calls `llm.generate(prepared.request)` to collect the same event stream into its existing `LLMResponse` model. No additional provider-attempt module is needed.
`LLMClient.generate` collects exactly one provider stream. It does not retry as a new logical Step, execute tools, continue after tool calls, publish Session events, capture filesystem snapshots, or update Session usage.
The initial public result exposes only `{ text }`. Keeping richer evidence internal avoids prematurely committing the public contract while allowing tests to verify tool-call and finish behavior.
The internal result is the collected text string. Keeping richer evidence internal avoids prematurely committing a public transport contract.
If a provider returns tool calls, collection records and ignores them. No tool hook or execution path runs. Assistant text, including an empty string, remains a successful result. Empty text is required for cache-warming calls.
@@ -147,32 +137,22 @@ The first contract should state:
The operation does not acquire ownership of the durable Session Drain and does not fail merely because the Session is running. This keeps transient generation independent from durable scheduling.
The prepared result carries the captured revision internally. A later recap integration can suppress stale output when the Session advances while generation runs. Returning the revision publicly can follow if more consumers need compare-and-display behavior.
A later recap integration can suppress stale output by comparing the Session aggregate sequence captured by that caller. Core does not expose a generic revision protocol before a concrete consumer needs one.
## Hooks Follow The Stage They Affect
The existing Session context hook should run because it participates in normal request preparation. Its event should eventually identify the operation:
```ts
type SessionModelRequestOperation = { type: "step"; step: number } | { type: "generate" } | { type: "compaction" }
```
Request preparation and observation hooks run for `session.generate`. Admission, projection, Session settlement, and tool-execution hooks do not run because those stages do not occur.
The existing Session context hook runs because it participates in normal request preparation. Admission, projection, Session settlement, and tool-execution hooks do not run because those stages do not occur. Add operation metadata only when a concrete hook consumer needs it; do not introduce a speculative operation union.
The no-mutation guarantee covers OpenCode's durable Session state. Arbitrary plugin hooks may still perform external side effects.
## Public Contract
## Internal Contract
Start with the smallest useful interface:
```ts
type SessionGenerateInput = {
sessionID: SessionID
text: string
}
type SessionGenerateOutput = {
text: string
prompt: string
}
```
@@ -180,9 +160,9 @@ The operation:
1. Resolves the Session or returns the normal Session-not-found error.
2. Captures its latest committed active model context.
3. Uses the selected agent, model, instructions, provider configuration, tools, transforms, hooks, and Session cache key.
4. Appends `text` only to the in-memory provider request.
5. Executes exactly one Physical Attempt with normal tool definitions and no executable tool capability.
3. Uses the selected agent, model, instructions, provider configuration, transforms, hooks, and Session cache key.
4. Appends `prompt` only to the in-memory provider request.
5. Executes exactly one Physical Attempt with tools disabled.
6. Returns collected assistant text, including an empty string.
7. Does not admit input, publish Session events, execute tools, initiate compaction, update usage, or mutate Session projections.
@@ -229,7 +209,7 @@ promote -> snapshot -> compact if required -> prepare -> attempt -> settle
### 5. Add The Core `Session.generate` Operation
Use read-only snapshot and request preparation, append one transient user message, select advertise-only tools, collect one attempt, and return text. Add tests proving that messages, pending inputs, instruction state, Session events, snapshots, and usage remain unchanged.
Use read-only context and request preparation, append one transient user message, disable tools, collect one attempt, and return text. Add tests proving that messages, pending inputs, instruction state, Session events, and usage remain unchanged.
Test concurrent Session advancement with deterministic synchronization around request dispatch. The generated request should retain its captured context while the source Session advances independently.
@@ -248,11 +228,11 @@ The implementation is complete when tests establish these laws:
1. **Request equivalence:** the durable runner's prepared request remains equivalent before and after extraction.
2. **Transcript immutability:** `session.generate` leaves Session messages and pending inputs unchanged.
3. **Instruction immutability:** transient generation does not advance instruction state or publish instruction events.
4. **Single attempt:** one call produces exactly one `llm.stream` invocation and no continuation.
5. **No tool execution:** advertised tool calls never reach tool settlement or tool hooks.
4. **Single attempt:** one call produces exactly one `llm.generate` invocation and no continuation.
5. **No tools:** transient requests advertise no tools and never reach tool settlement or tool hooks.
6. **Cache identity:** normal Steps and transient generation use the same Session-derived prompt-cache key.
7. **Hook parity:** Session request hooks see and may transform transient requests through the same preparation seam.
8. **Empty success:** a provider response with no assistant text returns `{ text: "" }`.
8. **Empty success:** a provider response with no assistant text returns `""`.
9. **Snapshot isolation:** concurrent Session advancement does not change an already prepared transient request.
10. **No accounting mutation:** transient usage does not alter durable Session cost or token totals.
+9 -9
View File
@@ -1,10 +1,10 @@
# Provider Policy Proposal
# Provider Policy
Status: **Proposed and unimplemented.** This document is design input, not current configuration or runtime behavior.
Status: **Implemented.**
## Purpose
This proposal would let policies control whether an operation on a named resource is allowed. Statements could be authored in configuration files while policy evaluation remained its own runtime concern.
Policies control whether an operation on a named resource is allowed. Statements are authored in configuration files and applied by a terminal catalog plugin.
The first policy consumer is provider availability:
@@ -60,7 +60,7 @@ interface PolicyInfo {
}
```
The proposed `Policy` module would own the shared `Policy.Info` interface, `Policy.Effect` type, and evaluator. Domains would define their supported typed statement schemas; for example, `Catalog.ProviderPolicy` would fix `action` to `"provider.use"`. The config schema could gather those domain-defined statement schemas into an `experimental.policies` union.
`ConfigPolicy` owns the statement schema. The policy plugin interprets the supported `provider.use` action after all other catalog transforms have run.
## Matching
@@ -236,16 +236,16 @@ Provider policy applies regardless of how a provider becomes known or usable, in
## Applying Provider Policy
Provider records and model overrides should be assembled before checking provider policy. Otherwise later provider loading could recreate a provider that was already filtered.
Provider records and model overrides are assembled before checking provider policy. Otherwise later provider loading could recreate a provider that was already filtered.
Intended flow:
Flow:
1. Build provider/model catalog entries.
2. Apply configured provider and model overrides.
3. Ask `Policy.Service` to evaluate `provider.use` for each provider ID.
4. Prevent denied providers from being selectable or used.
3. Run the terminal config policy transform.
4. Remove providers denied by the final matching `provider.use` statement.
Whether denied providers are removed entirely or retained as disabled records for diagnostics remains an implementation decision.
Config reload refreshes the plugin's policy snapshot and rebuilds the catalog.
## Legacy Migration