mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-22 18:56:10 +00:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4778e6e031 | ||
|
|
ac1b802820 | ||
|
|
5ee4c1082a | ||
|
|
8a70d70006 | ||
|
|
91b4634363 | ||
|
|
4f60bde502 | ||
|
|
0d3b6d430e | ||
|
|
a7cf21e157 | ||
|
|
e4a16830f1 | ||
|
|
103f764624 | ||
|
|
7a7075d86f | ||
|
|
c764732aea | ||
|
|
72af084dc1 | ||
|
|
65a42fd549 |
@@ -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
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
}) {}
|
||||
|
||||
@@ -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 }),
|
||||
)
|
||||
}),
|
||||
})
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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* () {
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
@@ -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") {}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 ?? {},
|
||||
|
||||
@@ -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] })
|
||||
|
||||
@@ -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 }),
|
||||
)
|
||||
|
||||
@@ -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 })
|
||||
}),
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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`)
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
}
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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 })
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user