Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 67c0c79613 | |||
| 603fa77705 | |||
| 3898cac63d | |||
| 2fee823dbf | |||
| 334613c18f | |||
| aa2c1472fa | |||
| cfdb9ab705 | |||
| 67b4b2894f | |||
| bf5294121a | |||
| 7d598b5610 | |||
| fae51d853d | |||
| 966dc54e1d | |||
| 606de48d8b | |||
| 0d1ef5adb6 | |||
| f016392368 | |||
| 140224b0fc | |||
| cfd35c9354 | |||
| 674d08f9be | |||
| 02f012f5d1 | |||
| bea6e1499d | |||
| 50a1fe49bc | |||
| 6380919fda | |||
| d972aa9d83 | |||
| d544ab4d91 | |||
| ce228bfd7c | |||
| eabf85aea2 | |||
| 2f4a688790 | |||
| 4a90ffedfb | |||
| 95cf5039be |
@@ -0,0 +1,36 @@
|
||||
export default {
|
||||
id: "Orchestrator",
|
||||
setup: async (ctx) => {
|
||||
await ctx.agent.transform((agents) => {
|
||||
agents.update("orchestrator", (agent) => {
|
||||
agent.description = "Coordinates work by delegating implementation tasks to the minion subagent."
|
||||
agent.mode = "primary"
|
||||
agent.system = [
|
||||
"You are Orchestrator, the primary coordinating agent for this repository. You do meta work only: you coordinate, brief, and synthesize — you do not perform the work itself.",
|
||||
"Delegate ALL actual work to the minion subagent — implementation, exploration, discovery, searching the codebase, reading files to understand a problem, and even trivial one-line edits. Task size is never a reason to do it yourself, and there is no 'final integration' exception.",
|
||||
"You are not hard-banned from tools, but direct tool use is reserved for coordination overhead: a quick peek to phrase a better brief, a fast read-only check to verify a minion's reported result, or answering a question about coordination state. If a tool call is producing the answer or the artifact the user asked for, that call belongs to a minion, not you.",
|
||||
"Exploration is work. If the user asks how something works or where something lives, delegate the investigation to a minion rather than exploring yourself.",
|
||||
"Always start minion subagents in the background. Even if you have nothing else to coordinate right now, the user may assign you new work while a Minion runs, and you must stay free to receive it. Never poll; you will be notified when they finish.",
|
||||
"Give each minion a clear, self-contained brief: the goal, constraints, expected output, and any files or context already known from the user or previous minion reports.",
|
||||
"Synthesize minion results, decide next steps, and report back concisely.",
|
||||
].join("\n")
|
||||
})
|
||||
|
||||
agents.update("minion", (agent) => {
|
||||
agent.description = "Subagent that executes focused tasks delegated by Orchestrator."
|
||||
agent.mode = "subagent"
|
||||
agent.model = { providerID: "opencode", id: "glm-5.2" }
|
||||
agent.system = [
|
||||
"You are minion, a focused execution subagent for this repository.",
|
||||
"Complete the specific task delegated to you by Orchestrator using the available tools.",
|
||||
"Inspect the codebase before making assumptions, make targeted changes when requested, and verify your work when feasible.",
|
||||
"Follow the repository's AGENTS.md conventions: respect the style guide, run `bun typecheck` from the affected package directory after code changes, never run tests from the repo root, and do not modify packages/opencode unless the task explicitly says V1 work.",
|
||||
"If the task is ambiguous or you hit a blocker, stop and report your findings instead of guessing.",
|
||||
"Keep your final response concise: summarize what you did, list important files changed or findings, and call out blockers or verification gaps.",
|
||||
"Do not delegate to other subagents; execute the assigned work yourself.",
|
||||
].join("\n")
|
||||
agent.permissions.push({ action: "subagent", resource: "*", effect: "deny" })
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
export default {
|
||||
id: "sample-agent-plugin",
|
||||
setup: async (ctx) => {
|
||||
await ctx.agent.transform((agents) => {
|
||||
agents.update("sample-plugin-agent", (agent) => {
|
||||
agent.description = "Example primary agent registered by .opencode/plugins/sample-agent.ts"
|
||||
agent.mode = "primary"
|
||||
agent.system = [
|
||||
"You are the sample plugin agent for this repository.",
|
||||
"Use this agent to verify that local plugin auto-discovery can add agents.",
|
||||
"Keep responses concise and explain which plugin registered you when asked.",
|
||||
].join("\n")
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -159,4 +159,5 @@ const table = sqliteTable("session", {
|
||||
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash continuation recovery requires a separate explicit design before it may retry provider work. A drain has no durable identity or transcript boundary.
|
||||
- Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe provider-turn boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's provider-turn allowance; a batch of steers resets it once.
|
||||
- Keep EventV2 replay owner claims separate from clustered Session execution ownership.
|
||||
- Keep the System Context algebra, registry, and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Epoch persistence Session-owned.
|
||||
- Keep the System Context algebra and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Checkpoint persistence Session-owned. The runner composes all context producers explicitly in `loadSystemContext`; there is no context registry.
|
||||
- The durable Applied record is what the model was last told, per source. Reconcile narrates drift as chronological System updates and never rewrites the baseline; only completed compaction rebaselines, and move or committed revert resets the checkpoint. Unavailable sources keep the model's prior belief, blocking only a session's first baseline.
|
||||
|
||||
@@ -164,23 +164,36 @@ const Endpoint4_9 = (raw: RawClient["server.session"]) => (input: Endpoint4_9Inp
|
||||
payload: { id: input["id"], skill: input["skill"], resume: input["resume"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint4_10Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
|
||||
type Endpoint4_10Input = { readonly sessionID: Endpoint4_10Request["params"]["sessionID"] }
|
||||
type Endpoint4_10Request = Parameters<RawClient["server.session"]["session.synthetic"]>[0]
|
||||
type Endpoint4_10Input = {
|
||||
readonly sessionID: Endpoint4_10Request["params"]["sessionID"]
|
||||
readonly text: Endpoint4_10Request["payload"]["text"]
|
||||
readonly description?: Endpoint4_10Request["payload"]["description"]
|
||||
readonly metadata?: Endpoint4_10Request["payload"]["metadata"]
|
||||
}
|
||||
const Endpoint4_10 = (raw: RawClient["server.session"]) => (input: Endpoint4_10Input) =>
|
||||
raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
raw["session.synthetic"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { text: input["text"], description: input["description"], metadata: input["metadata"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint4_11Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
|
||||
type Endpoint4_11Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
|
||||
type Endpoint4_11Input = { readonly sessionID: Endpoint4_11Request["params"]["sessionID"] }
|
||||
const Endpoint4_11 = (raw: RawClient["server.session"]) => (input: Endpoint4_11Input) =>
|
||||
raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint4_12Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
|
||||
type Endpoint4_12Input = { readonly sessionID: Endpoint4_12Request["params"]["sessionID"] }
|
||||
const Endpoint4_12 = (raw: RawClient["server.session"]) => (input: Endpoint4_12Input) =>
|
||||
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint4_12Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
|
||||
type Endpoint4_12Input = {
|
||||
readonly sessionID: Endpoint4_12Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint4_12Request["payload"]["messageID"]
|
||||
readonly files?: Endpoint4_12Request["payload"]["files"]
|
||||
type Endpoint4_13Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
|
||||
type Endpoint4_13Input = {
|
||||
readonly sessionID: Endpoint4_13Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint4_13Request["payload"]["messageID"]
|
||||
readonly files?: Endpoint4_13Request["payload"]["files"]
|
||||
}
|
||||
const Endpoint4_12 = (raw: RawClient["server.session"]) => (input: Endpoint4_12Input) =>
|
||||
const Endpoint4_13 = (raw: RawClient["server.session"]) => (input: Endpoint4_13Input) =>
|
||||
raw["session.revert.stage"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { messageID: input["messageID"], files: input["files"] },
|
||||
@@ -189,42 +202,42 @@ const Endpoint4_12 = (raw: RawClient["server.session"]) => (input: Endpoint4_12I
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint4_13Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
|
||||
type Endpoint4_13Input = { readonly sessionID: Endpoint4_13Request["params"]["sessionID"] }
|
||||
const Endpoint4_13 = (raw: RawClient["server.session"]) => (input: Endpoint4_13Input) =>
|
||||
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint4_14Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
|
||||
type Endpoint4_14Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
|
||||
type Endpoint4_14Input = { readonly sessionID: Endpoint4_14Request["params"]["sessionID"] }
|
||||
const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14Input) =>
|
||||
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint4_15Request = Parameters<RawClient["server.session"]["session.context"]>[0]
|
||||
type Endpoint4_15Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
|
||||
type Endpoint4_15Input = { readonly sessionID: Endpoint4_15Request["params"]["sessionID"] }
|
||||
const Endpoint4_15 = (raw: RawClient["server.session"]) => (input: Endpoint4_15Input) =>
|
||||
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint4_16Request = Parameters<RawClient["server.session"]["session.context"]>[0]
|
||||
type Endpoint4_16Input = { readonly sessionID: Endpoint4_16Request["params"]["sessionID"] }
|
||||
const Endpoint4_16 = (raw: RawClient["server.session"]) => (input: Endpoint4_16Input) =>
|
||||
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint4_16Request = Parameters<RawClient["server.session"]["session.history"]>[0]
|
||||
type Endpoint4_16Input = {
|
||||
readonly sessionID: Endpoint4_16Request["params"]["sessionID"]
|
||||
readonly limit?: Endpoint4_16Request["query"]["limit"]
|
||||
readonly after?: Endpoint4_16Request["query"]["after"]
|
||||
type Endpoint4_17Request = Parameters<RawClient["server.session"]["session.history"]>[0]
|
||||
type Endpoint4_17Input = {
|
||||
readonly sessionID: Endpoint4_17Request["params"]["sessionID"]
|
||||
readonly limit?: Endpoint4_17Request["query"]["limit"]
|
||||
readonly after?: Endpoint4_17Request["query"]["after"]
|
||||
}
|
||||
const Endpoint4_16 = (raw: RawClient["server.session"]) => (input: Endpoint4_16Input) =>
|
||||
const Endpoint4_17 = (raw: RawClient["server.session"]) => (input: Endpoint4_17Input) =>
|
||||
raw["session.history"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
query: { limit: input["limit"], after: input["after"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint4_17Request = Parameters<RawClient["server.session"]["session.events"]>[0]
|
||||
type Endpoint4_17Input = {
|
||||
readonly sessionID: Endpoint4_17Request["params"]["sessionID"]
|
||||
readonly after?: Endpoint4_17Request["query"]["after"]
|
||||
type Endpoint4_18Request = Parameters<RawClient["server.session"]["session.events"]>[0]
|
||||
type Endpoint4_18Input = {
|
||||
readonly sessionID: Endpoint4_18Request["params"]["sessionID"]
|
||||
readonly after?: Endpoint4_18Request["query"]["after"]
|
||||
}
|
||||
const Endpoint4_17 = (raw: RawClient["server.session"]) => (input: Endpoint4_17Input) =>
|
||||
const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18Input) =>
|
||||
Stream.unwrap(
|
||||
raw["session.events"]({ params: { sessionID: input["sessionID"] }, query: { after: input["after"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
@@ -232,22 +245,22 @@ const Endpoint4_17 = (raw: RawClient["server.session"]) => (input: Endpoint4_17I
|
||||
),
|
||||
)
|
||||
|
||||
type Endpoint4_18Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
||||
type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] }
|
||||
const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18Input) =>
|
||||
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.background"]>[0]
|
||||
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
||||
type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] }
|
||||
const Endpoint4_19 = (raw: RawClient["server.session"]) => (input: Endpoint4_19Input) =>
|
||||
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.background"]>[0]
|
||||
type Endpoint4_20Input = { readonly sessionID: Endpoint4_20Request["params"]["sessionID"] }
|
||||
const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) =>
|
||||
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||
type Endpoint4_20Input = {
|
||||
readonly sessionID: Endpoint4_20Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint4_20Request["params"]["messageID"]
|
||||
type Endpoint4_21Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||
type Endpoint4_21Input = {
|
||||
readonly sessionID: Endpoint4_21Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint4_21Request["params"]["messageID"]
|
||||
}
|
||||
const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) =>
|
||||
const Endpoint4_21 = (raw: RawClient["server.session"]) => (input: Endpoint4_21Input) =>
|
||||
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
@@ -264,17 +277,18 @@ const adaptGroup4 = (raw: RawClient["server.session"]) => ({
|
||||
rename: Endpoint4_7(raw),
|
||||
prompt: Endpoint4_8(raw),
|
||||
skill: Endpoint4_9(raw),
|
||||
compact: Endpoint4_10(raw),
|
||||
wait: Endpoint4_11(raw),
|
||||
revertStage: Endpoint4_12(raw),
|
||||
revertClear: Endpoint4_13(raw),
|
||||
revertCommit: Endpoint4_14(raw),
|
||||
context: Endpoint4_15(raw),
|
||||
history: Endpoint4_16(raw),
|
||||
events: Endpoint4_17(raw),
|
||||
interrupt: Endpoint4_18(raw),
|
||||
background: Endpoint4_19(raw),
|
||||
message: Endpoint4_20(raw),
|
||||
synthetic: Endpoint4_10(raw),
|
||||
compact: Endpoint4_11(raw),
|
||||
wait: Endpoint4_12(raw),
|
||||
revertStage: Endpoint4_13(raw),
|
||||
revertClear: Endpoint4_14(raw),
|
||||
revertCommit: Endpoint4_15(raw),
|
||||
context: Endpoint4_16(raw),
|
||||
history: Endpoint4_17(raw),
|
||||
events: Endpoint4_18(raw),
|
||||
interrupt: Endpoint4_19(raw),
|
||||
background: Endpoint4_20(raw),
|
||||
message: Endpoint4_21(raw),
|
||||
})
|
||||
|
||||
type Endpoint5_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0]
|
||||
@@ -297,7 +311,12 @@ type Endpoint6_0Input = { readonly location?: Endpoint6_0Request["query"]["locat
|
||||
const Endpoint6_0 = (raw: RawClient["server.model"]) => (input?: Endpoint6_0Input) =>
|
||||
raw["model.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
const adaptGroup6 = (raw: RawClient["server.model"]) => ({ list: Endpoint6_0(raw) })
|
||||
type Endpoint6_1Request = Parameters<RawClient["server.model"]["model.default"]>[0]
|
||||
type Endpoint6_1Input = { readonly location?: Endpoint6_1Request["query"]["location"] }
|
||||
const Endpoint6_1 = (raw: RawClient["server.model"]) => (input?: Endpoint6_1Input) =>
|
||||
raw["model.default"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
const adaptGroup6 = (raw: RawClient["server.model"]) => ({ list: Endpoint6_0(raw), default: Endpoint6_1(raw) })
|
||||
|
||||
type Endpoint7_0Request = Parameters<RawClient["server.generate"]["generate.text"]>[0]
|
||||
type Endpoint7_0Input = {
|
||||
|
||||
@@ -25,6 +25,8 @@ import type {
|
||||
SessionPromptOutput,
|
||||
SessionSkillInput,
|
||||
SessionSkillOutput,
|
||||
SessionSyntheticInput,
|
||||
SessionSyntheticOutput,
|
||||
SessionCompactInput,
|
||||
SessionCompactOutput,
|
||||
SessionWaitInput,
|
||||
@@ -51,6 +53,8 @@ import type {
|
||||
MessageListOutput,
|
||||
ModelListInput,
|
||||
ModelListOutput,
|
||||
ModelDefaultInput,
|
||||
ModelDefaultOutput,
|
||||
GenerateTextInput,
|
||||
GenerateTextOutput,
|
||||
ProviderListInput,
|
||||
@@ -461,6 +465,18 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
synthetic: (input: SessionSyntheticInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionSyntheticOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/synthetic`,
|
||||
body: { text: input["text"], description: input["description"], metadata: input["metadata"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
compact: (input: SessionCompactInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionCompactOutput>(
|
||||
{
|
||||
@@ -613,6 +629,18 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
default: (input?: ModelDefaultInput, requestOptions?: RequestOptions) =>
|
||||
request<ModelDefaultOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/model/default`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [503, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
generate: {
|
||||
text: (input: GenerateTextInput, requestOptions?: RequestOptions) =>
|
||||
|
||||
@@ -151,6 +151,7 @@ export type AgentListOutput = {
|
||||
readonly id: string
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
readonly request: {
|
||||
readonly settings: { readonly [x: string]: JsonValue }
|
||||
readonly headers: { readonly [x: string]: string }
|
||||
readonly body: { readonly [x: string]: JsonValue }
|
||||
}
|
||||
@@ -584,6 +585,27 @@ export type SessionSkillInput = {
|
||||
|
||||
export type SessionSkillOutput = void
|
||||
|
||||
export type SessionSyntheticInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly text: {
|
||||
readonly text: string
|
||||
readonly description?: string | null
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
}["text"]
|
||||
readonly description?: {
|
||||
readonly text: string
|
||||
readonly description?: string | null
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
}["description"]
|
||||
readonly metadata?: {
|
||||
readonly text: string
|
||||
readonly description?: string | null
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
}["metadata"]
|
||||
}
|
||||
|
||||
export type SessionSyntheticOutput = void
|
||||
|
||||
export type SessionCompactInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
export type SessionCompactOutput = void
|
||||
@@ -934,6 +956,7 @@ export type SessionHistoryOutput = {
|
||||
readonly messageID: string
|
||||
readonly text: string
|
||||
readonly description?: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -1428,6 +1451,7 @@ export type SessionEventsOutput =
|
||||
readonly messageID: string
|
||||
readonly text: string
|
||||
readonly description?: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -2169,12 +2193,14 @@ export type ModelListOutput = {
|
||||
readonly output: ReadonlyArray<string>
|
||||
}
|
||||
readonly request: {
|
||||
readonly settings: { readonly [x: string]: JsonValue }
|
||||
readonly headers: { readonly [x: string]: string }
|
||||
readonly body: { readonly [x: string]: JsonValue }
|
||||
readonly variant?: string
|
||||
}
|
||||
readonly variants: ReadonlyArray<{
|
||||
readonly id: string
|
||||
readonly settings: { readonly [x: string]: JsonValue }
|
||||
readonly headers: { readonly [x: string]: string }
|
||||
readonly body: { readonly [x: string]: JsonValue }
|
||||
}>
|
||||
@@ -2191,6 +2217,67 @@ export type ModelListOutput = {
|
||||
}>
|
||||
}
|
||||
|
||||
export type ModelDefaultInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type ModelDefaultOutput = {
|
||||
readonly location: {
|
||||
readonly directory: string
|
||||
readonly workspaceID?: string
|
||||
readonly project: { readonly id: string; readonly directory: string }
|
||||
}
|
||||
readonly data: {
|
||||
readonly id: string
|
||||
readonly providerID: string
|
||||
readonly family?: string
|
||||
readonly name: string
|
||||
readonly api:
|
||||
| {
|
||||
readonly id: string
|
||||
readonly type: "aisdk"
|
||||
readonly package: string
|
||||
readonly url?: string
|
||||
readonly settings?: { readonly [x: string]: JsonValue }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly type: "native"
|
||||
readonly url?: string
|
||||
readonly settings: { readonly [x: string]: JsonValue }
|
||||
}
|
||||
readonly capabilities: {
|
||||
readonly tools: boolean
|
||||
readonly input: ReadonlyArray<string>
|
||||
readonly output: ReadonlyArray<string>
|
||||
}
|
||||
readonly request: {
|
||||
readonly settings: { readonly [x: string]: JsonValue }
|
||||
readonly headers: { readonly [x: string]: string }
|
||||
readonly body: { readonly [x: string]: JsonValue }
|
||||
readonly variant?: string
|
||||
}
|
||||
readonly variants: ReadonlyArray<{
|
||||
readonly id: string
|
||||
readonly settings: { readonly [x: string]: JsonValue }
|
||||
readonly headers: { readonly [x: string]: string }
|
||||
readonly body: { readonly [x: string]: JsonValue }
|
||||
}>
|
||||
readonly time: { readonly released: number }
|
||||
readonly cost: ReadonlyArray<{
|
||||
readonly tier?: { readonly type: "context"; readonly size: number }
|
||||
readonly input: number
|
||||
readonly output: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}>
|
||||
readonly status: "alpha" | "beta" | "deprecated" | "active"
|
||||
readonly enabled: boolean
|
||||
readonly limit: { readonly context: number; readonly input?: number; readonly output: number }
|
||||
} | null
|
||||
}
|
||||
|
||||
export type GenerateTextInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
@@ -2233,6 +2320,7 @@ export type ProviderListOutput = {
|
||||
}
|
||||
| { readonly type: "native"; readonly url?: string; readonly settings: { readonly [x: string]: JsonValue } }
|
||||
readonly request: {
|
||||
readonly settings: { readonly [x: string]: JsonValue }
|
||||
readonly headers: { readonly [x: string]: string }
|
||||
readonly body: { readonly [x: string]: JsonValue }
|
||||
}
|
||||
@@ -2266,6 +2354,7 @@ export type ProviderGetOutput = {
|
||||
}
|
||||
| { readonly type: "native"; readonly url?: string; readonly settings: { readonly [x: string]: JsonValue } }
|
||||
readonly request: {
|
||||
readonly settings: { readonly [x: string]: JsonValue }
|
||||
readonly headers: { readonly [x: string]: string }
|
||||
readonly body: { readonly [x: string]: JsonValue }
|
||||
}
|
||||
@@ -3505,6 +3594,19 @@ export type EventSubscribeOutput =
|
||||
readonly delivery: "steer" | "queue"
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.next.execution.settled"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly timestamp: number
|
||||
readonly sessionID: string
|
||||
readonly outcome: "success" | "failure" | "interrupted"
|
||||
readonly error?: { readonly type: "unknown"; readonly message: string }
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
@@ -3530,6 +3632,7 @@ export type EventSubscribeOutput =
|
||||
readonly messageID: string
|
||||
readonly text: string
|
||||
readonly description?: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
}
|
||||
}
|
||||
| {
|
||||
|
||||
@@ -85,6 +85,7 @@ const layer = Layer.effect(
|
||||
? { ...model.api, settings: { ...provider.api.settings, ...model.api.settings } }
|
||||
: model.api
|
||||
const request = {
|
||||
settings: { ...provider.request.settings, ...model.request.settings },
|
||||
headers: { ...provider.request.headers, ...model.request.headers },
|
||||
body: { ...provider.request.body, ...model.request.body },
|
||||
variant: model.request.variant,
|
||||
|
||||
@@ -4,7 +4,6 @@ import { define } from "../../plugin/internal"
|
||||
import { Effect } from "effect"
|
||||
import { Config } from "../../config"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "config-provider",
|
||||
@@ -54,6 +53,7 @@ export const Plugin = define({
|
||||
if (item.name !== undefined) provider.name = item.name
|
||||
if (item.api !== undefined) provider.api = { ...item.api }
|
||||
if (item.request !== undefined) {
|
||||
Object.assign(provider.request.settings, item.request.settings)
|
||||
Object.assign(provider.request.headers, item.request.headers)
|
||||
Object.assign(provider.request.body, item.request.body)
|
||||
}
|
||||
@@ -71,6 +71,7 @@ export const Plugin = define({
|
||||
}
|
||||
}
|
||||
if (config.request !== undefined) {
|
||||
Object.assign(model.request.settings, config.request.settings)
|
||||
Object.assign(model.request.headers, config.request.headers)
|
||||
Object.assign(model.request.body, config.request.body)
|
||||
if (config.request.variant !== undefined) model.request.variant = config.request.variant
|
||||
@@ -81,11 +82,13 @@ export const Plugin = define({
|
||||
if (!existing) {
|
||||
existing = {
|
||||
id: variant.id,
|
||||
settings: {},
|
||||
headers: {},
|
||||
body: {},
|
||||
}
|
||||
model.variants.push(existing)
|
||||
}
|
||||
Object.assign(existing.settings, variant.settings)
|
||||
Object.assign(existing.headers, variant.headers)
|
||||
Object.assign(existing.body, variant.body)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ProviderV2 } from "../provider"
|
||||
import { ModelV2 } from "../model"
|
||||
|
||||
export class Request extends Schema.Class<Request>("ConfigV2.Provider.Request")({
|
||||
settings: ProviderV2.Settings.pipe(Schema.optional),
|
||||
headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
|
||||
body: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
File to save in: ~/.local/share/opencode/worktree/012780/location-layer-tiers/packages/core/src/effect/
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as InstructionContext from "./instruction-context"
|
||||
|
||||
import { Array, Effect, Layer, Schema } from "effect"
|
||||
import { Array, Context, Effect, Layer, Schema } from "effect"
|
||||
import { isAbsolute, join, relative, sep } from "path"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { Flag } from "./flag/flag"
|
||||
@@ -8,7 +8,6 @@ import { Global } from "./global"
|
||||
import { Location } from "./location"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { SystemContext } from "./system-context/index"
|
||||
import { SystemContextRegistry } from "./system-context/registry"
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
|
||||
class File extends Schema.Class<File>("InstructionContext.File")({
|
||||
@@ -19,12 +18,18 @@ class File extends Schema.Class<File>("InstructionContext.File")({
|
||||
const Files = Schema.Array(File)
|
||||
const key = SystemContext.Key.make("core/instructions")
|
||||
|
||||
const layer = Layer.effectDiscard(
|
||||
export interface Interface {
|
||||
readonly load: () => Effect.Effect<SystemContext.SystemContext>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/InstructionContext") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
const registry = yield* SystemContextRegistry.Service
|
||||
|
||||
const source = (value: ReadonlyArray<File> | SystemContext.Unavailable) =>
|
||||
SystemContext.make({
|
||||
@@ -71,28 +76,24 @@ const layer = Layer.effectDiscard(
|
||||
return files.filter((file): file is File => file !== undefined)
|
||||
})
|
||||
|
||||
yield* registry.register({
|
||||
key,
|
||||
load: observe().pipe(
|
||||
Effect.map((files) =>
|
||||
files === SystemContext.unavailable
|
||||
? source(files)
|
||||
: files.length === 0
|
||||
? SystemContext.empty
|
||||
: source(files),
|
||||
return Service.of({
|
||||
load: () =>
|
||||
observe().pipe(
|
||||
Effect.map((files) =>
|
||||
files === SystemContext.unavailable
|
||||
? source(files)
|
||||
: files.length === 0
|
||||
? SystemContext.empty
|
||||
: source(files),
|
||||
),
|
||||
Effect.catch(() => Effect.succeed(source(SystemContext.unavailable))),
|
||||
Effect.catchDefect(() => Effect.succeed(source(SystemContext.unavailable))),
|
||||
),
|
||||
Effect.catch(() => Effect.succeed(source(SystemContext.unavailable))),
|
||||
Effect.catchDefect(() => Effect.succeed(source(SystemContext.unavailable))),
|
||||
),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
name: "instruction-context",
|
||||
layer,
|
||||
deps: [FSUtil.node, Global.node, Location.node, SystemContextRegistry.node],
|
||||
})
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Global.node, Location.node] })
|
||||
|
||||
function render(files: ReadonlyArray<File>) {
|
||||
return files.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n")
|
||||
|
||||
@@ -36,8 +36,9 @@ import { SessionTodo } from "./session/todo"
|
||||
import { SkillV2 } from "./skill"
|
||||
import { SkillGuidance } from "./skill/guidance"
|
||||
import { Snapshot } from "./snapshot"
|
||||
import { InstructionContext } from "./instruction-context"
|
||||
import { SystemContextBuiltIns } from "./system-context/builtins"
|
||||
import { SystemContextRegistry } from "./system-context/registry"
|
||||
import { SessionInstructions } from "./session/instructions"
|
||||
import { BuiltInTools } from "./tool/builtins"
|
||||
import { McpTool } from "./tool/mcp"
|
||||
import { ReadToolFileSystem } from "./tool/read-filesystem"
|
||||
@@ -67,8 +68,8 @@ export const locationServices = LayerNode.group([
|
||||
Pty.node,
|
||||
Shell.node,
|
||||
SkillV2.node,
|
||||
SystemContextRegistry.node,
|
||||
SystemContextBuiltIns.node,
|
||||
InstructionContext.node,
|
||||
LocationMutation.node,
|
||||
FileMutation.node,
|
||||
MCP.node,
|
||||
@@ -85,6 +86,7 @@ export const locationServices = LayerNode.group([
|
||||
ReadToolFileSystem.node,
|
||||
BuiltInTools.node,
|
||||
McpTool.node,
|
||||
SessionInstructions.node,
|
||||
SessionRunnerModel.node,
|
||||
SessionCompaction.node,
|
||||
SessionTitle.node,
|
||||
|
||||
@@ -14,16 +14,40 @@ const Summary = Schema.Struct({
|
||||
})
|
||||
type Summary = typeof Summary.Type
|
||||
|
||||
const entries = (servers: ReadonlyArray<Summary>) =>
|
||||
servers.flatMap((server) => [
|
||||
` <server name="${server.server}">`,
|
||||
...server.instructions.split("\n").map((line) => ` ${line}`),
|
||||
" </server>",
|
||||
])
|
||||
|
||||
const render = (servers: ReadonlyArray<Summary>) =>
|
||||
[
|
||||
"<mcp_instructions>",
|
||||
...servers.flatMap((server) => [
|
||||
` <server name="${server.server}">`,
|
||||
...server.instructions.split("\n").map((line) => ` ${line}`),
|
||||
" </server>",
|
||||
]),
|
||||
"</mcp_instructions>",
|
||||
["<mcp_instructions>", ...entries(servers), "</mcp_instructions>"].join("\n")
|
||||
|
||||
const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary>) => {
|
||||
const diff = SystemContext.diffByKey(
|
||||
previous,
|
||||
current,
|
||||
(server) => server.server,
|
||||
(before, after) => before.instructions !== after.instructions,
|
||||
)
|
||||
// Additions and removals render as small deltas; anything else restates the full list.
|
||||
if (diff.changed.length > 0 || (diff.added.length === 0 && diff.removed.length === 0))
|
||||
return [
|
||||
"The available MCP server instructions have changed. This list supersedes the previous one.",
|
||||
render(current),
|
||||
].join("\n")
|
||||
return [
|
||||
...(diff.added.length === 0
|
||||
? []
|
||||
: ["New MCP server instructions are available in addition to those previously listed:", ...entries(diff.added)]),
|
||||
...(diff.removed.length === 0
|
||||
? []
|
||||
: [
|
||||
`Instructions for the following MCP servers are no longer available: ${diff.removed.map((server) => server.server).join(", ")}.`,
|
||||
]),
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly load: (agent: AgentV2.Selection) => Effect.Effect<SystemContext.SystemContext>
|
||||
@@ -50,7 +74,8 @@ export const layer = Layer.effect(
|
||||
return (
|
||||
owned.length === 0 ||
|
||||
owned.some(
|
||||
(tool) => PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny",
|
||||
(tool) =>
|
||||
PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny",
|
||||
)
|
||||
)
|
||||
})
|
||||
@@ -61,11 +86,7 @@ export const layer = Layer.effect(
|
||||
codec: Schema.toCodecJson(Schema.Array(Summary)),
|
||||
load: Effect.succeed(visible),
|
||||
baseline: render,
|
||||
update: (_previous, current) =>
|
||||
[
|
||||
"The available MCP server instructions have changed. This list supersedes the previous one.",
|
||||
render(current),
|
||||
].join("\n"),
|
||||
update,
|
||||
removed: () => "MCP server instructions are no longer available.",
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -26,8 +26,13 @@ export type Api = Model.Api
|
||||
export const Info = Model.Info
|
||||
export type Info = Model.Info
|
||||
|
||||
export type MutableInfo = Omit<Types.DeepMutable<Info>, "api"> & {
|
||||
export type MutableRequest = ProviderV2.MutableRequest & { variant?: string }
|
||||
export type MutableVariant = ProviderV2.MutableRequest & { id: VariantID }
|
||||
|
||||
export type MutableInfo = Omit<Types.DeepMutable<Info>, "api" | "request" | "variants"> & {
|
||||
api: ProviderV2.MutableApi<Api>
|
||||
request: MutableRequest
|
||||
variants: MutableVariant[]
|
||||
}
|
||||
|
||||
export function parse(input: string): { providerID: ProviderV2.ID; modelID: ID } {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Reference } from "./reference"
|
||||
import { SkillV2 } from "./skill"
|
||||
import { State } from "./state"
|
||||
import { ToolRegistry } from "./tool/registry"
|
||||
import { ToolHooks } from "./tool/hooks"
|
||||
|
||||
export const ID = Plugin.ID
|
||||
export type ID = typeof ID.Type
|
||||
@@ -165,6 +166,7 @@ export const node = makeLocationNode({
|
||||
Reference.node,
|
||||
SkillV2.node,
|
||||
ToolRegistry.toolsNode,
|
||||
ToolHooks.node,
|
||||
PluginRuntime.node,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Reference } from "../reference"
|
||||
import { AbsolutePath, type DeepMutable } from "../schema"
|
||||
import { SkillV2 } from "../skill"
|
||||
import { Tools } from "../tool/tools"
|
||||
import { ToolHooks } from "../tool/hooks"
|
||||
import { WorkspaceV2 } from "../workspace"
|
||||
|
||||
const mutable = <T>(value: T) => value as DeepMutable<T>
|
||||
@@ -31,6 +32,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||
const reference = yield* Reference.Service
|
||||
const skill = yield* SkillV2.Service
|
||||
const tools = yield* Tools.Service
|
||||
const toolHooks = yield* ToolHooks.Service
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
const locationInfo = () =>
|
||||
new Location.Info({
|
||||
@@ -247,6 +249,47 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
||||
},
|
||||
tool: {
|
||||
register: (input) => tools.register(input),
|
||||
execute: {
|
||||
before: (callback) =>
|
||||
toolHooks.hook.before((event) => {
|
||||
const output = {
|
||||
tool: event.tool,
|
||||
sessionID: event.sessionID,
|
||||
agent: event.agent,
|
||||
assistantMessageID: event.assistantMessageID,
|
||||
toolCallID: event.toolCallID,
|
||||
input: event.input,
|
||||
}
|
||||
const result = callback(output)
|
||||
return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe(
|
||||
Effect.tap(() => Effect.sync(() => (event.input = output.input))),
|
||||
)
|
||||
}),
|
||||
after: (callback) =>
|
||||
toolHooks.hook.after((event) => {
|
||||
const output = {
|
||||
tool: event.tool,
|
||||
sessionID: event.sessionID,
|
||||
agent: event.agent,
|
||||
assistantMessageID: event.assistantMessageID,
|
||||
toolCallID: event.toolCallID,
|
||||
input: event.input,
|
||||
result: event.result,
|
||||
output: event.output,
|
||||
outputPaths: event.outputPaths,
|
||||
}
|
||||
const result = callback(output)
|
||||
return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe(
|
||||
Effect.tap(() =>
|
||||
Effect.sync(() => {
|
||||
event.result = output.result
|
||||
event.output = output.output
|
||||
event.outputPaths = output.outputPaths
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
},
|
||||
},
|
||||
session: {
|
||||
create: (input) =>
|
||||
|
||||
@@ -70,25 +70,73 @@ function mergeCost(base: ModelV2Info["cost"], override: ModelsDev.Model["cost"]
|
||||
return [merge(baseDefault ?? { input: 0, output: 0, cache: { read: 0, write: 0 } }, nextDefault), ...tiers.values()]
|
||||
}
|
||||
|
||||
function reasoningVariants(model: ModelsDev.Model, packageName: string | undefined): ModelV2Info["variants"] {
|
||||
const result = new Map<ModelV2.VariantID, ModelV2Info["variants"][number]>()
|
||||
if (packageName === "@ai-sdk/openai" || packageName === "@ai-sdk/openai-compatible") {
|
||||
const option = model.reasoning_options?.find((option) => option.type === "effort")
|
||||
for (const value of option?.values ?? []) {
|
||||
const id = value === null ? "none" : value
|
||||
if (typeof id !== "string") continue
|
||||
const variantID = ModelV2.VariantID.make(id)
|
||||
result.set(variantID, {
|
||||
id: variantID,
|
||||
headers: {},
|
||||
body:
|
||||
packageName === "@ai-sdk/openai"
|
||||
? { include: ["reasoning.encrypted_content"], reasoning: { effort: id, summary: "auto" } }
|
||||
: { reasoning_effort: id },
|
||||
})
|
||||
const OPENAI_INCLUDE_ENCRYPTED_REASONING = ["reasoning.encrypted_content"]
|
||||
|
||||
function reasoningVariants(provider: ModelsDev.Provider, model: ModelsDev.Model): ModelV2Info["variants"] {
|
||||
const npm = model.provider?.npm ?? provider.npm
|
||||
const options = model.reasoning_options ?? []
|
||||
const effort = options.find((option) => option.type === "effort")
|
||||
if (effort?.type === "effort") {
|
||||
return effort.values.flatMap((value) => {
|
||||
const raw: unknown = value
|
||||
const id = raw === null ? "none" : typeof raw === "string" ? raw : undefined
|
||||
if (id === undefined) return []
|
||||
const settings = settingsForEffort(npm, id)
|
||||
return settings ? [{ id, settings, headers: {}, body: {} }] : []
|
||||
})
|
||||
}
|
||||
|
||||
const budget = options.find((option) => option.type === "budget_tokens")
|
||||
if (budget?.type === "budget_tokens") return budgetVariants(npm, budget)
|
||||
|
||||
// Toggle-only reasoning is intentionally left for a follow-up because V1 has
|
||||
// provider/model-specific behavior like MiniMax M3 adaptive thinking and
|
||||
// Qwen/GLM enable_thinking request shapes in packages/opencode.
|
||||
return []
|
||||
}
|
||||
|
||||
function settingsForEffort(npm: string | undefined, effort: string): ProviderV2.Settings | undefined {
|
||||
if (npm === "@openrouter/ai-sdk-provider") return { reasoning: { effort } }
|
||||
if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") {
|
||||
return { thinking: { type: "adaptive", display: "summarized" }, effort }
|
||||
}
|
||||
if (npm === "@ai-sdk/google" || npm === "@ai-sdk/google-vertex") {
|
||||
return { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } }
|
||||
}
|
||||
if (npm === "@ai-sdk/azure") return { reasoningEffort: effort }
|
||||
if (npm === "@ai-sdk/openai") {
|
||||
return {
|
||||
reasoningEffort: effort,
|
||||
reasoningSummary: "auto",
|
||||
include: OPENAI_INCLUDE_ENCRYPTED_REASONING,
|
||||
}
|
||||
}
|
||||
return [...result.values()]
|
||||
if (npm === "@ai-sdk/openai-compatible") return { reasoningEffort: effort }
|
||||
}
|
||||
|
||||
function budgetVariants(
|
||||
npm: string | undefined,
|
||||
option: Extract<NonNullable<ModelsDev.Model["reasoning_options"]>[number], { type: "budget_tokens" }>,
|
||||
): ModelV2Info["variants"] {
|
||||
const max = option.max
|
||||
const high = option.max === undefined ? Math.max(option.min ?? 0, 16_000) : Math.min(Math.max(option.min ?? 0, 16_000), option.max)
|
||||
return [
|
||||
{ id: "high", budget: high },
|
||||
...(max === undefined || max === high ? [] : [{ id: "max", budget: max }]),
|
||||
].flatMap((item) => {
|
||||
const settings = settingsForBudget(npm, item.budget)
|
||||
return settings ? [{ id: item.id, settings, headers: {}, body: {} }] : []
|
||||
})
|
||||
}
|
||||
|
||||
function settingsForBudget(npm: string | undefined, budget: number): ProviderV2.Settings | undefined {
|
||||
if (npm === "@openrouter/ai-sdk-provider") return { reasoning: { max_tokens: budget } }
|
||||
if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") {
|
||||
return { thinking: { type: "enabled", budgetTokens: budget } }
|
||||
}
|
||||
if (npm === "@ai-sdk/google" || npm === "@ai-sdk/google-vertex") {
|
||||
return { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } }
|
||||
}
|
||||
}
|
||||
|
||||
function modeName(model: ModelsDev.Model, mode: string) {
|
||||
@@ -193,7 +241,7 @@ export const ModelsDevPlugin = define({
|
||||
|
||||
for (const model of Object.values(item.models)) {
|
||||
const baseCost = cost(model.cost)
|
||||
const variants = reasoningVariants(model, model.provider?.npm ?? item.npm)
|
||||
const variants = reasoningVariants(item, model)
|
||||
catalog.model.update(providerID, model.id, (draft) => applyModel(draft, model, { cost: baseCost, variants }))
|
||||
for (const [mode, options] of Object.entries(model.experimental?.modes ?? {})) {
|
||||
catalog.model.update(providerID, `${model.id}-${mode}`, (draft) =>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
export * as OpenAICodex from "./openai-codex"
|
||||
|
||||
// TEMPORARY SEAM (#34765): plugins have no hook into LLM route construction, so
|
||||
// codex routing lives in SessionRunnerModel.fromCatalogModel and catalog filtering
|
||||
// in OpenAIPlugin, sharing this module. Once the native provider packages land
|
||||
// (#33689/#33925/#34462) this should collapse into the native OpenAI provider.
|
||||
// The eligibility rules mirror V1's CodexAuthPlugin allowlist; models.dev has no
|
||||
// plan-eligibility data for OpenAI today, but models other vendors' subscriptions
|
||||
// as dedicated providers (e.g. zai-coding-plan) - a future openai-chatgpt-plan
|
||||
// provider entry could replace the hardcoded rules with catalog data.
|
||||
|
||||
/** ChatGPT-plan requests must target the codex backend instead of the public API. */
|
||||
export const baseURL = "https://chatgpt.com/backend-api/codex"
|
||||
|
||||
const methodIDs: readonly string[] = ["chatgpt-browser", "chatgpt-headless"]
|
||||
|
||||
/** Structural credential shape so both core and plugin-facing credential types fit. */
|
||||
type CredentialLike = {
|
||||
readonly type: string
|
||||
readonly methodID?: string
|
||||
readonly metadata?: Record<string, unknown> | undefined
|
||||
}
|
||||
|
||||
export const isChatGPT = (credential: CredentialLike | undefined) =>
|
||||
credential?.type === "oauth" && credential.methodID !== undefined && methodIDs.includes(credential.methodID)
|
||||
|
||||
export const accountID = (credential: CredentialLike | undefined) => {
|
||||
if (!isChatGPT(credential)) return undefined
|
||||
const value = credential?.metadata?.accountID
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
|
||||
const allowed = new Set(["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini"])
|
||||
const disallowed = new Set(["gpt-5.5-pro"])
|
||||
|
||||
/** Which API model ids a ChatGPT subscription may call through the codex backend. */
|
||||
export const eligible = (apiID: string) => {
|
||||
if (allowed.has(apiID)) return true
|
||||
if (disallowed.has(apiID)) return false
|
||||
const match = apiID.match(/^gpt-(\d+\.\d+)/)
|
||||
return match ? Number.parseFloat(match[1]) > 5.4 : false
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
import { createServer } from "node:http"
|
||||
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Deferred, Effect } from "effect"
|
||||
import { Deferred, Effect, Semaphore, Stream } from "effect"
|
||||
import type { Scope } from "effect"
|
||||
import { Credential } from "../../credential"
|
||||
import { EventV2 } from "../../event"
|
||||
import { InstallationVersion } from "../../installation/version"
|
||||
import { Integration } from "../../integration"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { OauthCallbackPage } from "../../oauth/page"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
import type { PluginInternal } from "../internal"
|
||||
import { OpenAICodex } from "./openai-codex"
|
||||
|
||||
const clientID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||
const issuer = "https://auth.openai.com"
|
||||
@@ -154,6 +156,18 @@ const headless = {
|
||||
export const OpenAIPlugin = define({
|
||||
id: "openai",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const events = yield* EventV2.Service
|
||||
const loading = Semaphore.makeUnsafe(1)
|
||||
let chatgpt = false
|
||||
|
||||
const load = Effect.fn("OpenAIPlugin.load")(function* () {
|
||||
const connection = yield* ctx.integration.connection.active("openai")
|
||||
const credential = connection
|
||||
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
: undefined
|
||||
chatgpt = OpenAICodex.isChatGPT(credential)
|
||||
})
|
||||
|
||||
yield* ctx.integration.transform((draft) => {
|
||||
draft.method.update(browser)
|
||||
draft.method.update(headless)
|
||||
@@ -170,8 +184,30 @@ export const OpenAIPlugin = define({
|
||||
model.enabled = false
|
||||
})
|
||||
}
|
||||
if (!chatgpt) return
|
||||
const item = evt.provider.get(ProviderV2.ID.openai)
|
||||
if (!item) return
|
||||
for (const model of item.models.values()) {
|
||||
// ChatGPT-plan tokens only authorize codex-eligible models, and the
|
||||
// subscription covers usage, so hide the rest and zero the cost.
|
||||
evt.model.update(item.provider.id, model.id, (draft) => {
|
||||
if (!OpenAICodex.eligible(draft.api.id)) {
|
||||
draft.enabled = false
|
||||
return
|
||||
}
|
||||
draft.cost = []
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
|
||||
yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
||||
Stream.filter((event) => event.data.integrationID === Integration.ID.make("openai")),
|
||||
Stream.runForEach(refresh),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* refresh().pipe(Effect.forkScoped)
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/openai") return
|
||||
|
||||
@@ -146,7 +146,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | S
|
||||
const variantID = ModelV2.VariantID.make(id)
|
||||
let existing = model.variants.find((item) => item.id === variantID)
|
||||
if (!existing) {
|
||||
existing = { id: variantID, headers: {}, body: {} }
|
||||
existing = { id: variantID, settings: {}, headers: {}, body: {} }
|
||||
model.variants.push(existing)
|
||||
}
|
||||
Object.assign(existing.headers, options.headers)
|
||||
|
||||
@@ -110,8 +110,13 @@ export const providerLayer = providerLayerWithCell(defaultCell)
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
|
||||
|
||||
export const providerNode = makeGlobalNode({
|
||||
name: "plugin-runtime-provider",
|
||||
layer: providerLayer,
|
||||
deps: [node, SessionV2.node, Job.node, LocationServiceMap.node],
|
||||
})
|
||||
// Raw layer replacements are compiled without dependencies, so cell-scoped
|
||||
// provider replacements must go through this node to keep their deps wired.
|
||||
export const providerNodeWithCell = (cell: Cell) =>
|
||||
makeGlobalNode({
|
||||
name: "plugin-runtime-provider",
|
||||
layer: providerLayerWithCell(cell),
|
||||
deps: [node, SessionV2.node, Job.node, LocationServiceMap.node],
|
||||
})
|
||||
|
||||
export const providerNode = providerNodeWithCell(defaultCell)
|
||||
|
||||
@@ -33,7 +33,8 @@ export function generate(model: ModelV2Info): ModelV2Info["variants"] {
|
||||
if (!["glm-5.2", "glm-5-2", "glm-5p2"].some((name) => ids.includes(name))) return []
|
||||
return ["high", "max"].map((id) => ({
|
||||
id,
|
||||
settings: { reasoningEffort: id },
|
||||
headers: {},
|
||||
body: { reasoning_effort: id },
|
||||
body: {},
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -19,7 +19,15 @@ export type MutableApi<T extends Api = Api> = T extends Api
|
||||
export const Request = Provider.Request
|
||||
export type Request = Provider.Request
|
||||
|
||||
export const Settings = Provider.Settings
|
||||
export type Settings = Provider.Settings
|
||||
|
||||
export const Info = Provider.Info
|
||||
export type Info = Provider.Info
|
||||
|
||||
export type MutableInfo = Omit<Types.DeepMutable<Info>, "api"> & { api: MutableApi }
|
||||
export type MutableRequest = Types.DeepMutable<Request>
|
||||
|
||||
export type MutableInfo = Omit<Types.DeepMutable<Info>, "api" | "request"> & {
|
||||
api: MutableApi
|
||||
request: MutableRequest
|
||||
}
|
||||
|
||||
@@ -11,20 +11,48 @@ const Summary = Schema.Struct({
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
const entries = (references: ReadonlyArray<typeof Summary.Type>) =>
|
||||
references.flatMap((reference) => [
|
||||
" <reference>",
|
||||
` <name>${reference.name}</name>`,
|
||||
` <path>${reference.path}</path>`,
|
||||
...(reference.description === undefined ? [] : [` <description>${reference.description}</description>`]),
|
||||
" </reference>",
|
||||
])
|
||||
|
||||
const render = (references: ReadonlyArray<typeof Summary.Type>) =>
|
||||
[
|
||||
"Project references provide additional directories that can be accessed when relevant.",
|
||||
"<available_references>",
|
||||
...references.flatMap((reference) => [
|
||||
" <reference>",
|
||||
` <name>${reference.name}</name>`,
|
||||
` <path>${reference.path}</path>`,
|
||||
...(reference.description === undefined ? [] : [` <description>${reference.description}</description>`]),
|
||||
" </reference>",
|
||||
]),
|
||||
...entries(references),
|
||||
"</available_references>",
|
||||
].join("\n")
|
||||
|
||||
const update = (previous: ReadonlyArray<typeof Summary.Type>, current: ReadonlyArray<typeof Summary.Type>) => {
|
||||
const diff = SystemContext.diffByKey(
|
||||
previous,
|
||||
current,
|
||||
(reference) => reference.name,
|
||||
(before, after) => before.path !== after.path || before.description !== after.description,
|
||||
)
|
||||
// Additions and removals render as small deltas; anything else restates the full list.
|
||||
if (diff.changed.length > 0 || (diff.added.length === 0 && diff.removed.length === 0))
|
||||
return [
|
||||
"The available project references have changed. This list supersedes the previous reference list.",
|
||||
render(current),
|
||||
].join("\n")
|
||||
return [
|
||||
...(diff.added.length === 0
|
||||
? []
|
||||
: ["New project references are available in addition to those previously listed:", ...entries(diff.added)]),
|
||||
...(diff.removed.length === 0
|
||||
? []
|
||||
: [
|
||||
`The following project references are no longer available and must not be used: ${diff.removed.map((reference) => reference.name).join(", ")}.`,
|
||||
]),
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly load: () => Effect.Effect<SystemContext.SystemContext>
|
||||
}
|
||||
@@ -52,11 +80,7 @@ const layer = Layer.effect(
|
||||
codec: Schema.toCodecJson(Schema.Array(Summary)),
|
||||
load: Effect.succeed(available),
|
||||
baseline: render,
|
||||
update: (_previous, current) =>
|
||||
[
|
||||
"The available project references have changed. This list supersedes the previous reference list.",
|
||||
render(current),
|
||||
].join("\n"),
|
||||
update,
|
||||
removed: () => "Project reference guidance is no longer available. Do not use previously listed references.",
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -108,7 +108,7 @@ export class OperationUnavailableError extends Schema.TaggedErrorClass<Operation
|
||||
},
|
||||
) {}
|
||||
|
||||
export { ContextSnapshotDecodeError, MessageDecodeError } from "./session/error"
|
||||
export { MessageDecodeError } from "./session/error"
|
||||
|
||||
export class PromptConflictError extends Schema.TaggedErrorClass<PromptConflictError>()("Session.PromptConflictError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
@@ -199,6 +199,7 @@ export interface Interface {
|
||||
sessionID: SessionSchema.ID
|
||||
text: string
|
||||
description?: string
|
||||
metadata?: Record<string, unknown>
|
||||
}) => Effect.Effect<void, NotFoundError>
|
||||
readonly revert: {
|
||||
readonly stage: (input: {
|
||||
@@ -465,7 +466,9 @@ const layer = Layer.effect(
|
||||
text: skill.content,
|
||||
})
|
||||
if (input.resume !== false)
|
||||
yield* execution.resume(input.sessionID).pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
|
||||
yield* execution
|
||||
.resume(input.sessionID)
|
||||
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
|
||||
}),
|
||||
switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
@@ -547,8 +550,11 @@ const layer = Layer.effect(
|
||||
timestamp: yield* DateTime.now,
|
||||
text: input.text,
|
||||
description: input.description,
|
||||
metadata: input.metadata,
|
||||
})
|
||||
yield* execution.resume(input.sessionID).pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
|
||||
yield* execution
|
||||
.resume(input.sessionID)
|
||||
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
|
||||
}),
|
||||
interrupt: Effect.fn("V2Session.interrupt")((sessionID) =>
|
||||
Effect.uninterruptible(execution.interrupt(sessionID)),
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
export * as SessionContextCheckpoint from "./context-checkpoint"
|
||||
|
||||
import { eq } from "drizzle-orm"
|
||||
import { DateTime, Effect, Option, Schema } from "effect"
|
||||
import type { Database } from "../database/database"
|
||||
import { EventV2 } from "../event"
|
||||
import { SystemContext } from "../system-context/index"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionHistory } from "./history"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionContextCheckpointTable } from "./sql"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
|
||||
const decodeApplied = Schema.decodeUnknownOption(SystemContext.Applied)
|
||||
|
||||
/**
|
||||
* Loads or creates the session's durable context checkpoint, narrating any
|
||||
* drift since the model was last told as a chronological update. Completed
|
||||
* compaction rebaselines; nothing else rewrites the baseline. Runs before
|
||||
* input promotion so a blocked first turn leaves pending inputs untouched.
|
||||
*/
|
||||
export const prepare = Effect.fn("SessionContextCheckpoint.prepare")(function* (
|
||||
db: DatabaseService,
|
||||
events: EventV2.Interface,
|
||||
context: Effect.Effect<SystemContext.SystemContext>,
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
const [value, stored, compaction] = yield* Effect.all(
|
||||
[context, find(db, sessionID), SessionHistory.latestCompaction(db, sessionID)],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
if (!stored) {
|
||||
const baseline = yield* SystemContext.initialize(value)
|
||||
const baselineSeq = yield* insert(db, sessionID, baseline)
|
||||
return { baseline: baseline.text, baselineSeq }
|
||||
}
|
||||
|
||||
// The applied record is comparison state only; an undecodable one heals by
|
||||
// treating every source as new, re-announcing baselines as updates.
|
||||
const applied = Option.getOrElse(decodeApplied(stored.snapshot), () => ({}))
|
||||
if (compaction !== undefined && compaction.seq > stored.baseline_seq) {
|
||||
const baseline = yield* SystemContext.rebaseline(value, applied)
|
||||
yield* rewrite(db, sessionID, compaction.seq, baseline)
|
||||
return { baseline: baseline.text, baselineSeq: compaction.seq }
|
||||
}
|
||||
const result = yield* SystemContext.reconcile(value, applied)
|
||||
if (result._tag === "Unchanged") return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
|
||||
|
||||
yield* events.publish(
|
||||
SessionEvent.ContextUpdated,
|
||||
{ sessionID, messageID: SessionMessage.ID.create(), timestamp: yield* DateTime.now, text: result.text },
|
||||
{ commit: () => advance(db, sessionID, result.applied).pipe(Effect.orDie) },
|
||||
)
|
||||
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
|
||||
})
|
||||
|
||||
export const reset = Effect.fn("SessionContextCheckpoint.reset")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
yield* db
|
||||
.delete(SessionContextCheckpointTable)
|
||||
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const find = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
return yield* db
|
||||
.select()
|
||||
.from(SessionContextCheckpointTable)
|
||||
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const insert = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
baseline: SystemContext.Baseline,
|
||||
) {
|
||||
const baselineSeq = yield* EventV2.latestSequence(db, sessionID)
|
||||
yield* db
|
||||
.insert(SessionContextCheckpointTable)
|
||||
.values({
|
||||
session_id: sessionID,
|
||||
baseline: baseline.text,
|
||||
snapshot: baseline.applied,
|
||||
baseline_seq: baselineSeq,
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
return baselineSeq
|
||||
})
|
||||
|
||||
const rewrite = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
baselineSeq: number,
|
||||
baseline: SystemContext.Baseline,
|
||||
) {
|
||||
const updated = yield* db
|
||||
.update(SessionContextCheckpointTable)
|
||||
.set({
|
||||
baseline: baseline.text,
|
||||
snapshot: baseline.applied,
|
||||
baseline_seq: baselineSeq,
|
||||
})
|
||||
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
|
||||
.returning({ sessionID: SessionContextCheckpointTable.session_id })
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!updated) return yield* Effect.die("Context checkpoint not found")
|
||||
})
|
||||
|
||||
const advance = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
applied: SystemContext.Applied,
|
||||
) {
|
||||
const updated = yield* db
|
||||
.update(SessionContextCheckpointTable)
|
||||
.set({ snapshot: applied })
|
||||
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
|
||||
.returning({ sessionID: SessionContextCheckpointTable.session_id })
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!updated) return yield* Effect.die("Context checkpoint not found")
|
||||
})
|
||||
@@ -1,174 +0,0 @@
|
||||
export * as SessionContextEpoch from "./context-epoch"
|
||||
|
||||
import { eq } from "drizzle-orm"
|
||||
import { DateTime, Effect, Schema } from "effect"
|
||||
import type { Database } from "../database/database"
|
||||
import { EventV2 } from "../event"
|
||||
import { SystemContext } from "../system-context/index"
|
||||
import { ContextSnapshotDecodeError } from "./error"
|
||||
import { SessionEvent } from "./event"
|
||||
import { SessionHistory } from "./history"
|
||||
import { SessionInput } from "./input"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionContextEpochTable } from "./sql"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
|
||||
interface Prepared {
|
||||
readonly baseline: string
|
||||
readonly baselineSeq: number
|
||||
}
|
||||
|
||||
export function initialize(
|
||||
db: DatabaseService,
|
||||
context: Effect.Effect<SystemContext.SystemContext>,
|
||||
sessionID: SessionSchema.ID,
|
||||
): Effect.Effect<Prepared | undefined, SystemContext.InitializationBlocked> {
|
||||
return initializeOnce(db, context, sessionID).pipe(Effect.withSpan("SessionContextEpoch.initialize"))
|
||||
}
|
||||
|
||||
export function prepare(
|
||||
db: DatabaseService,
|
||||
events: EventV2.Interface,
|
||||
context: Effect.Effect<SystemContext.SystemContext>,
|
||||
sessionID: SessionSchema.ID,
|
||||
): Effect.Effect<Prepared, SystemContext.InitializationBlocked | ContextSnapshotDecodeError> {
|
||||
return prepareOnce(db, events, context, sessionID).pipe(Effect.withSpan("SessionContextEpoch.prepare"))
|
||||
}
|
||||
|
||||
const prepareOnce = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
events: EventV2.Interface,
|
||||
context: Effect.Effect<SystemContext.SystemContext>,
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
const [value, stored, compaction] = yield* Effect.all(
|
||||
[context, find(db, sessionID), SessionHistory.latestCompaction(db, sessionID)],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
if (!stored) {
|
||||
const generation = yield* SystemContext.initialize(value)
|
||||
const baselineSeq = yield* insert(db, sessionID, generation)
|
||||
return { baseline: generation.baseline, baselineSeq }
|
||||
}
|
||||
|
||||
const snapshot = yield* Schema.decodeUnknownEffect(SystemContext.Snapshot)(stored.snapshot).pipe(
|
||||
Effect.mapError((error) => new ContextSnapshotDecodeError({ sessionID, details: String(error) })),
|
||||
)
|
||||
const replacementSeq = compaction !== undefined && compaction.seq > stored.baseline_seq ? compaction.seq : undefined
|
||||
const result = replacementSeq
|
||||
? yield* SystemContext.replace(value, snapshot)
|
||||
: yield* SystemContext.reconcile(value, snapshot)
|
||||
if (result._tag === "Unchanged" || result._tag === "ReplacementBlocked") {
|
||||
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
|
||||
}
|
||||
if (result._tag === "ReplacementReady") {
|
||||
const baselineSeq = replacementSeq ?? (yield* EventV2.latestSequence(db, sessionID))
|
||||
yield* replace(db, sessionID, baselineSeq, result.generation)
|
||||
return { baseline: result.generation.baseline, baselineSeq }
|
||||
}
|
||||
|
||||
yield* events.publish(
|
||||
SessionEvent.ContextUpdated,
|
||||
{ sessionID, messageID: SessionMessage.ID.create(), timestamp: yield* DateTime.now, text: result.text },
|
||||
{ commit: () => advance(db, sessionID, result.snapshot).pipe(Effect.orDie) },
|
||||
)
|
||||
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
|
||||
})
|
||||
|
||||
const initializeOnce = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
context: Effect.Effect<SystemContext.SystemContext>,
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
if (yield* exists(db, sessionID)) return
|
||||
const generation = yield* context.pipe(Effect.flatMap(SystemContext.initialize))
|
||||
const baselineSeq = yield* insert(db, sessionID, generation)
|
||||
return { baseline: generation.baseline, baselineSeq }
|
||||
})
|
||||
|
||||
const exists = Effect.fn("SessionContextEpoch.exists")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
return (
|
||||
(yield* db
|
||||
.select({ sessionID: SessionContextEpochTable.session_id })
|
||||
.from(SessionContextEpochTable)
|
||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)) !== undefined
|
||||
)
|
||||
})
|
||||
|
||||
const find = Effect.fn("SessionContextEpoch.find")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
return yield* db
|
||||
.select()
|
||||
.from(SessionContextEpochTable)
|
||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
export const reset = Effect.fn("SessionContextEpoch.reset")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
yield* db
|
||||
.delete(SessionContextEpochTable)
|
||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const insert = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
generation: SystemContext.Generation,
|
||||
) {
|
||||
const baselineSeq = yield* EventV2.latestSequence(db, sessionID)
|
||||
yield* db
|
||||
.insert(SessionContextEpochTable)
|
||||
.values({
|
||||
session_id: sessionID,
|
||||
baseline: generation.baseline,
|
||||
snapshot: generation.snapshot,
|
||||
baseline_seq: baselineSeq,
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
return baselineSeq
|
||||
})
|
||||
|
||||
const replace = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
baselineSeq: number,
|
||||
generation: SystemContext.Generation,
|
||||
) {
|
||||
const updated = yield* db
|
||||
.update(SessionContextEpochTable)
|
||||
.set({
|
||||
baseline: generation.baseline,
|
||||
snapshot: generation.snapshot,
|
||||
baseline_seq: baselineSeq,
|
||||
})
|
||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||
.returning({ sessionID: SessionContextEpochTable.session_id })
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!updated) return yield* Effect.die("Context Epoch not found")
|
||||
})
|
||||
|
||||
const advance = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
snapshot: SystemContext.Snapshot,
|
||||
) {
|
||||
const updated = yield* db
|
||||
.update(SessionContextEpochTable)
|
||||
.set({ snapshot })
|
||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||
.returning({ sessionID: SessionContextEpochTable.session_id })
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!updated) return yield* Effect.die("Context Epoch not found")
|
||||
})
|
||||
@@ -10,15 +10,3 @@ export class MessageDecodeError extends Schema.TaggedErrorClass<MessageDecodeErr
|
||||
return `Failed to decode message ${this.messageID} in session ${this.sessionID}`
|
||||
}
|
||||
}
|
||||
|
||||
export class ContextSnapshotDecodeError extends Schema.TaggedErrorClass<ContextSnapshotDecodeError>()(
|
||||
"Session.ContextSnapshotDecodeError",
|
||||
{
|
||||
sessionID: SessionSchema.ID,
|
||||
details: Schema.String,
|
||||
},
|
||||
) {
|
||||
override get message() {
|
||||
return `Failed to decode context snapshot for session ${this.sessionID}: ${this.details}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Database } from "../database/database"
|
||||
import { MessageDecodeError } from "./error"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionContextEpochTable, SessionMessageTable } from "./sql"
|
||||
import { SessionContextCheckpointTable, SessionMessageTable } from "./sql"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
|
||||
@@ -33,6 +33,9 @@ const messageRows = Effect.fnUntraced(function* (
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, sessionID),
|
||||
// Keep system updates visible in the gap between a completed compaction
|
||||
// and the next prepared turn's rebaseline, when their content is not yet
|
||||
// folded into a new baseline.
|
||||
compaction
|
||||
? or(
|
||||
gte(SessionMessageTable.seq, compaction.seq),
|
||||
@@ -67,9 +70,9 @@ export const load = Effect.fn("SessionHistory.load")(function* (db: DatabaseServ
|
||||
const [epoch, compaction] = yield* Effect.all(
|
||||
[
|
||||
db
|
||||
.select({ baselineSeq: SessionContextEpochTable.baseline_seq })
|
||||
.from(SessionContextEpochTable)
|
||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||
.select({ baselineSeq: SessionContextCheckpointTable.baseline_seq })
|
||||
.from(SessionContextCheckpointTable)
|
||||
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie),
|
||||
latestCompaction(db, sessionID),
|
||||
@@ -79,14 +82,6 @@ export const load = Effect.fn("SessionHistory.load")(function* (db: DatabaseServ
|
||||
return yield* Effect.forEach(yield* messageRows(db, sessionID, compaction, epoch?.baselineSeq), decodeMessageRow)
|
||||
})
|
||||
|
||||
export const loadForRunner = Effect.fn("SessionHistory.loadForRunner")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
baselineSeq: number,
|
||||
) {
|
||||
return (yield* entriesForRunner(db, sessionID, baselineSeq)).map((entry) => entry.message)
|
||||
})
|
||||
|
||||
export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SessionInput from "./input"
|
||||
|
||||
import { and, asc, eq, isNull, lte } from "drizzle-orm"
|
||||
import { and, asc, eq, isNull } from "drizzle-orm"
|
||||
import { DateTime, Effect, Schema } from "effect"
|
||||
import { Admitted, Delivery } from "@opencode-ai/schema/session-input"
|
||||
import type { Database } from "../database/database"
|
||||
@@ -145,26 +145,11 @@ export const projectPrompted = Effect.fn("SessionInput.projectPrompted")(functio
|
||||
return
|
||||
}
|
||||
|
||||
// Every Prompted event is published from an admitted inbox row, so a missing or
|
||||
// divergent row on replay is an invariant violation.
|
||||
const stored = yield* find(db, input.id)
|
||||
if (stored) {
|
||||
if (!matchesProjection(stored, input) || stored.promotedSeq !== input.promotedSeq)
|
||||
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||
return
|
||||
}
|
||||
|
||||
yield* db
|
||||
.insert(SessionInputTable)
|
||||
.values({
|
||||
id: input.id,
|
||||
session_id: input.sessionID,
|
||||
prompt: encodePrompt(input.prompt),
|
||||
delivery: input.delivery,
|
||||
admitted_seq: input.promotedSeq,
|
||||
promoted_seq: input.promotedSeq,
|
||||
time_created: DateTime.toEpochMillis(input.timeCreated),
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
if (!stored || !matchesProjection(stored, input) || stored.promotedSeq !== input.promotedSeq)
|
||||
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||
})
|
||||
|
||||
export const hasPending = Effect.fn("SessionInput.hasPending")(function* (
|
||||
@@ -195,9 +180,8 @@ export const equivalent = (
|
||||
readonly prompt: Prompt
|
||||
readonly delivery: Delivery
|
||||
},
|
||||
) => input.delivery === expected.delivery && matchesPrompt(input, expected)
|
||||
|
||||
const matchesPrompt = (input: Admitted, expected: { readonly sessionID: SessionSchema.ID; readonly prompt: Prompt }) =>
|
||||
) =>
|
||||
input.delivery === expected.delivery &&
|
||||
input.sessionID === expected.sessionID &&
|
||||
JSON.stringify(encodePrompt(input.prompt)) === JSON.stringify(encodePrompt(expected.prompt))
|
||||
|
||||
@@ -246,7 +230,6 @@ export const promoteSteers = Effect.fn("SessionInput.promoteSteers")(function* (
|
||||
db: DatabaseService,
|
||||
events: EventV2.Interface,
|
||||
sessionID: SessionSchema.ID,
|
||||
cutoff: number,
|
||||
) {
|
||||
const rows = yield* db
|
||||
.select()
|
||||
@@ -256,7 +239,6 @@ export const promoteSteers = Effect.fn("SessionInput.promoteSteers")(function* (
|
||||
eq(SessionInputTable.session_id, sessionID),
|
||||
isNull(SessionInputTable.promoted_seq),
|
||||
eq(SessionInputTable.delivery, "steer"),
|
||||
lte(SessionInputTable.admitted_seq, cutoff),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(SessionInputTable.admitted_seq))
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
export * as SessionInstructions from "./instructions"
|
||||
|
||||
import { relative } from "path"
|
||||
import { Context, DateTime, Effect, Layer, Option, Ref, Schema } from "effect"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { EventV2 } from "../event"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { Location } from "../location"
|
||||
import { SessionEvent } from "./event"
|
||||
import { MessageDecodeError } from "./error"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionStore } from "./store"
|
||||
|
||||
const InjectedMetadata = Schema.Struct({
|
||||
instruction: Schema.Struct({ paths: Schema.Array(Schema.String) }),
|
||||
})
|
||||
|
||||
export interface Interface {
|
||||
readonly load: (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly paths: ReadonlyArray<string>
|
||||
}) => Effect.Effect<void, MessageDecodeError | FSUtil.Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionInstructions") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const location = yield* Location.Service
|
||||
// Resolved once for the Location layer; the synthetic text and dedup ledger keep
|
||||
// absolute paths, but the human-facing description shows paths relative to the project
|
||||
// root so opening a subdirectory still describes paths from the project root.
|
||||
const root = FSUtil.resolve(location.project.directory)
|
||||
// Same-turn parallel reads settle concurrently, so an in-memory claim guards each
|
||||
// Session/path pair before any filesystem work. The durable history check below covers
|
||||
// paths injected in earlier turns after this Location layer was reopened.
|
||||
const injected = yield* Ref.make<Map<SessionSchema.ID, Set<string>>>(new Map())
|
||||
|
||||
const load = Effect.fn("SessionInstructions.load")(function* (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly paths: ReadonlyArray<string>
|
||||
}) {
|
||||
const claimed = yield* Ref.modify(injected, (map) => {
|
||||
const existing = map.get(input.sessionID) ?? new Set<string>()
|
||||
const newlyClaimed = input.paths.filter((path) => !existing.has(path))
|
||||
if (newlyClaimed.length === 0) return [newlyClaimed, map]
|
||||
const next = new Map(map)
|
||||
next.set(input.sessionID, new Set([...existing, ...newlyClaimed]))
|
||||
return [newlyClaimed, next]
|
||||
})
|
||||
if (claimed.length === 0) return
|
||||
const alreadyInjected = yield* previouslyInjected(store, input.sessionID)
|
||||
const toInject = claimed.filter((path) => !alreadyInjected.has(path))
|
||||
if (toInject.length === 0) return
|
||||
const files = yield* Effect.forEach(
|
||||
toInject,
|
||||
(path) =>
|
||||
fs.readFileStringSafe(path).pipe(
|
||||
Effect.map((content) => (content === undefined ? undefined : { path, content })),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
const readable = files.filter((file): file is { path: string; content: string } => file !== undefined)
|
||||
if (readable.length === 0) return
|
||||
// Publish directly rather than through SessionV2.synthetic: a Location-scoped layer
|
||||
// cannot depend on SessionV2 (it routes through LocationServiceMap, forming a type
|
||||
// cycle with this node). The durable publish is what makes the synthetic visible on
|
||||
// the next projected history reload. The dedup ledger lives on the synthetic message
|
||||
// metadata so it survives across Location layer restarts.
|
||||
yield* events.publish(SessionEvent.Synthetic, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: yield* DateTime.now,
|
||||
text: readable.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n"),
|
||||
description: `Loaded ${readable.map((file) => describePath(root, file.path)).join(", ")}`,
|
||||
metadata: { instruction: { paths: readable.map((file) => file.path) } },
|
||||
})
|
||||
})
|
||||
|
||||
return Service.of({ load })
|
||||
}),
|
||||
)
|
||||
|
||||
function previouslyInjected(store: SessionStore.Interface, sessionID: SessionSchema.ID) {
|
||||
return Effect.gen(function* () {
|
||||
const history = yield* store.context(sessionID)
|
||||
return new Set(
|
||||
history
|
||||
.filter((message): message is SessionMessage.Synthetic => message.type === "synthetic")
|
||||
.flatMap(
|
||||
(message) =>
|
||||
Option.getOrUndefined(Schema.decodeUnknownOption(InjectedMetadata)(message.metadata))?.instruction.paths ??
|
||||
[],
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Paths are normally discovered under the project root, so the description shows them
|
||||
// relative to it. A directly-loaded path outside the root falls back to its absolute form
|
||||
// rather than emitting `../..` chains.
|
||||
function describePath(root: string, path: string) {
|
||||
return FSUtil.contains(root, path) ? relative(root, path) : path
|
||||
}
|
||||
|
||||
export const node = makeLocationNode({
|
||||
name: "session-instructions",
|
||||
layer,
|
||||
deps: [EventV2.node, FSUtil.node, Location.node, SessionStore.node],
|
||||
})
|
||||
@@ -139,6 +139,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
)
|
||||
},
|
||||
"session.next.prompt.admitted": () => Effect.void,
|
||||
"session.next.execution.settled": () => Effect.void,
|
||||
"session.next.context.updated": (event) =>
|
||||
adapter.appendMessage(
|
||||
SessionMessage.System.make({
|
||||
@@ -154,6 +155,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
||||
sessionID: event.data.sessionID,
|
||||
text: event.data.text,
|
||||
description: event.data.description,
|
||||
metadata: event.data.metadata,
|
||||
id: event.data.messageID,
|
||||
type: "synthetic",
|
||||
time: { created: event.data.timestamp },
|
||||
|
||||
@@ -12,8 +12,15 @@ import { SessionMessage } from "./message"
|
||||
import { SessionMessageUpdater } from "./message-updater"
|
||||
import { SessionInput } from "./input"
|
||||
import { WorkspaceV2 } from "../workspace"
|
||||
import { SessionContextEpoch } from "./context-epoch"
|
||||
import { MessageTable, PartTable, SessionInputTable, SessionMessageTable, SessionTable } from "./sql"
|
||||
import { SessionContextCheckpoint } from "./context-checkpoint"
|
||||
import {
|
||||
MessageTable,
|
||||
PartTable,
|
||||
SessionContextCheckpointTable,
|
||||
SessionInputTable,
|
||||
SessionMessageTable,
|
||||
SessionTable,
|
||||
} from "./sql"
|
||||
import type { DeepMutable } from "../schema"
|
||||
import { Slug } from "../util/slug"
|
||||
|
||||
@@ -156,12 +163,16 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(eq(SessionMessageTable.session_id, event.data.parentID), eq(SessionMessageTable.id, event.data.messageID)),
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, event.data.parentID),
|
||||
eq(SessionMessageTable.id, event.data.messageID),
|
||||
),
|
||||
)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
: undefined
|
||||
if (event.data.messageID && !boundary) return yield* Effect.die(`Fork boundary message not found: ${event.data.messageID}`)
|
||||
if (event.data.messageID && !boundary)
|
||||
return yield* Effect.die(`Fork boundary message not found: ${event.data.messageID}`)
|
||||
const copied = yield* db
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
@@ -206,6 +217,23 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
||||
.pipe(Effect.orDie)
|
||||
if (!stored) return yield* Effect.die(new SessionAlreadyProjected())
|
||||
|
||||
// The fork inherits the parent's transcript, so it inherits the context
|
||||
// checkpoint that transcript was built against: copied message seqs keep
|
||||
// folding at the same baseline horizon.
|
||||
const checkpoint = yield* db
|
||||
.select()
|
||||
.from(SessionContextCheckpointTable)
|
||||
.where(eq(SessionContextCheckpointTable.session_id, event.data.parentID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (checkpoint) {
|
||||
yield* db
|
||||
.insert(SessionContextCheckpointTable)
|
||||
.values({ ...checkpoint, session_id: event.data.sessionID })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
const usage = emptyUsage()
|
||||
let cursor = -1
|
||||
while (true) {
|
||||
@@ -452,7 +480,7 @@ const layer = Layer.effectDiscard(
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* SessionContextEpoch.reset(db, event.data.sessionID)
|
||||
yield* SessionContextCheckpoint.reset(db, event.data.sessionID)
|
||||
}),
|
||||
)
|
||||
yield* events.project(SessionV1.Event.Deleted, (event) =>
|
||||
@@ -666,7 +694,7 @@ const layer = Layer.effectDiscard(
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* SessionContextEpoch.reset(db, event.data.sessionID)
|
||||
yield* SessionContextCheckpoint.reset(db, event.data.sessionID)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -6,19 +6,26 @@ import { Deferred, Effect, Exit, Fiber, FiberSet, Scope } from "effect"
|
||||
export interface Coordinator<Key, E> {
|
||||
/** Snapshots keys with an execution owned by this coordinator. */
|
||||
readonly active: Effect.Effect<ReadonlySet<Key>>
|
||||
/** Starts execution while idle or joins the active execution. */
|
||||
/** Starts an execution while idle or joins the active execution. */
|
||||
readonly run: (key: Key) => Effect.Effect<void, E>
|
||||
/** Registers one coalesced follow-up after newly recorded work. */
|
||||
readonly wake: (key: Key) => Effect.Effect<void>
|
||||
/** Stops active execution and waits for its cleanup. */
|
||||
/** Stops the active execution and waits for its cleanup. */
|
||||
readonly interrupt: (key: Key) => Effect.Effect<void>
|
||||
/** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */
|
||||
readonly awaitIdle: (key: Key) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
type Entry<E> = {
|
||||
/**
|
||||
* One execution is a busy period for one key: one fiber that drains from the first wake
|
||||
* until the key would stay idle. `pendingWake` is the doorbell: work recorded during the
|
||||
* execution rings it, and the execution loop drains again instead of ending. The doorbell
|
||||
* closes the gap between a drain's last eligibility check and the idle transition, since
|
||||
* those cannot be one atomic step. `done` resolves joiners with this execution's exit.
|
||||
*/
|
||||
type Execution<E> = {
|
||||
readonly done: Deferred.Deferred<void, E>
|
||||
owner?: Fiber.Fiber<void, never>
|
||||
owner?: Fiber.Fiber<void>
|
||||
pendingWake: boolean
|
||||
stopping: boolean
|
||||
}
|
||||
@@ -27,90 +34,83 @@ export const make = <Key, E>(options: {
|
||||
readonly drain: (key: Key, force: boolean) => Effect.Effect<void, E>
|
||||
}): Effect.Effect<Coordinator<Key, E>, never, Scope.Scope> =>
|
||||
Effect.gen(function* () {
|
||||
const active = new Map<Key, Entry<E>>()
|
||||
const executions = new Map<Key, Execution<E>>()
|
||||
const fork = yield* FiberSet.makeRuntime<never, void, never>()
|
||||
|
||||
const makeEntry = (): Entry<E> => ({
|
||||
done: Deferred.makeUnsafe<void, E>(),
|
||||
pendingWake: false,
|
||||
stopping: false,
|
||||
})
|
||||
const loop = (key: Key, execution: Execution<E>, force: boolean): Effect.Effect<void, E> =>
|
||||
Effect.suspend(() => options.drain(key, force)).pipe(
|
||||
Effect.flatMap(() =>
|
||||
Effect.suspend(() => {
|
||||
if (execution.stopping || !execution.pendingWake) return Effect.void
|
||||
execution.pendingWake = false
|
||||
// Trampoline so drains that complete synchronously cannot grow the stack.
|
||||
return Effect.yieldNow.pipe(Effect.andThen(loop(key, execution, false)))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const start = (key: Key, entry: Entry<E>, force: boolean, successor = false) => {
|
||||
const ready = Deferred.makeUnsafe<void>()
|
||||
const owner = fork(
|
||||
(successor ? Effect.yieldNow : Deferred.await(ready)).pipe(
|
||||
Effect.andThen(Effect.suspend(() => options.drain(key, force))),
|
||||
Effect.onExit((exit) => Effect.sync(() => settle(key, entry, exit))),
|
||||
const start = (key: Key, force: boolean) => {
|
||||
const execution: Execution<E> = { done: Deferred.makeUnsafe<void, E>(), pendingWake: false, stopping: false }
|
||||
executions.set(key, execution)
|
||||
// The leading yield lets `owner` be assigned before the drain can settle, and keeps
|
||||
// failing self-waking executions from growing the stack across successor starts.
|
||||
// Drains start one tick after wake; callers observe progress through events or run.
|
||||
execution.owner = fork(
|
||||
Effect.yieldNow.pipe(
|
||||
Effect.andThen(loop(key, execution, force)),
|
||||
Effect.onExit((exit) => Effect.sync(() => settle(key, execution, exit))),
|
||||
Effect.exit,
|
||||
Effect.asVoid,
|
||||
),
|
||||
)
|
||||
entry.owner = owner
|
||||
if (!successor) Deferred.doneUnsafe(ready, Effect.void)
|
||||
return execution
|
||||
}
|
||||
|
||||
const settle = (key: Key, entry: Entry<E>, exit: Exit.Exit<void, E>) => {
|
||||
if (Exit.isSuccess(exit) && !entry.stopping && entry.pendingWake) {
|
||||
entry.pendingWake = false
|
||||
start(key, entry, false, true)
|
||||
return
|
||||
}
|
||||
|
||||
const successor = entry.pendingWake ? makeEntry() : undefined
|
||||
if (successor === undefined) active.delete(key)
|
||||
else {
|
||||
active.set(key, successor)
|
||||
start(key, successor, false, true)
|
||||
}
|
||||
Deferred.doneUnsafe(entry.done, exit)
|
||||
// A doorbell that survives the execution loop (rung after the loop decided to end, or
|
||||
// during failure or interruption cleanup) starts a fresh execution for the remaining work.
|
||||
const settle = (key: Key, execution: Execution<E>, exit: Exit.Exit<void, E>) => {
|
||||
if (execution.pendingWake) start(key, false)
|
||||
else executions.delete(key)
|
||||
Deferred.doneUnsafe(execution.done, exit)
|
||||
}
|
||||
|
||||
const run = (key: Key): Effect.Effect<void, E> =>
|
||||
Effect.uninterruptibleMask((restore) => {
|
||||
const entry = active.get(key)
|
||||
if (entry !== undefined) {
|
||||
if (entry.stopping) return restore(Deferred.await(entry.done).pipe(Effect.andThen(run(key))))
|
||||
return restore(Deferred.await(entry.done))
|
||||
const execution = executions.get(key)
|
||||
if (execution !== undefined) {
|
||||
if (execution.stopping) return restore(Deferred.await(execution.done).pipe(Effect.andThen(run(key))))
|
||||
return restore(Deferred.await(execution.done))
|
||||
}
|
||||
|
||||
const next = makeEntry()
|
||||
active.set(key, next)
|
||||
start(key, next, true)
|
||||
return restore(Deferred.await(next.done))
|
||||
return restore(Deferred.await(start(key, true).done))
|
||||
})
|
||||
|
||||
const wake = (key: Key) =>
|
||||
Effect.sync(() => {
|
||||
const entry = active.get(key)
|
||||
if (entry !== undefined) {
|
||||
entry.pendingWake = true
|
||||
const execution = executions.get(key)
|
||||
if (execution !== undefined) {
|
||||
execution.pendingWake = true
|
||||
return
|
||||
}
|
||||
|
||||
const next = makeEntry()
|
||||
active.set(key, next)
|
||||
start(key, next, false)
|
||||
start(key, false)
|
||||
})
|
||||
|
||||
const interrupt = (key: Key): Effect.Effect<void> =>
|
||||
Effect.suspend(() => {
|
||||
const entry = active.get(key)
|
||||
if (entry?.owner === undefined) return Effect.void
|
||||
entry.stopping = true
|
||||
entry.pendingWake = false
|
||||
return Fiber.interrupt(entry.owner)
|
||||
const execution = executions.get(key)
|
||||
if (execution?.owner === undefined) return Effect.void
|
||||
execution.stopping = true
|
||||
execution.pendingWake = false
|
||||
return Fiber.interrupt(execution.owner)
|
||||
})
|
||||
|
||||
// Each successful drain reuses its entry.done across coalesced wakes, so one await
|
||||
// already spans steered and queued continuation. Re-check after it settles to cover a
|
||||
// fresh wake (or a failure/stopping successor) that installs a new entry.
|
||||
// One execution's `done` already spans coalesced continuations; re-check after it
|
||||
// settles to cover a successor execution started by a late doorbell.
|
||||
const awaitIdle = (key: Key): Effect.Effect<void> =>
|
||||
Effect.suspend(() => {
|
||||
const entry = active.get(key)
|
||||
if (entry === undefined) return Effect.void
|
||||
return Deferred.await(entry.done).pipe(Effect.exit, Effect.andThen(awaitIdle(key)))
|
||||
const execution = executions.get(key)
|
||||
if (execution === undefined) return Effect.void
|
||||
return Deferred.await(execution.done).pipe(Effect.exit, Effect.andThen(awaitIdle(key)))
|
||||
})
|
||||
|
||||
return { active: Effect.sync(() => new Set(active.keys())), run, wake, interrupt, awaitIdle }
|
||||
return { active: Effect.sync(() => new Set(executions.keys())), run, wake, interrupt, awaitIdle }
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ export * as SessionRunner from "./index"
|
||||
import type { LLMError } from "@opencode-ai/llm"
|
||||
import { Context, Effect } from "effect"
|
||||
import { SessionSchema } from "../schema"
|
||||
import type { ContextSnapshotDecodeError, MessageDecodeError } from "../error"
|
||||
import type { MessageDecodeError } from "../error"
|
||||
import { SessionRunnerModel } from "./model"
|
||||
import type { SystemContext } from "../../system-context/index"
|
||||
import type { ToolOutputStore } from "../../tool-output-store"
|
||||
@@ -12,7 +12,6 @@ export type RunError =
|
||||
| LLMError
|
||||
| SessionRunnerModel.Error
|
||||
| MessageDecodeError
|
||||
| ContextSnapshotDecodeError
|
||||
| SystemContext.InitializationBlocked
|
||||
| ToolOutputStore.Error
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
isContextOverflowFailure,
|
||||
type ProviderErrorEvent,
|
||||
} from "@opencode-ai/llm"
|
||||
import { Cause, DateTime, Effect, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
|
||||
import { Cause, DateTime, Effect, Exit, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
|
||||
import { AgentV2 } from "../../agent"
|
||||
import { Config } from "../../config"
|
||||
import { Database } from "../../database/database"
|
||||
@@ -18,13 +18,14 @@ import { ModelV2 } from "../../model"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
import { QuestionV2 } from "../../question"
|
||||
import { SystemContext } from "../../system-context/index"
|
||||
import { SystemContextRegistry } from "../../system-context/registry"
|
||||
import { SystemContextBuiltIns } from "../../system-context/builtins"
|
||||
import { InstructionContext } from "../../instruction-context"
|
||||
import { SkillGuidance } from "../../skill/guidance"
|
||||
import { ReferenceGuidance } from "../../reference/guidance"
|
||||
import { McpGuidance } from "../../mcp/guidance"
|
||||
import { ToolRegistry } from "../../tool/registry"
|
||||
import { ToolOutputStore } from "../../tool-output-store"
|
||||
import { SessionContextEpoch } from "../context-epoch"
|
||||
import { SessionContextCheckpoint } from "../context-checkpoint"
|
||||
import { SessionCompaction } from "../compaction"
|
||||
import { SessionEvent } from "../event"
|
||||
import { SessionHistory } from "../history"
|
||||
@@ -32,7 +33,7 @@ import { SessionInput } from "../input"
|
||||
import { SessionSchema } from "../schema"
|
||||
import { SessionStore } from "../store"
|
||||
import { SessionTitle } from "../title"
|
||||
import { type RunError, Service } from "./index"
|
||||
import { Service } from "./index"
|
||||
import { SessionRunnerModel } from "./model"
|
||||
import { createLLMEventPublisher } from "./publish-llm-event"
|
||||
import { toLLMMessages } from "./to-llm-message"
|
||||
@@ -102,7 +103,8 @@ const layer = Layer.effect(
|
||||
const models = yield* SessionRunnerModel.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const location = yield* Location.Service
|
||||
const systemContext = yield* SystemContextRegistry.Service
|
||||
const builtins = yield* SystemContextBuiltIns.Service
|
||||
const instructions = yield* InstructionContext.Service
|
||||
const skillGuidance = yield* SkillGuidance.Service
|
||||
const referenceGuidance = yield* ReferenceGuidance.Service
|
||||
const mcpGuidance = yield* McpGuidance.Service
|
||||
@@ -153,26 +155,17 @@ const layer = Layer.effect(
|
||||
const isQuestionRejected = (cause: Cause.Cause<unknown>) =>
|
||||
cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof QuestionV2.RejectedError)
|
||||
|
||||
type TurnTransition =
|
||||
// Automatic compaction completed; rebuild the request from compacted history.
|
||||
| { readonly _tag: "ContinueAfterCompaction"; readonly step: number }
|
||||
// Overflow compaction completed; rebuild once through the path without overflow recovery.
|
||||
| { readonly _tag: "ContinueAfterOverflowCompaction"; readonly step: number }
|
||||
|
||||
class TurnTransitionError extends Error {
|
||||
constructor(readonly transition: TurnTransition) {
|
||||
super()
|
||||
}
|
||||
}
|
||||
|
||||
const continueAfterCompaction = (step: number) => new TurnTransitionError({ _tag: "ContinueAfterCompaction", step })
|
||||
const continueAfterOverflowCompaction = (step: number) =>
|
||||
new TurnTransitionError({ _tag: "ContinueAfterOverflowCompaction", step })
|
||||
|
||||
const loadSystemContext = (agent: AgentV2.Selection) =>
|
||||
Effect.all([systemContext.load(), skillGuidance.load(agent), referenceGuidance.load(), mcpGuidance.load(agent)], {
|
||||
concurrency: "unbounded",
|
||||
}).pipe(Effect.map(SystemContext.combine))
|
||||
Effect.all(
|
||||
[
|
||||
builtins.load(),
|
||||
instructions.load(),
|
||||
skillGuidance.load(agent),
|
||||
referenceGuidance.load(),
|
||||
mcpGuidance.load(agent),
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(Effect.map(SystemContext.combine))
|
||||
|
||||
const runTurnAttempt = Effect.fn("SessionRunner.runTurn")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
@@ -184,24 +177,23 @@ const layer = Layer.effect(
|
||||
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
|
||||
return yield* Effect.interrupt
|
||||
const agent = yield* agents.select(session.agent)
|
||||
const initialized = yield* SessionContextEpoch.initialize(db, loadSystemContext(agent), session.id)
|
||||
// Establish what the model knows before admitting what the user said, so
|
||||
// a blocked first turn leaves pending inputs untouched.
|
||||
const checkpoint = yield* SessionContextCheckpoint.prepare(db, events, loadSystemContext(agent), session.id)
|
||||
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
|
||||
let needsContinuation = false
|
||||
let currentStep = step
|
||||
if (promotion) {
|
||||
const cutoff = yield* EventV2.latestSequence(db, session.id)
|
||||
let promoted = 0
|
||||
if (promotion === "steer") promoted = yield* SessionInput.promoteSteers(db, events, session.id, cutoff)
|
||||
if (promotion === "steer") promoted = yield* SessionInput.promoteSteers(db, events, session.id)
|
||||
if (promotion === "queue") {
|
||||
promoted += Number(yield* SessionInput.promoteNextQueued(db, events, session.id))
|
||||
promoted += yield* SessionInput.promoteSteers(db, events, session.id, cutoff)
|
||||
promoted += yield* SessionInput.promoteSteers(db, events, session.id)
|
||||
}
|
||||
if (promoted > 0) currentStep = 1
|
||||
}
|
||||
const system =
|
||||
initialized ?? (yield* SessionContextEpoch.prepare(db, events, loadSystemContext(agent), session.id))
|
||||
const model = yield* models.resolve(session)
|
||||
const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq)
|
||||
const entries = yield* SessionHistory.entriesForRunner(db, session.id, checkpoint.baselineSeq)
|
||||
const context = entries.map((entry) => entry.message)
|
||||
const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps
|
||||
const toolMaterialization = isLastStep
|
||||
@@ -211,15 +203,19 @@ const layer = Layer.effect(
|
||||
const request = LLM.request({
|
||||
model,
|
||||
providerOptions: { openai: { promptCacheKey } },
|
||||
system: [agent.info?.system ? agent.info.system : SessionRunnerSystemPrompt.provider(model), system.baseline]
|
||||
system: [
|
||||
agent.info?.system ? agent.info.system : SessionRunnerSystemPrompt.provider(model),
|
||||
checkpoint.baseline,
|
||||
]
|
||||
.filter((part): part is string => part !== undefined && part.length > 0)
|
||||
.map(SystemPart.make),
|
||||
messages: [...toLLMMessages(context, model), ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : [])],
|
||||
tools: toolMaterialization?.definitions ?? [],
|
||||
toolChoice: isLastStep ? "none" : undefined,
|
||||
})
|
||||
// Automatic compaction completed; rebuild the request from compacted history.
|
||||
if (yield* compaction.compactIfNeeded({ sessionID: session.id, messages: context, request }))
|
||||
return yield* Effect.die(continueAfterCompaction(currentStep))
|
||||
return { _tag: "RestartAfterCompaction", step: currentStep } as const
|
||||
const startSnapshot = yield* snapshots.capture()
|
||||
const publisher = createLLMEventPublisher(events, {
|
||||
sessionID: session.id,
|
||||
@@ -284,44 +280,71 @@ const layer = Layer.effect(
|
||||
|
||||
return yield* Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
// Gather the evidence: how did the provider stream end?
|
||||
const stream = yield* restore(providerStream).pipe(Effect.exit)
|
||||
const failure =
|
||||
stream._tag === "Failure" ? Option.getOrUndefined(Cause.findErrorOption(stream.cause)) : undefined
|
||||
const streamFailure = Option.getOrUndefined(Exit.findErrorOption(stream))
|
||||
// Note: Exit.hasInterrupts is a type guard whose false branch unsoundly narrows
|
||||
// away non-interrupt failures, so both interrupt checks stay Cause-based.
|
||||
const streamInterrupted = stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)
|
||||
|
||||
// A context overflow before any assistant output is recoverable: compact and
|
||||
// restart the turn instead of surfacing the provider error.
|
||||
if (
|
||||
recoverOverflow &&
|
||||
!publisher.hasAssistantStarted() &&
|
||||
isContextOverflowFailure(overflowFailure ?? failure) &&
|
||||
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
|
||||
(yield* restore(recoverOverflow({ sessionID: session.id, messages: context, request })))
|
||||
)
|
||||
return yield* Effect.die(continueAfterOverflowCompaction(currentStep))
|
||||
return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const
|
||||
|
||||
// An unrecovered held-back overflow becomes the turn's durable provider error. A
|
||||
// thrown LLM failure fails hosted tool calls and the assistant unless a provider
|
||||
// error was already recorded from the stream.
|
||||
if (overflowFailure) yield* publish(overflowFailure)
|
||||
const llmFailure = failure instanceof LLMError ? failure : undefined
|
||||
const llmFailure = streamFailure instanceof LLMError ? streamFailure : undefined
|
||||
if (llmFailure && !publisher.hasProviderError()) {
|
||||
yield* publication.withPermit(publisher.failUnsettledTools("Provider did not return a tool result", true))
|
||||
yield* publication.withPermit(publisher.failAssistant(llmFailure.reason.message))
|
||||
}
|
||||
if (stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) yield* FiberSet.clear(toolFibers)
|
||||
// Provider error events only arrive from the stream, so the flag is final here.
|
||||
const providerFailed = publisher.hasProviderError()
|
||||
|
||||
// Settle tool fibers: an interrupted stream abandons unstarted tool work first.
|
||||
if (streamInterrupted) yield* FiberSet.clear(toolFibers)
|
||||
const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit)
|
||||
const streamInterrupted = stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)
|
||||
const toolsInterrupted = settled._tag === "Failure" && Cause.hasInterrupts(settled.cause)
|
||||
if (settled._tag === "Failure" && isQuestionRejected(settled.cause)) {
|
||||
const questionDismissed = settled._tag === "Failure" && isQuestionRejected(settled.cause)
|
||||
|
||||
if (questionDismissed || streamInterrupted || toolsInterrupted) {
|
||||
yield* FiberSet.clear(toolFibers)
|
||||
yield* publication.withPermit(publisher.failUnsettledTools("Tool execution interrupted"))
|
||||
yield* publication.withPermit(publisher.failAssistant("Provider turn interrupted"))
|
||||
return yield* Effect.interrupt
|
||||
// Match V1: dismissing a question halts the loop like an interruption.
|
||||
if (questionDismissed) return yield* Effect.interrupt
|
||||
}
|
||||
if (streamInterrupted || toolsInterrupted) {
|
||||
yield* FiberSet.clear(toolFibers)
|
||||
yield* publication.withPermit(publisher.failUnsettledTools("Tool execution interrupted"))
|
||||
yield* publication.withPermit(publisher.failAssistant("Provider turn interrupted"))
|
||||
}
|
||||
if (settled._tag === "Failure" && !Cause.hasInterrupts(settled.cause)) {
|
||||
const failure = Cause.squash(settled.cause)
|
||||
// A settled tool fiber failure is one of two things. A defect from a tool
|
||||
// implementation becomes a failed tool call the model can read, and the turn still
|
||||
// settles so the model may recover. A typed infrastructure failure (tool output
|
||||
// could not be persisted) also fails the assistant and then fails the drain.
|
||||
const settledFailure = settled._tag === "Failure" && !toolsInterrupted ? settled.cause : undefined
|
||||
const infraError =
|
||||
settledFailure === undefined ? undefined : Option.getOrUndefined(Cause.findErrorOption(settledFailure))
|
||||
if (settledFailure !== undefined) {
|
||||
const failure = infraError ?? Cause.squash(settledFailure)
|
||||
const message = failure instanceof Error ? failure.message : String(failure)
|
||||
yield* publication.withPermit(publisher.failUnsettledTools(`Tool execution failed: ${message}`))
|
||||
if (infraError !== undefined)
|
||||
yield* publication.withPermit(publisher.failAssistant(`Tool execution failed: ${message}`))
|
||||
}
|
||||
|
||||
const stepSettlement = publisher.stepSettlement()
|
||||
if (stepSettlement && !streamInterrupted && !toolsInterrupted && !publisher.hasProviderError()) {
|
||||
if (
|
||||
stepSettlement &&
|
||||
!streamInterrupted &&
|
||||
!toolsInterrupted &&
|
||||
infraError === undefined &&
|
||||
!providerFailed
|
||||
) {
|
||||
const endSnapshot = yield* snapshots.capture()
|
||||
const files =
|
||||
startSnapshot && endSnapshot
|
||||
@@ -342,52 +365,46 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (publisher.hasProviderError())
|
||||
yield* publication.withPermit(publisher.failUnsettledTools("Tool execution interrupted"))
|
||||
if (stream._tag === "Success" && !publisher.hasProviderError())
|
||||
// A provider error orphans recorded local calls; a clean stream can still leave
|
||||
// hosted calls without results.
|
||||
if (providerFailed) yield* publication.withPermit(publisher.failUnsettledTools("Tool execution interrupted"))
|
||||
if (stream._tag === "Success" && !providerFailed)
|
||||
yield* publication.withPermit(publisher.failUnsettledTools("Provider did not return a tool result", true))
|
||||
|
||||
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
|
||||
if (settled._tag === "Failure" && Cause.hasInterrupts(settled.cause))
|
||||
if (settled._tag === "Failure" && (toolsInterrupted || infraError !== undefined))
|
||||
return yield* Effect.failCause(settled.cause)
|
||||
return { needsContinuation: !publisher.hasProviderError() && needsContinuation, step: currentStep }
|
||||
return {
|
||||
_tag: "Completed",
|
||||
needsContinuation: !providerFailed && needsContinuation,
|
||||
step: currentStep,
|
||||
} as const
|
||||
}),
|
||||
)
|
||||
}, Effect.scoped)
|
||||
type RunTurn = (
|
||||
|
||||
const runTurn = Effect.fnUntraced(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
promotion: SessionInput.Delivery | undefined,
|
||||
step: number,
|
||||
) => Effect.Effect<{ readonly needsContinuation: boolean; readonly step: number }, RunError>
|
||||
|
||||
const runAfterOverflowCompaction: RunTurn = Effect.fnUntraced(function* (sessionID, promotion, step) {
|
||||
return yield* runTurnAttempt(sessionID, promotion, step).pipe(
|
||||
Effect.catchDefect(
|
||||
Effect.fnUntraced(function* (defect) {
|
||||
if (!(defect instanceof TurnTransitionError)) return yield* Effect.die(defect)
|
||||
if (defect.transition._tag === "ContinueAfterOverflowCompaction")
|
||||
return yield* Effect.die("Post-compaction provider attempt cannot recover another overflow")
|
||||
yield* Effect.yieldNow
|
||||
return yield* runAfterOverflowCompaction(sessionID, undefined, defect.transition.step)
|
||||
}),
|
||||
),
|
||||
)
|
||||
) {
|
||||
// Compaction restarts rebuild the request from compacted history without re-promoting.
|
||||
// Overflow recovery is one-shot: a post-compaction attempt must not recover another
|
||||
// overflow, so the recovery hook is dropped after it fires.
|
||||
let recoverOverflow: typeof compaction.compactAfterOverflow | undefined = compaction.compactAfterOverflow
|
||||
let currentPromotion = promotion
|
||||
let currentStep = step
|
||||
while (true) {
|
||||
const attempt = yield* runTurnAttempt(sessionID, currentPromotion, currentStep, recoverOverflow)
|
||||
if (attempt._tag === "Completed") return { needsContinuation: attempt.needsContinuation, step: attempt.step }
|
||||
if (attempt._tag === "RestartAfterOverflowCompaction") recoverOverflow = undefined
|
||||
yield* Effect.yieldNow
|
||||
currentPromotion = undefined
|
||||
currentStep = attempt.step
|
||||
}
|
||||
})
|
||||
|
||||
const runTurn: RunTurn = Effect.fnUntraced(function* (sessionID, promotion, step) {
|
||||
return yield* runTurnAttempt(sessionID, promotion, step, compaction.compactAfterOverflow).pipe(
|
||||
Effect.catchDefect(
|
||||
Effect.fnUntraced(function* (defect) {
|
||||
if (!(defect instanceof TurnTransitionError)) return yield* Effect.die(defect)
|
||||
yield* Effect.yieldNow
|
||||
if (defect.transition._tag === "ContinueAfterOverflowCompaction")
|
||||
return yield* runAfterOverflowCompaction(sessionID, undefined, defect.transition.step)
|
||||
return yield* runTurn(sessionID, undefined, defect.transition.step)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const run = Effect.fn("SessionRunner.run")(function* (input: {
|
||||
const drain = Effect.fnUntraced(function* (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly force: boolean
|
||||
}) {
|
||||
@@ -418,6 +435,30 @@ const layer = Layer.effect(
|
||||
}
|
||||
})
|
||||
|
||||
const run = Effect.fn("SessionRunner.run")(
|
||||
(input: { readonly sessionID: SessionSchema.ID; readonly force: boolean }) =>
|
||||
drain(input).pipe(
|
||||
Effect.onExit((exit) =>
|
||||
Effect.gen(function* () {
|
||||
const failure =
|
||||
Exit.isFailure(exit) && !Cause.hasInterrupts(exit.cause) ? Cause.squash(exit.cause) : undefined
|
||||
yield* events.publish(SessionEvent.ExecutionSettled, {
|
||||
sessionID: input.sessionID,
|
||||
timestamp: yield* DateTime.now,
|
||||
outcome: Exit.isSuccess(exit) ? "success" : Cause.hasInterrupts(exit.cause) ? "interrupted" : "failure",
|
||||
error:
|
||||
failure !== undefined
|
||||
? { type: "unknown", message: failure instanceof Error ? failure.message : String(failure) }
|
||||
: undefined,
|
||||
})
|
||||
}).pipe(
|
||||
Effect.catchCause(() => Effect.void),
|
||||
Effect.asVoid,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
run,
|
||||
})
|
||||
@@ -435,7 +476,8 @@ export const node = makeLocationNode({
|
||||
SessionRunnerModel.node,
|
||||
SessionStore.node,
|
||||
Location.node,
|
||||
SystemContextRegistry.node,
|
||||
SystemContextBuiltIns.node,
|
||||
InstructionContext.node,
|
||||
SkillGuidance.node,
|
||||
ReferenceGuidance.node,
|
||||
McpGuidance.node,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Catalog } from "../../catalog"
|
||||
import { Credential } from "../../credential"
|
||||
import { Integration } from "../../integration"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { OpenAICodex } from "../../plugin/provider/openai-codex"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
import { SessionSchema } from "../schema"
|
||||
|
||||
@@ -96,11 +97,22 @@ const withDefaults = (model: ModelV2.Info, route: AnyRoute) => {
|
||||
provider: model.providerID,
|
||||
endpoint: model.api.url === undefined ? undefined : { baseURL: model.api.url },
|
||||
headers: model.request.headers,
|
||||
providerOptions: providerOptions(model),
|
||||
http: { body: httpBody },
|
||||
limits: { context: model.limit.context, output: model.limit.output },
|
||||
})
|
||||
}
|
||||
|
||||
const providerOptions = (
|
||||
model: ModelV2.Info,
|
||||
): { readonly [key: string]: { readonly [key: string]: unknown } } | undefined => {
|
||||
if (Object.keys(model.request.settings).length === 0) return undefined
|
||||
if (model.api.type !== "aisdk") return undefined
|
||||
if (model.api.package === "@ai-sdk/openai") return { openai: model.request.settings }
|
||||
if (model.api.package === "@ai-sdk/anthropic") return { anthropic: model.request.settings }
|
||||
if (model.api.package === "@ai-sdk/openai-compatible") return { openai: model.request.settings }
|
||||
}
|
||||
|
||||
export const withVariant = (
|
||||
model: ModelV2.Info,
|
||||
variantID: ModelV2.VariantID | undefined,
|
||||
@@ -118,6 +130,7 @@ export const withVariant = (
|
||||
return Effect.succeed(
|
||||
variant
|
||||
? produce(model, (draft) => {
|
||||
Object.assign(draft.request.settings, variant.settings)
|
||||
Object.assign(draft.request.headers, variant.headers)
|
||||
Object.assign(draft.request.body, variant.body)
|
||||
})
|
||||
@@ -140,6 +153,21 @@ export const fromCatalogModel = (
|
||||
})
|
||||
const key = apiKey(resolved, credential)
|
||||
if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai") {
|
||||
// ChatGPT-plan OAuth tokens are not API-key credentials: the public API rejects
|
||||
// them, so requests must target the codex backend with the account header.
|
||||
if (OpenAICodex.isChatGPT(credential)) {
|
||||
const account = OpenAICodex.accountID(credential)
|
||||
return Effect.succeed(
|
||||
withDefaults(resolved, OpenAIResponses.route)
|
||||
.with({
|
||||
endpoint: { baseURL: OpenAICodex.baseURL },
|
||||
auth: (key === undefined ? Auth.none : Auth.bearer(key)).andThen(
|
||||
account === undefined ? Auth.none : Auth.headers({ "chatgpt-account-id": account }),
|
||||
),
|
||||
})
|
||||
.model({ id: resolved.api.id }),
|
||||
)
|
||||
}
|
||||
return Effect.succeed(
|
||||
withDefaults(resolved, OpenAIResponses.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
||||
|
||||
@@ -130,7 +130,7 @@ function toLLMMessage(message: SessionMessage.Message, model: Model): Message[]
|
||||
}),
|
||||
]
|
||||
case "synthetic":
|
||||
return [Message.make({ id: message.id, role: "user", content: message.text, metadata: message.metadata })]
|
||||
return [Message.make({ id: message.id, role: "user", content: message.text })]
|
||||
case "skill":
|
||||
return [Message.make({ id: message.id, role: "user", content: message.text, metadata: message.metadata })]
|
||||
case "system":
|
||||
|
||||
@@ -165,12 +165,12 @@ export const SessionInputTable = sqliteTable(
|
||||
],
|
||||
)
|
||||
|
||||
export const SessionContextEpochTable = sqliteTable("session_context_epoch", {
|
||||
export const SessionContextCheckpointTable = sqliteTable("session_context_epoch", {
|
||||
session_id: text()
|
||||
.$type<SessionSchema.ID>()
|
||||
.primaryKey()
|
||||
.references(() => SessionTable.id, { onDelete: "cascade" }),
|
||||
baseline: text().notNull(),
|
||||
snapshot: text({ mode: "json" }).notNull().$type<SystemContext.Snapshot>(),
|
||||
snapshot: text({ mode: "json" }).notNull().$type<SystemContext.Applied>(),
|
||||
baseline_seq: integer().notNull(),
|
||||
})
|
||||
|
||||
@@ -14,10 +14,6 @@ import { fromRow } from "./info"
|
||||
export interface Interface {
|
||||
readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info | undefined>
|
||||
readonly context: (sessionID: SessionSchema.ID) => Effect.Effect<SessionMessage.Message[], MessageDecodeError>
|
||||
readonly runnerContext: (
|
||||
sessionID: SessionSchema.ID,
|
||||
baselineSeq: number,
|
||||
) => Effect.Effect<SessionMessage.Message[], MessageDecodeError>
|
||||
readonly message: (
|
||||
messageID: SessionMessage.ID,
|
||||
) => Effect.Effect<{ readonly sessionID: SessionSchema.ID; readonly message: SessionMessage.Message } | undefined>
|
||||
@@ -39,9 +35,6 @@ const layer = Layer.effect(
|
||||
context: Effect.fn("SessionStore.context")(function* (sessionID) {
|
||||
return yield* SessionHistory.load(db, sessionID)
|
||||
}),
|
||||
runnerContext: Effect.fn("SessionStore.runnerContext")(function* (sessionID, baselineSeq) {
|
||||
return yield* SessionHistory.loadForRunner(db, sessionID, baselineSeq)
|
||||
}),
|
||||
message: Effect.fn("SessionStore.message")(function* (messageID) {
|
||||
const row = yield* db
|
||||
.select()
|
||||
|
||||
@@ -13,23 +13,47 @@ const Summary = Schema.Struct({
|
||||
})
|
||||
type Summary = typeof Summary.Type
|
||||
|
||||
const entries = (skills: ReadonlyArray<Summary>) =>
|
||||
skills.flatMap((skill) => [
|
||||
" <skill>",
|
||||
` <name>${skill.name}</name>`,
|
||||
` <description>${skill.description}</description>`,
|
||||
" </skill>",
|
||||
])
|
||||
|
||||
const render = (skills: ReadonlyArray<Summary>) =>
|
||||
[
|
||||
"Skills provide specialized instructions and workflows for specific tasks.",
|
||||
"Use the skill tool to load a skill when a task matches its description.",
|
||||
...(skills.length === 0
|
||||
? ["No skills are currently available."]
|
||||
: ["<available_skills>", ...entries(skills), "</available_skills>"]),
|
||||
].join("\n")
|
||||
|
||||
const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary>) => {
|
||||
const diff = SystemContext.diffByKey(
|
||||
previous,
|
||||
current,
|
||||
(skill) => skill.name,
|
||||
(before, after) => before.description !== after.description,
|
||||
)
|
||||
// Additions and removals render as small deltas; anything else restates the full list.
|
||||
if (diff.changed.length > 0 || (diff.added.length === 0 && diff.removed.length === 0))
|
||||
return [
|
||||
"The available skills have changed. This list supersedes the previous available skills list.",
|
||||
render(current),
|
||||
].join("\n")
|
||||
return [
|
||||
...(diff.added.length === 0
|
||||
? []
|
||||
: ["New skills are available in addition to those previously listed:", ...entries(diff.added)]),
|
||||
...(diff.removed.length === 0
|
||||
? []
|
||||
: [
|
||||
"<available_skills>",
|
||||
...skills.flatMap((skill) => [
|
||||
" <skill>",
|
||||
` <name>${skill.name}</name>`,
|
||||
` <description>${skill.description}</description>`,
|
||||
" </skill>",
|
||||
]),
|
||||
"</available_skills>",
|
||||
`The following skills are no longer available and must not be used: ${diff.removed.map((skill) => skill.name).join(", ")}.`,
|
||||
]),
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly load: (agent: AgentV2.Selection) => Effect.Effect<SystemContext.SystemContext>
|
||||
@@ -61,11 +85,7 @@ const layer = Layer.effect(
|
||||
codec: Schema.toCodecJson(Schema.Array(Summary)),
|
||||
load: Effect.succeed(available),
|
||||
baseline: render,
|
||||
update: (_previous, current) =>
|
||||
[
|
||||
"The available skills have changed. This list supersedes the previous available skills list.",
|
||||
render(current),
|
||||
].join("\n"),
|
||||
update,
|
||||
removed: () => "Skill guidance is no longer available. Do not use any previously listed skill.",
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
export * as SystemContextBuiltIns from "./builtins"
|
||||
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { DateTime, Effect, Layer, Schema } from "effect"
|
||||
import { Context, DateTime, Effect, Layer, Schema } from "effect"
|
||||
import { Location } from "../location"
|
||||
import { SystemContext } from "./index"
|
||||
import { InstructionContext } from "../instruction-context"
|
||||
import { SystemContextRegistry } from "./registry"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { Global } from "../global"
|
||||
|
||||
const builtIns = Layer.effectDiscard(
|
||||
export interface Interface {
|
||||
readonly load: () => Effect.Effect<SystemContext.SystemContext>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SystemContextBuiltIns") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const registry = yield* SystemContextRegistry.Service
|
||||
const environment = [
|
||||
"<env>",
|
||||
` Working directory: ${location.directory}`,
|
||||
@@ -39,12 +41,8 @@ const builtIns = Layer.effectDiscard(
|
||||
}),
|
||||
])
|
||||
|
||||
yield* registry.register({ key: SystemContext.Key.make("core/builtins"), load: Effect.succeed(context) })
|
||||
return Service.of({ load: () => Effect.succeed(context) })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
name: "system-context-builtins",
|
||||
layer: builtIns,
|
||||
deps: [Location.node, SystemContextRegistry.node, InstructionContext.node, FSUtil.node, Global.node],
|
||||
})
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Location.node] })
|
||||
|
||||
@@ -7,13 +7,18 @@ import { Effect, Option, Schema } from "effect"
|
||||
*
|
||||
* `Source<A>` describes how to observe, compare, and render one value. `make`
|
||||
* closes over `A`, producing an opaque `SystemContext` that composes uniformly
|
||||
* with contexts built from other value types. Interpreters observe the composed
|
||||
* context once, then produce a durable structured
|
||||
* `Snapshot` alongside the exact model-visible baseline or update text.
|
||||
* with contexts built from other value types.
|
||||
*
|
||||
* The durable `Applied` record tracks what the model was last told, per source:
|
||||
* it is the model's current belief. Interpreters uphold one invariant —
|
||||
* `reconcile` never rewrites the baseline; it only narrates drift as update
|
||||
* text. Only `rebaseline` (compaction) and `initialize` (first turn) produce
|
||||
* baseline text.
|
||||
*
|
||||
* Returning `unavailable` means observation failed temporarily. It differs from
|
||||
* removing a source from the context: refresh preserves the admitted snapshot,
|
||||
* and replacement waits rather than silently constructing an incomplete baseline.
|
||||
* removing a source from the context: the model's prior belief stands.
|
||||
* `reconcile` retains the applied value silently, and `rebaseline` restates the
|
||||
* belief by rendering the last-applied value instead of a live observation.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -45,39 +50,30 @@ export interface SystemContext {
|
||||
readonly [ContextTypeId]: ReadonlyArray<PackedSource>
|
||||
}
|
||||
|
||||
/** Durable comparison state for one admitted source. */
|
||||
export const SourceSnapshot = Schema.Struct({
|
||||
/** The value last applied to the model for one admitted source. */
|
||||
export const AppliedSource = Schema.Struct({
|
||||
value: Schema.Json,
|
||||
removed: Schema.optional(Schema.NonEmptyString),
|
||||
})
|
||||
export type SourceSnapshot = typeof SourceSnapshot.Type
|
||||
export type AppliedSource = typeof AppliedSource.Type
|
||||
|
||||
/** Durable structured comparison state for one active context generation. */
|
||||
export const Snapshot = Schema.Record(Key, SourceSnapshot)
|
||||
export type Snapshot = Readonly<Record<string, SourceSnapshot>>
|
||||
/** Durable record of what the model currently believes, per source. */
|
||||
export const Applied = Schema.Record(Key, AppliedSource)
|
||||
export type Applied = Readonly<Record<string, AppliedSource>>
|
||||
|
||||
export interface Generation {
|
||||
readonly baseline: string
|
||||
readonly snapshot: Snapshot
|
||||
/** A rendered baseline together with the applied values it was rendered from. */
|
||||
export interface Baseline {
|
||||
readonly text: string
|
||||
readonly applied: Applied
|
||||
}
|
||||
|
||||
export interface Updated {
|
||||
readonly _tag: "Updated"
|
||||
readonly text: string
|
||||
readonly snapshot: Snapshot
|
||||
readonly applied: Applied
|
||||
}
|
||||
|
||||
export interface ReplacementReady {
|
||||
readonly _tag: "ReplacementReady"
|
||||
readonly generation: Generation
|
||||
}
|
||||
|
||||
export interface ReplacementBlocked {
|
||||
readonly _tag: "ReplacementBlocked"
|
||||
}
|
||||
|
||||
export type ReplacementResult = ReplacementReady | ReplacementBlocked
|
||||
export type ReconcileResult = { readonly _tag: "Unchanged" } | Updated | ReplacementResult
|
||||
export type ReconcileResult = { readonly _tag: "Unchanged" } | Updated
|
||||
|
||||
export class InitializationBlocked extends Schema.TaggedErrorClass<InitializationBlocked>()(
|
||||
"SystemContext.InitializationBlocked",
|
||||
@@ -98,36 +94,24 @@ export class DuplicateKeyError extends Schema.TaggedErrorClass<DuplicateKeyError
|
||||
|
||||
interface PackedSource {
|
||||
readonly key: Key
|
||||
readonly load: Effect.Effect<Loaded | Unavailable>
|
||||
readonly load: Effect.Effect<Observed | Unavailable>
|
||||
/** Restates the model's belief from a last-applied value when the source cannot be observed. */
|
||||
readonly recall: (stored: AppliedSource) => string | undefined
|
||||
}
|
||||
|
||||
interface Loaded {
|
||||
readonly baseline: () => Rendered
|
||||
readonly compare: (previous: Schema.Json) => Compared
|
||||
interface Observed {
|
||||
readonly applied: AppliedSource
|
||||
readonly baseline: () => string
|
||||
/** `undefined` means unchanged. An undecodable previous value re-renders the baseline (treat-as-new). */
|
||||
readonly update: (previous: AppliedSource) => string | undefined
|
||||
}
|
||||
|
||||
interface Rendered {
|
||||
readonly text: string
|
||||
readonly snapshot: SourceSnapshot
|
||||
}
|
||||
|
||||
type Compared =
|
||||
| { readonly _tag: "Incompatible" }
|
||||
| { readonly _tag: "Unchanged" }
|
||||
| { readonly _tag: "Updated"; readonly render: () => Rendered }
|
||||
|
||||
interface AvailableEntry extends Loaded {
|
||||
readonly _tag: "Available"
|
||||
interface Entry {
|
||||
readonly key: Key
|
||||
readonly recall: PackedSource["recall"]
|
||||
readonly observed: Observed | Unavailable
|
||||
}
|
||||
|
||||
interface UnavailableEntry {
|
||||
readonly _tag: "Unavailable"
|
||||
readonly key: Key
|
||||
}
|
||||
|
||||
type Entry = AvailableEntry | UnavailableEntry
|
||||
|
||||
/** The identity context. */
|
||||
export const empty = context([])
|
||||
|
||||
@@ -136,42 +120,65 @@ export function make<A>(source: Source<A>): SystemContext {
|
||||
const decode = Schema.decodeUnknownOption(source.codec)
|
||||
const encode = Schema.encodeSync(source.codec)
|
||||
const equivalent = Schema.toEquivalence(source.codec)
|
||||
const baseline = (value: A) => requireText(source.key, "baseline", source.baseline(value))
|
||||
return context([
|
||||
{
|
||||
key: source.key,
|
||||
recall: (stored) =>
|
||||
Option.match(decode(stored.value), {
|
||||
onNone: () => undefined,
|
||||
onSome: baseline,
|
||||
}),
|
||||
load: source.load.pipe(
|
||||
Effect.map((value) => {
|
||||
if (isUnavailable(value)) return value
|
||||
const snapshot = (): SourceSnapshot => ({
|
||||
value: encode(value),
|
||||
...(source.removed ? { removed: requireText(source.key, "removal", source.removed(value)) } : {}),
|
||||
})
|
||||
return {
|
||||
baseline: (): Rendered => ({
|
||||
text: requireText(source.key, "baseline", source.baseline(value)),
|
||||
snapshot: snapshot(),
|
||||
}),
|
||||
compare: (previous): Compared =>
|
||||
Option.match(decode(previous), {
|
||||
onNone: (): Compared => ({ _tag: "Incompatible" }),
|
||||
onSome: (decoded): Compared =>
|
||||
applied: {
|
||||
value: encode(value),
|
||||
...(source.removed ? { removed: requireText(source.key, "removal", source.removed(value)) } : {}),
|
||||
},
|
||||
baseline: () => baseline(value),
|
||||
update: (previous) =>
|
||||
Option.match(decode(previous.value), {
|
||||
onNone: () => baseline(value),
|
||||
onSome: (decoded) =>
|
||||
equivalent(decoded, value)
|
||||
? { _tag: "Unchanged" }
|
||||
: {
|
||||
_tag: "Updated",
|
||||
render: () => ({
|
||||
text: requireText(source.key, "update", source.update(decoded, value)),
|
||||
snapshot: snapshot(),
|
||||
}),
|
||||
},
|
||||
? undefined
|
||||
: requireText(source.key, "update", source.update(decoded, value)),
|
||||
}),
|
||||
}
|
||||
} satisfies Observed
|
||||
}),
|
||||
),
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyed three-way diff for list-shaped sources rendering delta updates.
|
||||
* `changed` compares two values sharing a key; entries equal under it are dropped.
|
||||
*/
|
||||
export function diffByKey<A>(
|
||||
previous: ReadonlyArray<A>,
|
||||
current: ReadonlyArray<A>,
|
||||
key: (value: A) => string,
|
||||
changed: (previous: A, current: A) => boolean,
|
||||
): {
|
||||
readonly added: ReadonlyArray<A>
|
||||
readonly removed: ReadonlyArray<A>
|
||||
readonly changed: ReadonlyArray<{ readonly previous: A; readonly current: A }>
|
||||
} {
|
||||
const currentKeys = new Set(current.map(key))
|
||||
const previousByKey = new Map(previous.map((value) => [key(value), value] as const))
|
||||
return {
|
||||
added: current.filter((value) => !previousByKey.has(key(value))),
|
||||
removed: previous.filter((value) => !currentKeys.has(key(value))),
|
||||
changed: current.flatMap((value) => {
|
||||
const before = previousByKey.get(key(value))
|
||||
return before === undefined || !changed(before, value) ? [] : [{ previous: before, current: value }]
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/** Combines contexts in order and rejects duplicate source keys immediately. */
|
||||
export function combine(values: ReadonlyArray<SystemContext>): SystemContext {
|
||||
const sources = values.flatMap((value) => value[ContextTypeId])
|
||||
@@ -183,111 +190,91 @@ const observe = (value: SystemContext) =>
|
||||
Effect.forEach(
|
||||
value[ContextTypeId],
|
||||
(source) =>
|
||||
source.load.pipe(
|
||||
Effect.map(
|
||||
(result): Entry =>
|
||||
result === unavailable
|
||||
? { _tag: "Unavailable", key: source.key }
|
||||
: { _tag: "Available", key: source.key, ...result },
|
||||
),
|
||||
),
|
||||
source.load.pipe(Effect.map((observed): Entry => ({ key: source.key, recall: source.recall, observed }))),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
/** Creates the immutable baseline and durable snapshot for a new generation. */
|
||||
export function initialize(value: SystemContext): Effect.Effect<Generation, InitializationBlocked> {
|
||||
/** Creates the first baseline. Blocks rather than admit a baseline missing an unobservable source. */
|
||||
export function initialize(value: SystemContext): Effect.Effect<Baseline, InitializationBlocked> {
|
||||
return observe(value).pipe(
|
||||
Effect.flatMap((entries) => {
|
||||
const unavailable = entries.flatMap((entry) => (entry._tag === "Unavailable" ? [entry.key] : []))
|
||||
if (unavailable.length > 0) return new InitializationBlocked({ keys: unavailable })
|
||||
return Effect.succeed(initializeObservation(entries))
|
||||
const blocked = entries.flatMap((entry) => (entry.observed === unavailable ? [entry.key] : []))
|
||||
if (blocked.length > 0) return new InitializationBlocked({ keys: blocked })
|
||||
const parts: string[] = []
|
||||
const applied: Record<string, AppliedSource> = {}
|
||||
for (const entry of entries) {
|
||||
if (entry.observed === unavailable) continue
|
||||
parts.push(entry.observed.baseline())
|
||||
applied[entry.key] = entry.observed.applied
|
||||
}
|
||||
return Effect.succeed({ text: render(parts), applied })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function initializeObservation(entries: ReadonlyArray<Entry>): Generation {
|
||||
const available = entries.filter((entry): entry is AvailableEntry => entry._tag === "Available")
|
||||
const rendered = available.map((entry) => [entry.key, entry.baseline()] as const)
|
||||
return {
|
||||
baseline: render(rendered.map(([, result]) => result.text)),
|
||||
snapshot: Object.fromEntries(rendered.map(([key, result]) => [key, result.snapshot])),
|
||||
}
|
||||
}
|
||||
|
||||
/** Reconciles current source values with one active generation. */
|
||||
export function reconcile(value: SystemContext, previous: Snapshot): Effect.Effect<ReconcileResult> {
|
||||
/** Narrates drift between current source values and the model's beliefs. Never rewrites the baseline. */
|
||||
export function reconcile(value: SystemContext, previous: Applied): Effect.Effect<ReconcileResult> {
|
||||
return observe(value).pipe(
|
||||
Effect.map((entries): ReconcileResult => {
|
||||
const result = reconcileObservation(entries, previous)
|
||||
if (result._tag === "Unchanged" || result._tag === "Updated") return result
|
||||
return replaceObservation(entries, previous)
|
||||
const updates: string[] = []
|
||||
const applied: Record<string, AppliedSource> = {}
|
||||
for (const entry of entries) {
|
||||
const stored = get(previous, entry.key)
|
||||
if (entry.observed === unavailable) {
|
||||
// The prior belief stands while the source cannot be observed.
|
||||
if (stored) applied[entry.key] = stored
|
||||
continue
|
||||
}
|
||||
if (!stored) {
|
||||
updates.push(entry.observed.baseline())
|
||||
applied[entry.key] = entry.observed.applied
|
||||
continue
|
||||
}
|
||||
const text = entry.observed.update(stored)
|
||||
if (text === undefined) {
|
||||
applied[entry.key] = stored
|
||||
continue
|
||||
}
|
||||
updates.push(text)
|
||||
applied[entry.key] = entry.observed.applied
|
||||
}
|
||||
const keys = new Set<string>(entries.map((entry) => entry.key))
|
||||
for (const key of Object.keys(previous).sort()) {
|
||||
if (keys.has(key)) continue
|
||||
const removed = previous[key].removed
|
||||
// An unannounced removal retains the belief; it clears at the next rebaseline.
|
||||
if (removed === undefined) applied[key] = previous[key]
|
||||
else updates.push(removed)
|
||||
}
|
||||
if (updates.length === 0) return { _tag: "Unchanged" }
|
||||
return { _tag: "Updated", text: render(updates), applied }
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function reconcileObservation(
|
||||
entries: ReadonlyArray<Entry>,
|
||||
previous: Snapshot,
|
||||
): { readonly _tag: "Unchanged" } | Updated | { readonly _tag: "Replace" } {
|
||||
const keys = new Set(entries.map((entry) => entry.key))
|
||||
const comparisons = new Map<Key, Compared>()
|
||||
for (const entry of entries) {
|
||||
if (entry._tag === "Unavailable") continue
|
||||
const stored = getSnapshot(previous, entry.key)
|
||||
if (!stored) continue
|
||||
const compared = entry.compare(stored.value)
|
||||
if (compared._tag === "Incompatible") return { _tag: "Replace" }
|
||||
comparisons.set(entry.key, compared)
|
||||
}
|
||||
for (const key of Object.keys(previous).sort()) {
|
||||
if (keys.has(Key.make(key))) continue
|
||||
if (previous[key].removed === undefined) return { _tag: "Replace" }
|
||||
}
|
||||
|
||||
const snapshot: Record<string, SourceSnapshot> = {}
|
||||
const updates: string[] = []
|
||||
for (const entry of entries) {
|
||||
const stored = getSnapshot(previous, entry.key)
|
||||
if (entry._tag === "Unavailable") {
|
||||
if (stored) snapshot[entry.key] = stored
|
||||
continue
|
||||
}
|
||||
if (!stored) {
|
||||
const rendered = entry.baseline()
|
||||
updates.push(rendered.text)
|
||||
snapshot[entry.key] = rendered.snapshot
|
||||
continue
|
||||
}
|
||||
const compared = comparisons.get(entry.key)
|
||||
if (!compared || compared._tag === "Incompatible")
|
||||
throw new Error(`Missing comparison for system context source ${entry.key}`)
|
||||
if (compared._tag === "Unchanged") {
|
||||
snapshot[entry.key] = stored
|
||||
continue
|
||||
}
|
||||
const rendered = compared.render()
|
||||
updates.push(rendered.text)
|
||||
snapshot[entry.key] = rendered.snapshot
|
||||
}
|
||||
for (const key of Object.keys(previous).sort()) {
|
||||
if (keys.has(Key.make(key))) continue
|
||||
const removed = previous[key].removed
|
||||
if (removed === undefined) throw new Error(`Missing removal rendering for system context source ${key}`)
|
||||
updates.push(removed)
|
||||
}
|
||||
if (updates.length === 0) return { _tag: "Unchanged" }
|
||||
return { _tag: "Updated", text: render(updates), snapshot }
|
||||
}
|
||||
|
||||
/** Creates a complete replacement generation or blocks while admitted context is unavailable. */
|
||||
export function replace(value: SystemContext, previous: Snapshot): Effect.Effect<ReplacementResult> {
|
||||
return observe(value).pipe(Effect.map((entries) => replaceObservation(entries, previous)))
|
||||
}
|
||||
|
||||
function replaceObservation(entries: ReadonlyArray<Entry>, previous: Snapshot): ReplacementResult {
|
||||
if (entries.some((entry) => entry._tag === "Unavailable" && getSnapshot(previous, entry.key) !== undefined))
|
||||
return { _tag: "ReplacementBlocked" }
|
||||
return { _tag: "ReplacementReady", generation: initializeObservation(entries) }
|
||||
/** Rebuilds the baseline, restating unobservable sources from the model's last-applied beliefs. */
|
||||
export function rebaseline(value: SystemContext, previous: Applied): Effect.Effect<Baseline> {
|
||||
return observe(value).pipe(
|
||||
Effect.map((entries): Baseline => {
|
||||
const parts: string[] = []
|
||||
const applied: Record<string, AppliedSource> = {}
|
||||
for (const entry of entries) {
|
||||
if (entry.observed !== unavailable) {
|
||||
parts.push(entry.observed.baseline())
|
||||
applied[entry.key] = entry.observed.applied
|
||||
continue
|
||||
}
|
||||
const stored = get(previous, entry.key)
|
||||
if (!stored) continue
|
||||
const text = entry.recall(stored)
|
||||
// An undecodable belief cannot be restated; the source re-announces when observable again.
|
||||
if (text === undefined) continue
|
||||
parts.push(text)
|
||||
applied[entry.key] = stored
|
||||
}
|
||||
return { text: render(parts), applied }
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function context(sources: ReadonlyArray<PackedSource>): SystemContext {
|
||||
@@ -298,8 +285,8 @@ function render(parts: ReadonlyArray<string>) {
|
||||
return parts.join("\n\n")
|
||||
}
|
||||
|
||||
function getSnapshot(snapshot: Snapshot, key: Key) {
|
||||
return Object.hasOwn(snapshot, key) ? snapshot[key] : undefined
|
||||
function get(applied: Applied, key: Key) {
|
||||
return Object.hasOwn(applied, key) ? applied[key] : undefined
|
||||
}
|
||||
|
||||
function isUnavailable(value: unknown): value is Unavailable {
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
export * as SystemContextRegistry from "./registry"
|
||||
|
||||
import { Context, Effect, Layer, Ref, Scope } from "effect"
|
||||
import { SystemContext } from "./index"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
|
||||
export interface Entry {
|
||||
readonly key: SystemContext.Key
|
||||
readonly load: Effect.Effect<SystemContext.SystemContext>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly register: (entry: Entry) => Effect.Effect<void, never, Scope.Scope>
|
||||
readonly load: () => Effect.Effect<SystemContext.SystemContext>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SystemContextRegistry") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const entries = yield* Ref.make<ReadonlyArray<Entry>>([])
|
||||
|
||||
return Service.of({
|
||||
register: Effect.fn("SystemContextRegistry.register")(function* (entry) {
|
||||
yield* Effect.acquireRelease(
|
||||
Ref.modify(entries, (current) => {
|
||||
if (current.some((item) => item.key === entry.key)) return [false, current]
|
||||
return [true, [...current, entry]]
|
||||
}).pipe(
|
||||
Effect.flatMap((added) =>
|
||||
added ? Effect.void : Effect.die(`Duplicate system context entry key: ${entry.key}`),
|
||||
),
|
||||
Effect.as(entry),
|
||||
),
|
||||
(entry) => Ref.update(entries, (current) => current.filter((item) => item !== entry)),
|
||||
)
|
||||
}),
|
||||
load: Effect.fn("SystemContextRegistry.load")(function* () {
|
||||
const current = (yield* Ref.get(entries)).toSorted((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
|
||||
return SystemContext.combine(
|
||||
yield* Effect.forEach(current, (entry) => entry.load, { concurrency: "unbounded" }),
|
||||
)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [] })
|
||||
@@ -0,0 +1,92 @@
|
||||
export * as ToolHooks from "./hooks"
|
||||
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { SessionMessage } from "../session/message"
|
||||
import { SessionSchema } from "../session/schema"
|
||||
import { State } from "../state"
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
import type { ToolOutput, ToolResultValue } from "@opencode-ai/llm"
|
||||
|
||||
export interface BeforeEvent {
|
||||
readonly tool: string
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly agent: AgentV2.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly toolCallID: string
|
||||
input: unknown
|
||||
}
|
||||
|
||||
export interface AfterEvent {
|
||||
readonly tool: string
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly agent: AgentV2.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly toolCallID: string
|
||||
readonly input: unknown
|
||||
result: ToolResultValue
|
||||
output?: ToolOutput
|
||||
outputPaths?: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly hook: {
|
||||
readonly before: (
|
||||
callback: (event: BeforeEvent) => Effect.Effect<void> | void,
|
||||
) => Effect.Effect<State.Registration, never, Scope.Scope>
|
||||
readonly after: (
|
||||
callback: (event: AfterEvent) => Effect.Effect<void> | void,
|
||||
) => Effect.Effect<State.Registration, never, Scope.Scope>
|
||||
}
|
||||
readonly runBefore: (event: BeforeEvent) => Effect.Effect<BeforeEvent>
|
||||
readonly runAfter: (event: AfterEvent) => Effect.Effect<AfterEvent>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolHooks") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
let beforeHooks: ((event: BeforeEvent) => Effect.Effect<void> | void)[] = []
|
||||
let afterHooks: ((event: AfterEvent) => Effect.Effect<void> | void)[] = []
|
||||
|
||||
const register = <Event>(
|
||||
hooks: () => ((event: Event) => Effect.Effect<void> | void)[],
|
||||
update: (hooks: ((event: Event) => Effect.Effect<void> | void)[]) => void,
|
||||
) =>
|
||||
Effect.fn("ToolHooks.hook")(function* (callback: (event: Event) => Effect.Effect<void> | void) {
|
||||
const scope = yield* Scope.Scope
|
||||
let active = true
|
||||
update([...hooks(), callback])
|
||||
const dispose = Effect.sync(() => {
|
||||
if (!active) return
|
||||
active = false
|
||||
update(hooks().filter((item) => item !== callback))
|
||||
})
|
||||
yield* Scope.addFinalizer(scope, dispose)
|
||||
return { dispose }
|
||||
})
|
||||
|
||||
const run = Effect.fnUntraced(function* <Event>(
|
||||
hooks: readonly ((event: Event) => Effect.Effect<void> | void)[],
|
||||
event: Event,
|
||||
) {
|
||||
for (const hook of hooks) {
|
||||
const result = hook(event)
|
||||
if (Effect.isEffect(result)) yield* result
|
||||
}
|
||||
return event
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
hook: {
|
||||
before: register(() => beforeHooks, (next) => (beforeHooks = next)),
|
||||
after: register(() => afterHooks, (next) => (afterHooks = next)),
|
||||
},
|
||||
runBefore: (event) => run(beforeHooks, event),
|
||||
runAfter: (event) => run(afterHooks, event),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [] })
|
||||
@@ -1,12 +1,16 @@
|
||||
export * as ReadTool from "./read"
|
||||
|
||||
import { dirname } from "path"
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { Image } from "../image"
|
||||
import { Location } from "../location"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { SessionInstructions } from "../session/instructions"
|
||||
import { AbsolutePath } from "../schema"
|
||||
import { ReadToolFileSystem } from "./read-filesystem"
|
||||
import { ToolRegistry } from "./registry"
|
||||
@@ -14,6 +18,7 @@ import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
|
||||
export const name = "read"
|
||||
const FILENAME = "AGENTS.md"
|
||||
const SUPPORTED_IMAGE_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"])
|
||||
const LocationInput = Schema.Struct({
|
||||
path: Schema.String,
|
||||
@@ -34,6 +39,9 @@ const layer = Layer.effectDiscard(
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const image = yield* Image.Service
|
||||
const permission = yield* PermissionV2.Service
|
||||
const sessionInstructions = yield* SessionInstructions.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
|
||||
yield* tools
|
||||
.register({
|
||||
@@ -77,12 +85,33 @@ const layer = Layer.effectDiscard(
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
if (type === "directory")
|
||||
return yield* reader.list(absolute, { offset: input.offset, limit: input.limit })
|
||||
const content = yield* reader.read(absolute, resource, {
|
||||
offset: input.offset,
|
||||
limit: input.limit,
|
||||
})
|
||||
const content =
|
||||
type === "directory"
|
||||
? yield* reader.list(absolute, { offset: input.offset, limit: input.limit })
|
||||
: yield* reader.read(absolute, resource, {
|
||||
offset: input.offset,
|
||||
limit: input.limit,
|
||||
})
|
||||
// After a successful read, discover nearby AGENTS.md walking up to the Location
|
||||
// root exclusive and inject them as durable synthetic instructions. For a
|
||||
// directory listing the walk starts at the directory itself (so its own AGENTS.md
|
||||
// is discovered); for a file it starts at the file's dirname. External reads are
|
||||
// skipped, and discovery failures never fail the read.
|
||||
yield* Effect.gen(function* () {
|
||||
if (target.externalDirectory !== undefined) return
|
||||
const resolved = FSUtil.resolve(target.canonical)
|
||||
const root = FSUtil.resolve(location.directory)
|
||||
// up() searches its stop directory, so the Location-root AGENTS.md (already
|
||||
// supplied by the core/instructions baseline) is dropped by the dirname filter.
|
||||
const discovered = yield* fs.up({
|
||||
targets: [FILENAME],
|
||||
start: type === "directory" ? resolved : dirname(resolved),
|
||||
stop: root,
|
||||
})
|
||||
const candidates = discovered.map(FSUtil.resolve).filter((file) => dirname(file) !== root)
|
||||
if (candidates.length === 0) return
|
||||
yield* sessionInstructions.load({ sessionID: context.sessionID, paths: candidates })
|
||||
}).pipe(Effect.catch(() => Effect.void), Effect.catchDefect(() => Effect.void))
|
||||
if ("encoding" in content && content.encoding === "base64" && SUPPORTED_IMAGE_MIMES.has(content.mime)) {
|
||||
return yield* image
|
||||
.normalize(resource, { ...content, encoding: "base64" })
|
||||
@@ -113,5 +142,14 @@ const layer = Layer.effectDiscard(
|
||||
export const node = makeLocationNode({
|
||||
name: "tool/read",
|
||||
layer,
|
||||
deps: [ToolRegistry.node, ReadToolFileSystem.node, LocationMutation.node, Image.node, PermissionV2.node],
|
||||
deps: [
|
||||
ToolRegistry.node,
|
||||
ReadToolFileSystem.node,
|
||||
LocationMutation.node,
|
||||
Image.node,
|
||||
PermissionV2.node,
|
||||
SessionInstructions.node,
|
||||
FSUtil.node,
|
||||
Location.node,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ToolOutputStore } from "../tool-output-store"
|
||||
import { Wildcard } from "../util/wildcard"
|
||||
import { definition, permission, registrationEntries, settle, type AnyTool, type RegistrationError } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
import { ToolHooks } from "./hooks"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
|
||||
export type ExecuteInput = {
|
||||
@@ -47,6 +48,7 @@ const registryLayer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const resources = yield* ToolOutputStore.Service
|
||||
const toolHooks = yield* ToolHooks.Service
|
||||
type Registration = { readonly identity: object; readonly tool: AnyTool }
|
||||
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
|
||||
|
||||
@@ -61,7 +63,17 @@ const registryLayer = Layer.effect(
|
||||
}
|
||||
if (advertised && registration.identity !== advertised)
|
||||
return { result: { type: "error" as const, value: `Stale tool call: ${input.call.name}` } }
|
||||
const pending = yield* settle(registration.tool, input.call, {
|
||||
// Hooks fire only for hosted/local tools; provider-executed calls never reach settleWith.
|
||||
const beforeEvent: ToolHooks.BeforeEvent = {
|
||||
tool: input.call.name,
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
assistantMessageID: input.assistantMessageID,
|
||||
toolCallID: input.call.id,
|
||||
input: input.call.input,
|
||||
}
|
||||
yield* toolHooks.runBefore(beforeEvent)
|
||||
const pending = yield* settle(registration.tool, { ...input.call, input: beforeEvent.input }, {
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
assistantMessageID: input.assistantMessageID,
|
||||
@@ -72,15 +84,38 @@ const registryLayer = Layer.effect(
|
||||
Effect.succeed({ result: { type: "error" as const, value: failure.message } }),
|
||||
),
|
||||
)
|
||||
if ("result" in pending) return pending
|
||||
const output = pending.output
|
||||
const bounded = yield* resources.bound({ sessionID: input.sessionID, toolCallID: input.call.id, output })
|
||||
const result = ToolOutput.toResultValue(bounded.output)
|
||||
if (result.type === "error")
|
||||
return bounded.outputPaths.length > 0 ? { result, outputPaths: bounded.outputPaths } : { result }
|
||||
return bounded.outputPaths.length > 0
|
||||
? { result, output: bounded.output, outputPaths: bounded.outputPaths }
|
||||
: { result, output: bounded.output }
|
||||
let settlement: Settlement
|
||||
if ("result" in pending) {
|
||||
settlement = pending
|
||||
} else {
|
||||
const bounded = yield* resources.bound({ sessionID: input.sessionID, toolCallID: input.call.id, output: pending.output })
|
||||
const result = ToolOutput.toResultValue(bounded.output)
|
||||
settlement =
|
||||
result.type === "error"
|
||||
? bounded.outputPaths.length > 0
|
||||
? { result, outputPaths: bounded.outputPaths }
|
||||
: { result }
|
||||
: bounded.outputPaths.length > 0
|
||||
? { result, output: bounded.output, outputPaths: bounded.outputPaths }
|
||||
: { result, output: bounded.output }
|
||||
}
|
||||
const afterEvent: ToolHooks.AfterEvent = {
|
||||
tool: input.call.name,
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
assistantMessageID: input.assistantMessageID,
|
||||
toolCallID: input.call.id,
|
||||
input: beforeEvent.input,
|
||||
result: settlement.result,
|
||||
output: settlement.output,
|
||||
outputPaths: settlement.outputPaths,
|
||||
}
|
||||
yield* toolHooks.runAfter(afterEvent)
|
||||
return {
|
||||
result: afterEvent.result,
|
||||
...(afterEvent.output !== undefined ? { output: afterEvent.output } : {}),
|
||||
...(afterEvent.outputPaths !== undefined ? { outputPaths: afterEvent.outputPaths } : {}),
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
@@ -143,11 +178,11 @@ function whollyDisabled(action: string, rules: PermissionV2.Ruleset) {
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [ToolOutputStore.node],
|
||||
deps: [ToolOutputStore.node, ToolHooks.node],
|
||||
})
|
||||
|
||||
export const toolsNode = makeLocationNode({
|
||||
service: Tools.Service,
|
||||
layer,
|
||||
deps: [ToolOutputStore.node],
|
||||
deps: [ToolOutputStore.node, ToolHooks.node],
|
||||
})
|
||||
|
||||
@@ -173,6 +173,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
||||
model: { providerID: "anthropic", id: "claude-sonnet", variant: undefined },
|
||||
})
|
||||
expect(reviewer.request).toEqual({
|
||||
settings: {},
|
||||
headers: { first: "one", shared: "last", second: "two" },
|
||||
body: { enabled: true, profile: "review", retries: 2, effort: "high" },
|
||||
})
|
||||
|
||||
@@ -10,7 +10,6 @@ import { InstructionContext } from "@opencode-ai/core/instruction-context"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SystemContext } from "@opencode-ai/core/system-context"
|
||||
import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
@@ -22,7 +21,7 @@ const instructionLayer = (input: {
|
||||
locationServiceLayer: Layer.Layer<Location.Service>
|
||||
filesystemLayer?: Layer.Layer<FSUtil.Service>
|
||||
}) =>
|
||||
AppNodeBuilder.build(LayerNode.group([SystemContextRegistry.node, InstructionContext.node]), [
|
||||
AppNodeBuilder.build(InstructionContext.node, [
|
||||
[Global.node, Global.layerWith({ config: input.config })],
|
||||
[Location.node, input.locationServiceLayer],
|
||||
...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []),
|
||||
@@ -52,7 +51,7 @@ describe("InstructionContext", () => {
|
||||
await fs.writeFile(packageFile, "package")
|
||||
})
|
||||
|
||||
const load = SystemContextRegistry.Service.pipe(
|
||||
const load = InstructionContext.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
@@ -71,23 +70,23 @@ describe("InstructionContext", () => {
|
||||
)
|
||||
|
||||
const initialized = yield* SystemContext.initialize(yield* load)
|
||||
expect(initialized.baseline).toBe(
|
||||
expect(initialized.text).toBe(
|
||||
[
|
||||
`Instructions from: ${globalFile}\nglobal`,
|
||||
`Instructions from: ${packageFile}\npackage`,
|
||||
`Instructions from: ${projectFile}\nproject`,
|
||||
].join("\n\n"),
|
||||
)
|
||||
expect(initialized.baseline).not.toContain("outside")
|
||||
expect(initialized.text).not.toContain("outside")
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(packageFile, "changed"))
|
||||
expect(yield* SystemContext.reconcile(yield* load, initialized.snapshot)).toMatchObject({
|
||||
expect(yield* SystemContext.reconcile(yield* load, initialized.applied)).toMatchObject({
|
||||
_tag: "Updated",
|
||||
text: expect.stringContaining(`Instructions from: ${packageFile}\nchanged`),
|
||||
})
|
||||
|
||||
yield* Effect.promise(() => fs.rm(packageFile))
|
||||
const partial = yield* SystemContext.reconcile(yield* load, initialized.snapshot)
|
||||
const partial = yield* SystemContext.reconcile(yield* load, initialized.applied)
|
||||
expect(partial).toEqual({
|
||||
_tag: "Updated",
|
||||
text: [
|
||||
@@ -95,14 +94,14 @@ describe("InstructionContext", () => {
|
||||
`Instructions from: ${globalFile}\nglobal`,
|
||||
`Instructions from: ${projectFile}\nproject`,
|
||||
].join("\n\n"),
|
||||
snapshot: expect.any(Object),
|
||||
applied: expect.any(Object),
|
||||
})
|
||||
|
||||
yield* Effect.promise(() => Promise.all([fs.rm(globalFile), fs.rm(projectFile)]))
|
||||
expect(yield* SystemContext.reconcile(yield* load, initialized.snapshot)).toEqual({
|
||||
expect(yield* SystemContext.reconcile(yield* load, initialized.applied)).toEqual({
|
||||
_tag: "Updated",
|
||||
text: "Previously loaded instructions no longer apply.",
|
||||
snapshot: {},
|
||||
applied: {},
|
||||
})
|
||||
}),
|
||||
),
|
||||
@@ -118,7 +117,7 @@ describe("InstructionContext", () => {
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(tmp.path, "AGENTS.md")
|
||||
yield* Effect.promise(() => fs.writeFile(file, ""))
|
||||
const context = yield* SystemContextRegistry.Service.pipe(
|
||||
const context = yield* InstructionContext.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
@@ -131,7 +130,7 @@ describe("InstructionContext", () => {
|
||||
),
|
||||
)
|
||||
|
||||
expect((yield* SystemContext.initialize(context)).baseline).toBe(`Instructions from: ${file}\n`)
|
||||
expect((yield* SystemContext.initialize(context)).text).toBe(`Instructions from: ${file}\n`)
|
||||
}),
|
||||
),
|
||||
),
|
||||
@@ -147,7 +146,7 @@ describe("InstructionContext", () => {
|
||||
),
|
||||
),
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||
const context = yield* SystemContextRegistry.Service.pipe(
|
||||
const context = yield* InstructionContext.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
@@ -187,7 +186,7 @@ describe("InstructionContext", () => {
|
||||
),
|
||||
),
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||
const context = yield* SystemContextRegistry.Service.pipe(
|
||||
const context = yield* InstructionContext.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
@@ -231,7 +230,7 @@ describe("InstructionContext", () => {
|
||||
),
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||
|
||||
yield* SystemContextRegistry.Service.pipe(
|
||||
yield* InstructionContext.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
@@ -261,7 +260,7 @@ describe("InstructionContext", () => {
|
||||
let scanned = false
|
||||
process.env.OPENCODE_DISABLE_PROJECT_CONFIG = "1"
|
||||
|
||||
yield* SystemContextRegistry.Service.pipe(
|
||||
yield* InstructionContext.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
@@ -293,7 +292,7 @@ describe("InstructionContext", () => {
|
||||
it.effect("does not discover project instructions outside the canonical project root", () =>
|
||||
Effect.gen(function* () {
|
||||
let scanned = false
|
||||
yield* SystemContextRegistry.Service.pipe(
|
||||
yield* InstructionContext.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Effect, Exit, Fiber, Schema } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { Tool } from "@opencode-ai/core/tool/tool"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { testEffect } from "./lib/effect"
|
||||
@@ -102,4 +104,68 @@ describe("PluginV2", () => {
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fires before/after tool hooks with mutable events around settlement", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const executed: unknown[] = []
|
||||
const seen: {
|
||||
before?: unknown
|
||||
after?: { input: unknown; result: unknown; output: unknown }
|
||||
} = {}
|
||||
|
||||
const plugin = define({
|
||||
id: "tool-hooks",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.tool
|
||||
.register({
|
||||
echo: Tool.make({
|
||||
description: "Echo",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
execute: ({ text }) => Effect.sync(() => executed.push({ text })).pipe(Effect.as({ text })),
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
yield* ctx.tool.execute
|
||||
.before((event) => {
|
||||
seen.before = event.input
|
||||
event.input = { text: "before-mutated" }
|
||||
})
|
||||
.pipe(Effect.asVoid)
|
||||
|
||||
yield* ctx.tool.execute
|
||||
.after((event) => {
|
||||
seen.after = { input: event.input, result: event.result, output: event.output }
|
||||
event.result = { type: "text", value: "after-mutated" }
|
||||
event.output = { structured: { rewritten: true }, content: [] }
|
||||
})
|
||||
.pipe(Effect.asVoid)
|
||||
}),
|
||||
})
|
||||
|
||||
yield* plugins.add(PluginV2.ID.make(plugin.id), plugin.effect)
|
||||
|
||||
const materialized = yield* registry.materialize({ model: testModel })
|
||||
const settlement = yield* materialized.settle({
|
||||
sessionID: SessionV2.ID.make("ses_hooks"),
|
||||
agent: AgentV2.ID.make("build"),
|
||||
assistantMessageID: SessionMessage.ID.make("msg_hooks"),
|
||||
call: { type: "tool-call", id: "call-hooks", name: "echo", input: { text: "original" } },
|
||||
})
|
||||
|
||||
expect(seen.before).toEqual({ text: "original" })
|
||||
expect(executed).toEqual([{ text: "before-mutated" }])
|
||||
expect(seen.after).toEqual({
|
||||
input: { text: "before-mutated" },
|
||||
result: { type: "json", value: { text: "before-mutated" } },
|
||||
output: { structured: { text: "before-mutated" }, content: [] },
|
||||
})
|
||||
expect(settlement.result).toEqual({ type: "text", value: "after-mutated" })
|
||||
expect(settlement.output).toEqual({ structured: { rewritten: true }, content: [] })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@ import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||
import { Reference } from "@opencode-ai/core/reference"
|
||||
import { SkillV2 } from "@opencode-ai/core/skill"
|
||||
import { ToolHooks } from "@opencode-ai/core/tool/hooks"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { tempLocationLayer } from "../fixture/location"
|
||||
@@ -47,6 +48,7 @@ export const PluginTestLayer = AppNodeBuilder.build(
|
||||
PluginRuntime.node,
|
||||
Reference.node,
|
||||
SkillV2.node,
|
||||
ToolHooks.node,
|
||||
ToolRegistry.toolsNode,
|
||||
]),
|
||||
[
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"openai": {
|
||||
"id": "openai",
|
||||
"name": "OpenAI",
|
||||
"env": ["OPENAI_API_KEY"],
|
||||
"npm": "@ai-sdk/openai",
|
||||
"api": "https://api.openai.com/v1",
|
||||
"models": {
|
||||
"gpt-reasoning": {
|
||||
"id": "gpt-reasoning",
|
||||
"name": "GPT Reasoning",
|
||||
"release_date": "2026-01-01",
|
||||
"attachment": false,
|
||||
"reasoning": true,
|
||||
"reasoning_options": [
|
||||
{ "type": "effort", "values": ["low", "high"] },
|
||||
{ "type": "budget_tokens", "min": 1024, "max": 64000 },
|
||||
{ "type": "toggle" }
|
||||
],
|
||||
"temperature": true,
|
||||
"tool_call": true,
|
||||
"limit": { "context": 128000, "output": 8192 },
|
||||
"experimental": {
|
||||
"modes": {
|
||||
"high": {
|
||||
"provider": {
|
||||
"headers": { "x-mode": "high" },
|
||||
"body": { "service_tier": "priority" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"anthropic": {
|
||||
"id": "anthropic",
|
||||
"name": "Anthropic",
|
||||
"env": ["ANTHROPIC_API_KEY"],
|
||||
"npm": "@ai-sdk/anthropic",
|
||||
"api": "https://api.anthropic.com/v1",
|
||||
"models": {
|
||||
"claude-budget": {
|
||||
"id": "claude-budget",
|
||||
"name": "Claude Budget",
|
||||
"release_date": "2026-01-01",
|
||||
"attachment": false,
|
||||
"reasoning": true,
|
||||
"reasoning_options": [{ "type": "budget_tokens", "min": 1024, "max": 64000 }],
|
||||
"temperature": true,
|
||||
"tool_call": true,
|
||||
"limit": { "context": 128000, "output": 8192 }
|
||||
},
|
||||
"claude-effort": {
|
||||
"id": "claude-effort",
|
||||
"name": "Claude Effort",
|
||||
"release_date": "2026-01-01",
|
||||
"attachment": false,
|
||||
"reasoning": true,
|
||||
"reasoning_options": [{ "type": "effort", "values": ["low"] }],
|
||||
"temperature": true,
|
||||
"tool_call": true,
|
||||
"limit": { "context": 128000, "output": 8192 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,10 @@ export function host(overrides: Overrides = {}): PluginContext {
|
||||
},
|
||||
tool: overrides.tool ?? {
|
||||
register: () => Effect.die("unused tool.register"),
|
||||
execute: {
|
||||
before: () => Effect.die("unused tool.execute.before"),
|
||||
after: () => Effect.die("unused tool.execute.after"),
|
||||
},
|
||||
},
|
||||
session: overrides.session ?? {
|
||||
create: () => Effect.die("unused session.create"),
|
||||
@@ -275,7 +279,11 @@ function agentInfo(value: AgentV2.Info) {
|
||||
return {
|
||||
...value,
|
||||
model: value.model && { ...value.model },
|
||||
request: { headers: { ...value.request.headers }, body: { ...value.request.body } },
|
||||
request: {
|
||||
settings: { ...value.request.settings },
|
||||
headers: { ...value.request.headers },
|
||||
body: { ...value.request.body },
|
||||
},
|
||||
permissions: value.permissions.map((permission) => ({ ...permission })),
|
||||
}
|
||||
}
|
||||
@@ -284,7 +292,11 @@ function providerInfo(value: ProviderV2.MutableInfo) {
|
||||
return {
|
||||
...value,
|
||||
api: { ...value.api, settings: value.api.settings && { ...value.api.settings } },
|
||||
request: { headers: { ...value.request.headers }, body: { ...value.request.body } },
|
||||
request: {
|
||||
settings: { ...value.request.settings },
|
||||
headers: { ...value.request.headers },
|
||||
body: { ...value.request.body },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,11 +311,13 @@ function modelInfo(value: ModelV2.Info | ModelV2.MutableInfo) {
|
||||
},
|
||||
request: {
|
||||
...value.request,
|
||||
settings: { ...value.request.settings },
|
||||
headers: { ...value.request.headers },
|
||||
body: { ...value.request.body },
|
||||
},
|
||||
variants: value.variants.map((variant) => ({
|
||||
...variant,
|
||||
settings: { ...variant.settings },
|
||||
headers: { ...variant.headers },
|
||||
body: { ...variant.body },
|
||||
})),
|
||||
|
||||
@@ -168,14 +168,14 @@ describe("ModelsDevPlugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("derives OpenAI reasoning variants from models.dev reasoning options", () =>
|
||||
it.effect("converts reasoning options into settings variants", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = {
|
||||
path: Flag.OPENCODE_MODELS_PATH,
|
||||
disabled: Flag.OPENCODE_DISABLE_MODELS_FETCH,
|
||||
}
|
||||
Flag.OPENCODE_MODELS_PATH = path.join(import.meta.dir, "fixtures", "models-dev.json")
|
||||
Flag.OPENCODE_MODELS_PATH = path.join(import.meta.dir, "fixtures", "models-dev-reasoning.json")
|
||||
Flag.OPENCODE_DISABLE_MODELS_FETCH = true
|
||||
return previous
|
||||
}),
|
||||
@@ -183,17 +183,6 @@ describe("ModelsDevPlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const integrations = yield* Integration.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.model.update(ProviderV2.ID.opencode, ModelV2.ID.make("gpt-5.5"), (model) => {
|
||||
model.variants = [
|
||||
{
|
||||
id: ModelV2.VariantID.make("high"),
|
||||
headers: { custom: "true" },
|
||||
body: { custom: true },
|
||||
},
|
||||
]
|
||||
})
|
||||
})
|
||||
yield* ModelsDevPlugin.effect(
|
||||
host({
|
||||
catalog: catalogHost(catalog),
|
||||
@@ -201,42 +190,67 @@ describe("ModelsDevPlugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("gpt-5.5")))?.variants).toEqual([
|
||||
{
|
||||
id: ModelV2.VariantID.make("none"),
|
||||
headers: {},
|
||||
body: {
|
||||
include: ["reasoning.encrypted_content"],
|
||||
reasoning: { effort: "none", summary: "auto" },
|
||||
},
|
||||
},
|
||||
expect.objectContaining({
|
||||
id: "low",
|
||||
body: {
|
||||
include: ["reasoning.encrypted_content"],
|
||||
reasoning: { effort: "low", summary: "auto" },
|
||||
},
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "medium",
|
||||
body: {
|
||||
include: ["reasoning.encrypted_content"],
|
||||
reasoning: { effort: "medium", summary: "auto" },
|
||||
},
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "high",
|
||||
headers: { custom: "true" },
|
||||
body: { custom: true },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "xhigh",
|
||||
body: {
|
||||
include: ["reasoning.encrypted_content"],
|
||||
reasoning: { effort: "xhigh", summary: "auto" },
|
||||
},
|
||||
}),
|
||||
const model = yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-reasoning"))
|
||||
expect(model?.variants.map((variant) => variant.id)).toEqual([
|
||||
ModelV2.VariantID.make("low"),
|
||||
ModelV2.VariantID.make("high"),
|
||||
])
|
||||
expect(model?.variants).toContainEqual({
|
||||
id: ModelV2.VariantID.make("low"),
|
||||
settings: {
|
||||
reasoningEffort: "low",
|
||||
reasoningSummary: "auto",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
},
|
||||
headers: {},
|
||||
body: {},
|
||||
})
|
||||
expect(model?.variants).toContainEqual({
|
||||
id: ModelV2.VariantID.make("high"),
|
||||
settings: {
|
||||
reasoningEffort: "high",
|
||||
reasoningSummary: "auto",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
},
|
||||
headers: {},
|
||||
body: {},
|
||||
})
|
||||
|
||||
const mode = yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-reasoning-high"))
|
||||
expect(mode).toMatchObject({
|
||||
id: "gpt-reasoning-high",
|
||||
name: "GPT Reasoning High",
|
||||
request: {
|
||||
headers: { "x-mode": "high" },
|
||||
body: { service_tier: "priority" },
|
||||
},
|
||||
})
|
||||
expect(mode?.variants.map((variant) => variant.id)).toEqual([
|
||||
ModelV2.VariantID.make("low"),
|
||||
ModelV2.VariantID.make("high"),
|
||||
])
|
||||
|
||||
const budgetModel = yield* catalog.model.get(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-budget"))
|
||||
expect(budgetModel?.variants).toContainEqual({
|
||||
id: ModelV2.VariantID.make("high"),
|
||||
settings: { thinking: { type: "enabled", budgetTokens: 16000 } },
|
||||
headers: {},
|
||||
body: {},
|
||||
})
|
||||
expect(budgetModel?.variants).toContainEqual({
|
||||
id: ModelV2.VariantID.make("max"),
|
||||
settings: { thinking: { type: "enabled", budgetTokens: 64000 } },
|
||||
headers: {},
|
||||
body: {},
|
||||
})
|
||||
|
||||
const anthropicEffortModel = yield* catalog.model.get(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-effort"))
|
||||
expect(anthropicEffortModel?.variants).toContainEqual({
|
||||
id: ModelV2.VariantID.make("low"),
|
||||
settings: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" },
|
||||
headers: {},
|
||||
body: {},
|
||||
})
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(ModelsDev.node))),
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
@@ -245,5 +259,4 @@ describe("ModelsDevPlugin", () => {
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
})
|
||||
|
||||
@@ -93,7 +93,7 @@ describe("AmazonBedrockPlugin", () => {
|
||||
})
|
||||
catalog.provider.update(bedrock.id, (item) => {
|
||||
item.api = bedrock.api
|
||||
item.request = bedrock.request
|
||||
item.request = { settings: {}, headers: {}, body: { endpoint: "https://bedrock.example" } }
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
||||
@@ -36,7 +36,7 @@ describe("AnthropicPlugin", () => {
|
||||
})
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.api = item.api
|
||||
draft.request = item.request
|
||||
draft.request = { settings: {}, headers: { Existing: "1" }, body: {} }
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
||||
@@ -87,7 +87,7 @@ describe("AzurePlugin", () => {
|
||||
})
|
||||
catalog.provider.update(azure.id, (item) => {
|
||||
item.api = azure.api
|
||||
item.request = azure.request
|
||||
item.request = { settings: {}, headers: {}, body: { resourceName: "from-config" } }
|
||||
})
|
||||
catalog.provider.update(ProviderV2.ID.openai, () => {})
|
||||
})
|
||||
@@ -110,7 +110,7 @@ describe("AzurePlugin", () => {
|
||||
})
|
||||
catalog.provider.update(azure.id, (item) => {
|
||||
item.api = azure.api
|
||||
item.request = azure.request
|
||||
item.request = { settings: {}, headers: {}, body: { resourceName: "" } }
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
@@ -131,7 +131,7 @@ describe("AzurePlugin", () => {
|
||||
})
|
||||
catalog.provider.update(azure.id, (item) => {
|
||||
item.api = azure.api
|
||||
item.request = azure.request
|
||||
item.request = { settings: {}, headers: {}, body: { resourceName: " " } }
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
||||
@@ -32,7 +32,7 @@ describe("KiloPlugin", () => {
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://api.kilo.ai/api/gateway",
|
||||
}
|
||||
provider.request = { headers: { Existing: "value" }, body: {} }
|
||||
provider.request = { settings: {}, headers: { Existing: "value" }, body: {} }
|
||||
})
|
||||
catalog.provider.update(ProviderV2.ID.openrouter, () => {})
|
||||
})
|
||||
|
||||
@@ -39,7 +39,7 @@ describe("LLMGatewayPlugin", () => {
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://api.llmgateway.io/v1",
|
||||
}
|
||||
provider.request = { headers: { Existing: "value" }, body: {} }
|
||||
provider.request = { settings: {}, headers: { Existing: "value" }, body: {} }
|
||||
})
|
||||
catalog.provider.update(ProviderV2.ID.openrouter, () => {})
|
||||
})
|
||||
|
||||
@@ -32,7 +32,7 @@ describe("NvidiaPlugin", () => {
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
url: "https://integrate.api.nvidia.com/v1",
|
||||
}
|
||||
provider.request = { headers: { Existing: "value" }, body: {} }
|
||||
provider.request = { settings: {}, headers: { Existing: "value" }, body: {} }
|
||||
})
|
||||
catalog.provider.update(ProviderV2.ID.openrouter, () => {})
|
||||
})
|
||||
@@ -80,6 +80,7 @@ describe("NvidiaPlugin", () => {
|
||||
url: "https://integrate.api.nvidia.com/v1",
|
||||
}
|
||||
provider.request = {
|
||||
settings: {},
|
||||
headers: { "X-BILLING-INVOKE-ORIGIN": "CustomOrigin" },
|
||||
body: { baseURL: "https://integrate.api.nvidia.com/v1" },
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect } from "bun:test"
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
@@ -27,6 +28,20 @@ function required<T>(value: T | undefined): T {
|
||||
return value
|
||||
}
|
||||
|
||||
function eventually<A>(
|
||||
effect: Effect.Effect<A>,
|
||||
predicate: (value: A) => boolean,
|
||||
remaining = 1000,
|
||||
): Effect.Effect<A, Error> {
|
||||
return Effect.gen(function* () {
|
||||
const value = yield* effect
|
||||
if (predicate(value)) return value
|
||||
if (remaining === 0) return yield* Effect.fail(new Error("Timed out waiting for value"))
|
||||
yield* Effect.promise(() => Bun.sleep(1))
|
||||
return yield* eventually(effect, predicate, remaining - 1)
|
||||
})
|
||||
}
|
||||
|
||||
function fakeSelectorSdk(calls: string[]) {
|
||||
const make = (method: string) => (id: string) => {
|
||||
calls.push(`${method}:${id}`)
|
||||
@@ -153,6 +168,80 @@ describe("OpenAIPlugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("filters the OpenAI catalog to codex-eligible models under a ChatGPT connection", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const credentials = yield* Credential.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
const item = ProviderV2.Info.make({
|
||||
...ProviderV2.Info.empty(ProviderV2.ID.openai),
|
||||
api: { type: "aisdk", package: "@ai-sdk/openai" },
|
||||
})
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.api = item.api
|
||||
})
|
||||
catalog.model.update(item.id, ModelV2.ID.make("gpt-5.5"), (model) => {
|
||||
model.cost = [{ input: 1, output: 2, cache: { read: 0.1, write: 0 } }]
|
||||
})
|
||||
catalog.model.update(item.id, ModelV2.ID.make("gpt-5.5-pro"), () => {})
|
||||
catalog.model.update(item.id, ModelV2.ID.make("gpt-4.1"), () => {})
|
||||
})
|
||||
yield* credentials.create({
|
||||
integrationID: Integration.ID.make("openai"),
|
||||
value: Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: Integration.MethodID.make("chatgpt-browser"),
|
||||
access: "chatgpt-token",
|
||||
refresh: "refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
metadata: { accountID: "acct_123" },
|
||||
}),
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
||||
const eligible = required(
|
||||
yield* eventually(
|
||||
catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.5")),
|
||||
(model) => model?.cost.length === 0,
|
||||
),
|
||||
)
|
||||
expect(eligible.enabled).toBe(true)
|
||||
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.5-pro"))).enabled).toBe(
|
||||
false,
|
||||
)
|
||||
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-4.1"))).enabled).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps the full OpenAI catalog under an API key connection", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const credentials = yield* Credential.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
const item = ProviderV2.Info.make({
|
||||
...ProviderV2.Info.empty(ProviderV2.ID.openai),
|
||||
api: { type: "aisdk", package: "@ai-sdk/openai" },
|
||||
})
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.api = item.api
|
||||
})
|
||||
catalog.model.update(item.id, ModelV2.ID.make("gpt-5.5"), () => {})
|
||||
catalog.model.update(item.id, ModelV2.ID.make("gpt-4.1"), () => {})
|
||||
})
|
||||
yield* credentials.create({
|
||||
integrationID: Integration.ID.make("openai"),
|
||||
value: Credential.Key.make({ type: "key", key: "sk-test" }),
|
||||
})
|
||||
yield* addPlugin()
|
||||
// The connection refresh is asynchronous; give it time to settle before
|
||||
// asserting nothing was filtered.
|
||||
yield* Effect.promise(() => Bun.sleep(25))
|
||||
|
||||
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.5"))).enabled).toBe(true)
|
||||
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-4.1"))).enabled).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not disable gpt-5-chat-latest for non-OpenAI providers", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
|
||||
@@ -142,6 +142,7 @@ describe("OpencodePlugin", () => {
|
||||
model.variants = [
|
||||
{
|
||||
id: ModelV2.VariantID.make("custom"),
|
||||
settings: {},
|
||||
headers: { "x-custom": "true" },
|
||||
body: { custom: true },
|
||||
},
|
||||
@@ -177,7 +178,7 @@ describe("OpencodePlugin", () => {
|
||||
url: `${server.url.origin}/v1`,
|
||||
},
|
||||
})
|
||||
expect(provider.request).toEqual({ headers: { "x-org-id": "org" }, body: { custom: "value" } })
|
||||
expect(provider.request).toEqual({ settings: {}, headers: { "x-org-id": "org" }, body: { custom: "value" } })
|
||||
expect(yield* (yield* Integration.Service).get(Integration.ID.make("remote"))).toBeUndefined()
|
||||
|
||||
const model = required(yield* catalog.model.get(ProviderV2.ID.make("remote"), ModelV2.ID.make("model")))
|
||||
@@ -192,11 +193,13 @@ describe("OpencodePlugin", () => {
|
||||
expect(model.variants).toEqual([
|
||||
{
|
||||
id: ModelV2.VariantID.make("custom"),
|
||||
settings: {},
|
||||
headers: { "x-custom": "true" },
|
||||
body: { custom: true },
|
||||
},
|
||||
{
|
||||
id: ModelV2.VariantID.make("high"),
|
||||
settings: {},
|
||||
headers: {},
|
||||
body: { temperature: 0.2 },
|
||||
},
|
||||
@@ -359,6 +362,7 @@ describe("OpencodePlugin", () => {
|
||||
...ProviderV2.Info.empty(ProviderV2.ID.opencode),
|
||||
api: { type: "aisdk", package: "test-provider" },
|
||||
request: {
|
||||
settings: {},
|
||||
headers: {},
|
||||
body: { apiKey: "configured" },
|
||||
},
|
||||
@@ -369,7 +373,7 @@ describe("OpencodePlugin", () => {
|
||||
cost: cost(1),
|
||||
})
|
||||
catalog.provider.update(provider.id, (draft) => {
|
||||
draft.request = provider.request
|
||||
draft.request = { settings: {}, headers: {}, body: { apiKey: "configured" } }
|
||||
})
|
||||
catalog.model.update(provider.id, model.id, (draft) => {
|
||||
draft.cost = [...model.cost]
|
||||
|
||||
@@ -31,7 +31,7 @@ describe("OpenRouterPlugin", () => {
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(ProviderV2.ID.openrouter, (provider) => {
|
||||
provider.api = { type: "aisdk", package: "@openrouter/ai-sdk-provider" }
|
||||
provider.request = { headers: { Existing: "value" }, body: {} }
|
||||
provider.request = { settings: {}, headers: { Existing: "value" }, body: {} }
|
||||
})
|
||||
catalog.provider.update(ProviderV2.ID.make("nvidia"), () => {})
|
||||
})
|
||||
|
||||
@@ -37,8 +37,8 @@ describe("VariantPlugin", () => {
|
||||
yield* VariantPlugin.Plugin.effect(host({ catalog: catalogHost(service) }))
|
||||
|
||||
expect((yield* service.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("glm-5.2")))?.variants).toEqual([
|
||||
expect.objectContaining({ id: "high", body: { reasoning_effort: "high" } }),
|
||||
expect.objectContaining({ id: "max", body: { reasoning_effort: "max" } }),
|
||||
expect.objectContaining({ id: "high", settings: { reasoningEffort: "high" } }),
|
||||
expect.objectContaining({ id: "max", settings: { reasoningEffort: "max" } }),
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -53,14 +53,14 @@ describe("VariantPlugin", () => {
|
||||
type: "aisdk",
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
}
|
||||
model.variants = [{ id: ModelV2.VariantID.make("high"), headers: { custom: "true" }, body: {} }]
|
||||
model.variants = [{ id: ModelV2.VariantID.make("high"), settings: {}, headers: { custom: "true" }, body: {} }]
|
||||
})
|
||||
})
|
||||
yield* VariantPlugin.Plugin.effect(host({ catalog: catalogHost(service) }))
|
||||
|
||||
expect((yield* service.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("glm-5.2")))?.variants).toEqual([
|
||||
expect.objectContaining({ id: "high", headers: { custom: "true" } }),
|
||||
expect.objectContaining({ id: "max", body: { reasoning_effort: "max" } }),
|
||||
expect.objectContaining({ id: "max", settings: { reasoningEffort: "max" } }),
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -16,10 +16,10 @@ describe("ReferenceGuidance", () => {
|
||||
const guidance = yield* ReferenceGuidance.Service
|
||||
const generation = yield* SystemContext.initialize(yield* guidance.load())
|
||||
|
||||
expect(generation.baseline).toContain("<available_references>")
|
||||
expect(generation.baseline).toContain("<name>docs</name>")
|
||||
expect(generation.baseline).toContain("<path>/docs</path>")
|
||||
expect(generation.baseline).toContain("<description>Use for product documentation</description>")
|
||||
expect(generation.text).toContain("<available_references>")
|
||||
expect(generation.text).toContain("<name>docs</name>")
|
||||
expect(generation.text).toContain("<path>/docs</path>")
|
||||
expect(generation.text).toContain("<description>Use for product documentation</description>")
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
guidanceLayer(
|
||||
@@ -47,7 +47,7 @@ describe("ReferenceGuidance", () => {
|
||||
Effect.gen(function* () {
|
||||
const guidance = yield* ReferenceGuidance.Service
|
||||
const generation = yield* SystemContext.initialize(yield* guidance.load())
|
||||
expect(generation.baseline).toBe("")
|
||||
expect(generation.text).toBe("")
|
||||
}).pipe(Effect.provide(guidanceLayer(Layer.mock(Reference.Service, { list: () => Effect.succeed([]) })))),
|
||||
)
|
||||
|
||||
@@ -55,7 +55,7 @@ describe("ReferenceGuidance", () => {
|
||||
Effect.gen(function* () {
|
||||
const guidance = yield* ReferenceGuidance.Service
|
||||
const generation = yield* SystemContext.initialize(yield* guidance.load())
|
||||
expect(generation.baseline).toBe("")
|
||||
expect(generation.text).toBe("")
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
guidanceLayer(
|
||||
@@ -73,4 +73,41 @@ describe("ReferenceGuidance", () => {
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("announces added and removed references as deltas", () => {
|
||||
const reference = (name: string, description: string) =>
|
||||
new Reference.Info({
|
||||
name,
|
||||
path: AbsolutePath.make(`/${name}`),
|
||||
description,
|
||||
source: Reference.LocalSource.make({ type: "local", path: AbsolutePath.make(`/${name}`), description }),
|
||||
})
|
||||
let references = [reference("docs", "Use for product documentation")]
|
||||
return Effect.gen(function* () {
|
||||
const guidance = yield* ReferenceGuidance.Service
|
||||
const initialized = yield* SystemContext.initialize(yield* guidance.load())
|
||||
|
||||
references = [reference("docs", "Use for product documentation"), reference("examples", "Use for examples")]
|
||||
const added = yield* SystemContext.reconcile(yield* guidance.load(), initialized.applied)
|
||||
expect(added).toMatchObject({
|
||||
_tag: "Updated",
|
||||
text: [
|
||||
"New project references are available in addition to those previously listed:",
|
||||
" <reference>",
|
||||
" <name>examples</name>",
|
||||
" <path>/examples</path>",
|
||||
" <description>Use for examples</description>",
|
||||
" </reference>",
|
||||
].join("\n"),
|
||||
})
|
||||
|
||||
references = [reference("examples", "Use for examples")]
|
||||
expect(
|
||||
yield* SystemContext.reconcile(yield* guidance.load(), added._tag === "Updated" ? added.applied : {}),
|
||||
).toMatchObject({
|
||||
_tag: "Updated",
|
||||
text: "The following project references are no longer available and must not be used: docs.",
|
||||
})
|
||||
}).pipe(Effect.provide(guidanceLayer(Layer.mock(Reference.Service, { list: () => Effect.succeed(references) }))))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -81,11 +81,20 @@ describe("SessionV2.compact", () => {
|
||||
const events = yield* EventV2.Service
|
||||
const created = yield* session.create({ location })
|
||||
|
||||
const messageID = SessionMessage.ID.create()
|
||||
const prompt = Prompt.make({ text: "Please compact this session history." })
|
||||
yield* events.publish(SessionEvent.PromptAdmitted, {
|
||||
sessionID: created.id,
|
||||
messageID,
|
||||
timestamp: DateTime.makeUnsafe(0),
|
||||
prompt,
|
||||
delivery: "steer",
|
||||
})
|
||||
yield* events.publish(SessionEvent.Prompted, {
|
||||
sessionID: created.id,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
messageID,
|
||||
timestamp: DateTime.makeUnsafe(0),
|
||||
prompt: Prompt.make({ text: "Please compact this session history." }),
|
||||
prompt,
|
||||
delivery: "steer",
|
||||
})
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ describe("SessionV2.create", () => {
|
||||
prompt: Prompt.make({ text: "First" }),
|
||||
resume: false,
|
||||
})
|
||||
yield* SessionInput.promoteSteers(db, events, parent.id, Number.MAX_SAFE_INTEGER)
|
||||
yield* SessionInput.promoteSteers(db, events, parent.id)
|
||||
yield* events.publish(SessionEvent.Synthetic, {
|
||||
sessionID: parent.id,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
@@ -167,9 +167,9 @@ describe("SessionV2.create", () => {
|
||||
})
|
||||
|
||||
yield* session.prompt({ sessionID: parent.id, prompt: Prompt.make({ text: "Parent changed" }), resume: false })
|
||||
yield* SessionInput.promoteSteers(db, events, parent.id, Number.MAX_SAFE_INTEGER)
|
||||
yield* SessionInput.promoteSteers(db, events, parent.id)
|
||||
yield* session.prompt({ sessionID: forked.id, prompt: Prompt.make({ text: "Child continues" }), resume: false })
|
||||
yield* SessionInput.promoteSteers(db, events, forked.id, Number.MAX_SAFE_INTEGER)
|
||||
yield* SessionInput.promoteSteers(db, events, forked.id)
|
||||
|
||||
expect((yield* session.context(parent.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"])
|
||||
expect((yield* session.context(forked.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"])
|
||||
@@ -192,13 +192,13 @@ describe("SessionV2.create", () => {
|
||||
prompt: Prompt.make({ text: "First" }),
|
||||
resume: false,
|
||||
})
|
||||
yield* SessionInput.promoteSteers(db, events, parent.id, Number.MAX_SAFE_INTEGER)
|
||||
yield* SessionInput.promoteSteers(db, events, parent.id)
|
||||
const second = yield* session.prompt({
|
||||
sessionID: parent.id,
|
||||
prompt: Prompt.make({ text: "Second" }),
|
||||
resume: false,
|
||||
})
|
||||
yield* SessionInput.promoteSteers(db, events, parent.id, Number.MAX_SAFE_INTEGER)
|
||||
yield* SessionInput.promoteSteers(db, events, parent.id)
|
||||
|
||||
const forked = yield* session.fork({ sessionID: parent.id, messageID: second.id })
|
||||
|
||||
@@ -314,7 +314,7 @@ describe("SessionV2.create", () => {
|
||||
const { db } = yield* Database.Service
|
||||
const created = yield* session.create({ location })
|
||||
yield* session.prompt({ sessionID: created.id, prompt: Prompt.make({ text: "Hello" }), resume: false })
|
||||
yield* SessionInput.promoteSteers(db, events, created.id, Number.MAX_SAFE_INTEGER)
|
||||
yield* SessionInput.promoteSteers(db, events, created.id)
|
||||
|
||||
expect(
|
||||
Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(2), Stream.runCollect)),
|
||||
@@ -336,7 +336,7 @@ describe("SessionV2.create", () => {
|
||||
prompt: Prompt.make({ text: "Replay lifecycle" }),
|
||||
resume: false,
|
||||
})
|
||||
yield* SessionInput.promoteSteers(sourceDb, sourceEvents, created.id, Number.MAX_SAFE_INTEGER)
|
||||
yield* SessionInput.promoteSteers(sourceDb, sourceEvents, created.id)
|
||||
const serialized = (yield* sourceDb
|
||||
.select()
|
||||
.from(EventTable)
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { DateTime, Effect, Layer } from "effect"
|
||||
import { Message, Model } from "@opencode-ai/llm"
|
||||
import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Image } from "@opencode-ai/core/image"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ReadTool } from "@opencode-ai/core/tool/read"
|
||||
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionInstructions } from "@opencode-ai/core/session/instructions"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message"
|
||||
import { ToolHooks } from "@opencode-ai/core/tool/hooks"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { tempLocationLayer } from "./fixture/location"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { settleTool, testModel } from "./lib/tool"
|
||||
|
||||
const projects = Layer.succeed(
|
||||
ProjectV2.Service,
|
||||
ProjectV2.Service.of({
|
||||
resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
commit: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
const permission = Layer.succeed(
|
||||
PermissionV2.Service,
|
||||
PermissionV2.Service.of({
|
||||
assert: () => Effect.void,
|
||||
ask: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
get: () => Effect.die("unused"),
|
||||
forSession: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))
|
||||
const imageLayer = AppNodeBuilder.build(Image.node, [[Config.node, config]])
|
||||
|
||||
const testLayer = AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
Database.node,
|
||||
EventV2.node,
|
||||
SessionProjector.node,
|
||||
SessionStore.node,
|
||||
SessionV2.node,
|
||||
Location.node,
|
||||
FSUtil.node,
|
||||
LocationMutation.node,
|
||||
ReadToolFileSystem.node,
|
||||
ReadTool.node,
|
||||
ToolRegistry.node,
|
||||
ToolRegistry.toolsNode,
|
||||
ToolHooks.node,
|
||||
SessionInstructions.node,
|
||||
Global.node,
|
||||
ToolOutputStore.node,
|
||||
Image.node,
|
||||
]),
|
||||
[
|
||||
[ProjectV2.node, projects],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
[Location.node, tempLocationLayer],
|
||||
[PermissionV2.node, permission],
|
||||
[Config.node, config],
|
||||
[Image.node, imageLayer],
|
||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||
],
|
||||
) as unknown as Layer.Layer<unknown>
|
||||
|
||||
const it = testEffect(testLayer)
|
||||
|
||||
const identity = {
|
||||
agent: AgentV2.ID.make("build"),
|
||||
assistantMessageID: SessionMessage.ID.make("msg_nearby"),
|
||||
}
|
||||
const readCall = (sessionID: SessionV2.ID, id: string, readPath: string): ToolRegistry.ExecuteInput => ({
|
||||
sessionID,
|
||||
...identity,
|
||||
call: { type: "tool-call", id, name: "read", input: { path: readPath } },
|
||||
})
|
||||
|
||||
const writeAgents = (file: string, content: string) => Effect.promise(() => fs.writeFile(file, content))
|
||||
const mkdir = (dir: string) => Effect.promise(() => fs.mkdir(dir, { recursive: true }))
|
||||
|
||||
const synthetics = (sessionID: SessionV2.ID) =>
|
||||
Effect.gen(function* () {
|
||||
const store = yield* SessionStore.Service
|
||||
return (yield* store.context(sessionID)).filter((message) => message.type === "synthetic")
|
||||
})
|
||||
|
||||
// Seed a prior synthetic message with an instruction dedup ledger, simulating a prior turn
|
||||
// after the Location layer was reopened (in-memory set empty).
|
||||
const seedSynthetic = (sessionID: SessionV2.ID, paths: string[]) =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
yield* events.publish(SessionEvent.Synthetic, {
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: yield* DateTime.now,
|
||||
text: `Instructions from: ${paths[0]}\nprior`,
|
||||
description: `Loaded ${paths[0]}`,
|
||||
metadata: { instruction: { paths } },
|
||||
})
|
||||
})
|
||||
|
||||
describe("SessionInstructions", () => {
|
||||
it.effect("injects AGENTS.md files above a read, excludes the Location root, and dedups across reads", () =>
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const dir = location.directory
|
||||
const rootPath = path.resolve(dir, "AGENTS.md")
|
||||
const subPath = path.resolve(dir, "sub", "AGENTS.md")
|
||||
const deepPath = path.resolve(dir, "sub", "deep", "AGENTS.md")
|
||||
const otherPath = path.resolve(dir, "sub", "other", "AGENTS.md")
|
||||
yield* mkdir(path.dirname(deepPath))
|
||||
yield* mkdir(path.dirname(otherPath))
|
||||
yield* writeAgents(rootPath, "root-instructions")
|
||||
yield* writeAgents(subPath, "sub-instructions")
|
||||
yield* writeAgents(deepPath, "deep-instructions")
|
||||
yield* writeAgents(otherPath, "other-instructions")
|
||||
yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "sub", "deep", "file.txt"), "file content"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "sub", "other", "file2.txt"), "file content 2"))
|
||||
|
||||
const session = yield* SessionV2.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id
|
||||
|
||||
// A read deep under sub/ discovers deep and sub AGENTS.md, walking up to but
|
||||
// excluding the Location root (already supplied by the core/instructions baseline).
|
||||
yield* settleTool(registry, readCall(sessionID, "call-deep", "sub/deep/file.txt"))
|
||||
|
||||
const firstInjected = yield* synthetics(sessionID)
|
||||
expect(firstInjected).toHaveLength(1)
|
||||
expect(firstInjected[0]!.text).toBe(
|
||||
`Instructions from: ${deepPath}\ndeep-instructions\n\nInstructions from: ${subPath}\nsub-instructions`,
|
||||
)
|
||||
expect(firstInjected[0]!.description).toBe(`Loaded ${path.relative(dir, deepPath)}, ${path.relative(dir, subPath)}`)
|
||||
// The synthetic's metadata carries the durable dedup ledger.
|
||||
expect(firstInjected[0]!.metadata).toEqual({ instruction: { paths: [deepPath, subPath] } })
|
||||
expect(firstInjected[0]!.text).not.toContain("root-instructions")
|
||||
|
||||
// A sibling read under sub/other discovers only the new AGENTS.md; sub is already
|
||||
// injected for this session so it is not re-emitted, and the root is still excluded.
|
||||
yield* settleTool(registry, readCall(sessionID, "call-other", "sub/other/file2.txt"))
|
||||
|
||||
const secondInjected = yield* synthetics(sessionID)
|
||||
expect(secondInjected).toHaveLength(2)
|
||||
expect(secondInjected[1]!.text).toBe(`Instructions from: ${otherPath}\nother-instructions`)
|
||||
expect(secondInjected[1]!.description).toBe(`Loaded ${path.relative(dir, otherPath)}`)
|
||||
expect(secondInjected[1]!.metadata).toEqual({ instruction: { paths: [otherPath] } })
|
||||
expect(secondInjected.some((message) => message.text.includes("root-instructions"))).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not re-inject paths already recorded in durable session history", () =>
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const dir = location.directory
|
||||
const rootPath = path.resolve(dir, "AGENTS.md")
|
||||
const subPath = path.resolve(dir, "sub", "AGENTS.md")
|
||||
yield* mkdir(path.resolve(dir, "sub"))
|
||||
yield* writeAgents(rootPath, "root-instructions")
|
||||
yield* writeAgents(subPath, "sub-instructions")
|
||||
yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "sub", "file.txt"), "content"))
|
||||
|
||||
const session = yield* SessionV2.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id
|
||||
|
||||
// Seed the durable history with a prior synthetic that already claims sub's AGENTS.md
|
||||
// via the instruction metadata ledger.
|
||||
yield* seedSynthetic(sessionID, [subPath])
|
||||
expect((yield* synthetics(sessionID))).toHaveLength(1)
|
||||
|
||||
yield* settleTool(registry, readCall(sessionID, "call-sub", "sub/file.txt"))
|
||||
|
||||
// The durable claim on the prior synthetic prevents re-injection; no new synthetic.
|
||||
expect((yield* synthetics(sessionID))).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("discovers AGENTS.md on a directory listing, including the listed directory's own, and dedups with a later file read", () =>
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const dir = location.directory
|
||||
const rootPath = path.resolve(dir, "AGENTS.md")
|
||||
const pkgPath = path.resolve(dir, "packages", "foo", "AGENTS.md")
|
||||
yield* mkdir(path.resolve(dir, "packages", "foo"))
|
||||
yield* writeAgents(rootPath, "root-instructions")
|
||||
yield* writeAgents(pkgPath, "pkg-instructions")
|
||||
yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "packages", "foo", "file.txt"), "content"))
|
||||
|
||||
const session = yield* SessionV2.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id
|
||||
|
||||
// Listing packages/foo/ discovers its own AGENTS.md, walking up to but excluding
|
||||
// the Location root (already supplied by the core/instructions baseline).
|
||||
yield* settleTool(registry, readCall(sessionID, "call-list", "packages/foo"))
|
||||
|
||||
const firstInjected = yield* synthetics(sessionID)
|
||||
expect(firstInjected).toHaveLength(1)
|
||||
expect(firstInjected[0]!.text).toBe(`Instructions from: ${pkgPath}\npkg-instructions`)
|
||||
expect(firstInjected[0]!.description).toBe(`Loaded ${path.relative(dir, pkgPath)}`)
|
||||
expect(firstInjected[0]!.metadata).toEqual({ instruction: { paths: [pkgPath] } })
|
||||
expect(firstInjected[0]!.text).not.toContain("root-instructions")
|
||||
|
||||
// A subsequent file read under the listed directory is a dedup: pkg's AGENTS.md is
|
||||
// already injected for this session, so nothing new is emitted.
|
||||
yield* settleTool(registry, readCall(sessionID, "call-file", "packages/foo/file.txt"))
|
||||
|
||||
expect((yield* synthetics(sessionID))).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("listing the Location root directory injects no instructions", () =>
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const dir = location.directory
|
||||
const rootPath = path.resolve(dir, "AGENTS.md")
|
||||
const subPath = path.resolve(dir, "sub", "AGENTS.md")
|
||||
yield* mkdir(path.resolve(dir, "sub"))
|
||||
yield* writeAgents(rootPath, "root-instructions")
|
||||
yield* writeAgents(subPath, "sub-instructions")
|
||||
|
||||
const session = yield* SessionV2.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id
|
||||
|
||||
// The walk starts and stops at the Location root: the root AGENTS.md is searched but
|
||||
// dropped by the dirname filter, and up() only walks upward so nested dirs are unseen.
|
||||
yield* settleTool(registry, readCall(sessionID, "call-root-list", "."))
|
||||
|
||||
expect((yield* synthetics(sessionID))).toHaveLength(0)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("loads instructions directly without a read", () =>
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const dir = location.directory
|
||||
const subPath = path.resolve(dir, "sub", "AGENTS.md")
|
||||
yield* mkdir(path.resolve(dir, "sub"))
|
||||
yield* writeAgents(subPath, "sub-instructions")
|
||||
|
||||
const session = yield* SessionV2.Service
|
||||
const sessionInstructions = yield* SessionInstructions.Service
|
||||
const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id
|
||||
|
||||
yield* sessionInstructions.load({ sessionID, paths: [subPath] })
|
||||
|
||||
const injected = yield* synthetics(sessionID)
|
||||
expect(injected).toHaveLength(1)
|
||||
expect(injected[0]!.text).toBe(`Instructions from: ${subPath}\nsub-instructions`)
|
||||
expect(injected[0]!.description).toBe(`Loaded ${path.relative(dir, subPath)}`)
|
||||
expect(injected[0]!.metadata).toEqual({ instruction: { paths: [subPath] } })
|
||||
}),
|
||||
)
|
||||
|
||||
test("toLLMMessages does not forward synthetic metadata to the provider", () => {
|
||||
const created = DateTime.makeUnsafe(0)
|
||||
const model = Model.make({ id: "model", provider: "provider", route: OpenAIChat.route })
|
||||
const synthetic = SessionMessage.Synthetic.make({
|
||||
id: SessionMessage.ID.make("msg_synthetic"),
|
||||
type: "synthetic",
|
||||
sessionID: SessionV2.ID.make("ses_test"),
|
||||
text: "Instructions from: /repo/sub/AGENTS.md\ncontent",
|
||||
description: "Loaded /repo/sub/AGENTS.md",
|
||||
metadata: { instruction: { paths: ["/repo/sub/AGENTS.md"] } },
|
||||
time: { created },
|
||||
})
|
||||
const messages = toLLMMessages([synthetic], model)
|
||||
expect(messages).toHaveLength(1)
|
||||
expect(messages[0]!.role).toBe("user")
|
||||
expect(messages[0]!.content).toEqual([{ type: "text", text: "Instructions from: /repo/sub/AGENTS.md\ncontent" }])
|
||||
// Metadata is bookkeeping for the dedup ledger; the model must not see it.
|
||||
expect(messages[0]!.metadata).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -19,7 +19,12 @@ import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionInput } from "@opencode-ai/core/session/input"
|
||||
import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import {
|
||||
SessionContextCheckpointTable,
|
||||
SessionInputTable,
|
||||
SessionMessageTable,
|
||||
SessionTable,
|
||||
} from "@opencode-ai/core/session/sql"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||
|
||||
@@ -67,6 +72,10 @@ describe("SessionProjector", () => {
|
||||
.insert(SessionMessageTable)
|
||||
.values([assistantRow(boundary, 1), assistantRow(SessionMessage.ID.make("msg_later"), 2)])
|
||||
.run()
|
||||
yield* db
|
||||
.insert(SessionContextCheckpointTable)
|
||||
.values({ session_id: sessionID, baseline: "baseline", snapshot: {}, baseline_seq: 0 })
|
||||
.run()
|
||||
const events = yield* EventV2.Service
|
||||
yield* events.publish(SessionEvent.RevertEvent.Staged, {
|
||||
sessionID,
|
||||
@@ -93,6 +102,8 @@ describe("SessionProjector", () => {
|
||||
expect(
|
||||
(yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all()).map((row) => row.id),
|
||||
).toEqual([boundary])
|
||||
// A committed revert resets the context checkpoint so the next turn re-initializes.
|
||||
expect(yield* db.select().from(SessionContextCheckpointTable).get().pipe(Effect.orDie)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -118,6 +129,13 @@ describe("SessionProjector", () => {
|
||||
.pipe(Effect.orDie)
|
||||
const events = yield* EventV2.Service
|
||||
|
||||
yield* events.publish(SessionEvent.PromptAdmitted, {
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.make("msg_first"),
|
||||
timestamp: created,
|
||||
prompt: Prompt.make({ text: "first" }),
|
||||
delivery: "steer",
|
||||
})
|
||||
yield* events.publish(
|
||||
SessionEvent.Prompted,
|
||||
{
|
||||
@@ -129,6 +147,13 @@ describe("SessionProjector", () => {
|
||||
},
|
||||
{ id: EventV2.ID.make("evt_z") },
|
||||
)
|
||||
yield* events.publish(SessionEvent.PromptAdmitted, {
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.make("msg_second"),
|
||||
timestamp: created,
|
||||
prompt: Prompt.make({ text: "second" }),
|
||||
delivery: "steer",
|
||||
})
|
||||
yield* events.publish(
|
||||
SessionEvent.Prompted,
|
||||
{
|
||||
@@ -248,6 +273,7 @@ describe("SessionProjector", () => {
|
||||
messageID: SessionMessage.ID.create(),
|
||||
timestamp: created,
|
||||
text: "synthetic context",
|
||||
metadata: { source: "projector-test" },
|
||||
})
|
||||
yield* events.publish(SessionEvent.Shell.Started, {
|
||||
sessionID,
|
||||
@@ -318,6 +344,10 @@ describe("SessionProjector", () => {
|
||||
"shell",
|
||||
"compaction",
|
||||
])
|
||||
expect(messages.find((message) => message.type === "synthetic")).toMatchObject({
|
||||
text: "synthetic context",
|
||||
metadata: { source: "projector-test" },
|
||||
})
|
||||
expect(messages.find((message) => message.type === "shell")).toMatchObject({
|
||||
output: "/project",
|
||||
time: { completed: DateTime.makeUnsafe(1) },
|
||||
|
||||
@@ -197,7 +197,7 @@ describe("SessionV2.prompt", () => {
|
||||
prompt: Prompt.make({ text: "boundary" }),
|
||||
resume: false,
|
||||
})
|
||||
yield* SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER)
|
||||
yield* SessionInput.promoteSteers(db, events, sessionID)
|
||||
const stale = SessionMessage.ID.make("msg_stale_assistant")
|
||||
yield* db.insert(SessionMessageTable).values(assistantRow(stale, 100)).run().pipe(Effect.orDie)
|
||||
yield* events.publish(SessionEvent.RevertEvent.Staged, {
|
||||
@@ -250,7 +250,7 @@ describe("SessionV2.prompt", () => {
|
||||
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
|
||||
yield* SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER)
|
||||
yield* SessionInput.promoteSteers(db, events, sessionID)
|
||||
const streamed = Array.from(yield* Fiber.join(fiber))
|
||||
|
||||
expect(streamed.map((event) => [event.durable?.seq, event.type])).toEqual([
|
||||
@@ -424,10 +424,7 @@ describe("SessionV2.prompt", () => {
|
||||
yield* session.prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "Promote once" }), resume: false })
|
||||
|
||||
yield* Effect.all(
|
||||
[
|
||||
SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER),
|
||||
SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER),
|
||||
],
|
||||
[SessionInput.promoteSteers(db, events, sessionID), SessionInput.promoteSteers(db, events, sessionID)],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
@@ -439,23 +436,6 @@ describe("SessionV2.prompt", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("promotes steers only through the captured inbox cutoff", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const { db } = yield* Database.Service
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const first = yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Before cutoff" }), resume: false })
|
||||
const cutoff = first.admittedSeq
|
||||
const second = yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "After cutoff" }), resume: false })
|
||||
|
||||
yield* SessionInput.promoteSteers(db, events, sessionID, cutoff)
|
||||
|
||||
expect(yield* admitted(first.id)).toHaveProperty("promotedSeq")
|
||||
expect(yield* admitted(second.id)).not.toHaveProperty("promotedSeq")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reprojects pending inbox input without scheduling execution", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
@@ -499,48 +479,6 @@ describe("SessionV2.prompt", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("returns an exact retry of a legacy projected prompt", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const prompt = Prompt.make({ text: "Historical prompt" })
|
||||
yield* events.publish(SessionEvent.Prompted, {
|
||||
sessionID,
|
||||
messageID,
|
||||
timestamp: yield* DateTime.now,
|
||||
prompt,
|
||||
delivery: "steer",
|
||||
})
|
||||
|
||||
const retried = yield* session.prompt({ id: messageID, sessionID, prompt, resume: false })
|
||||
|
||||
expect(retried).toMatchObject({ id: messageID, prompt: { text: "Historical prompt" } })
|
||||
expect(yield* admitted(messageID)).toHaveProperty("promotedSeq")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("returns an exact retry of a legacy projected queued prompt", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const prompt = Prompt.make({ text: "Historical queued prompt" })
|
||||
yield* events.publish(SessionEvent.Prompted, {
|
||||
sessionID,
|
||||
messageID,
|
||||
timestamp: yield* DateTime.now,
|
||||
prompt,
|
||||
delivery: "queue",
|
||||
})
|
||||
|
||||
const retried = yield* session.prompt({ id: messageID, sessionID, prompt, delivery: "queue", resume: false })
|
||||
|
||||
expect(retried).toMatchObject({ id: messageID, prompt: { text: "Historical queued prompt" } })
|
||||
expect(yield* admitted(messageID)).toMatchObject({ delivery: "queue" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects reuse of one globally unique message ID across sessions", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
|
||||
@@ -30,6 +30,7 @@ const model = (api: Api, variants: ModelV2.Info["variants"] = []) =>
|
||||
api: { id: ModelV2.ID.make("api-test-model"), ...api },
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
request: {
|
||||
settings: {},
|
||||
headers: { "x-test": "header" },
|
||||
body: { apiKey: "secret", custom_extension: { enabled: true } },
|
||||
},
|
||||
@@ -83,7 +84,7 @@ describe("SessionRunnerModel", () => {
|
||||
url: "https://compatible.example/v1",
|
||||
settings: { apiKey: "settings-secret", compatibility: "strict" },
|
||||
}),
|
||||
request: { headers: {}, body: {} },
|
||||
request: { settings: {}, headers: {}, body: {} },
|
||||
}),
|
||||
)
|
||||
const request = LLM.request({ model: resolved, prompt: "Hello" })
|
||||
@@ -100,17 +101,17 @@ describe("SessionRunnerModel", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("overlays selected OpenAI Session variant bodies", () =>
|
||||
it.effect("overlays selected OpenAI Session variant settings and bodies", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }, [
|
||||
{
|
||||
id: ModelV2.VariantID.make("high"),
|
||||
settings: { reasoningEffort: "high" },
|
||||
headers: { "x-variant": "high" },
|
||||
body: {
|
||||
store: false,
|
||||
service_tier: "priority",
|
||||
temperature: 0.2,
|
||||
reasoning: { effort: "high" },
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -137,7 +138,9 @@ describe("SessionRunnerModel", () => {
|
||||
store: false,
|
||||
service_tier: "priority",
|
||||
temperature: 0.2,
|
||||
reasoning: { effort: "high" },
|
||||
})
|
||||
expect(resolved.route.defaults.providerOptions).toEqual({
|
||||
openai: { store: false, reasoningEffort: "high" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -149,6 +152,7 @@ describe("SessionRunnerModel", () => {
|
||||
[
|
||||
{
|
||||
id: ModelV2.VariantID.make("high"),
|
||||
settings: {},
|
||||
headers: {},
|
||||
body: { store: false, reasoning_effort: "high" },
|
||||
},
|
||||
@@ -205,13 +209,14 @@ describe("SessionRunnerModel", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("overlays selected Anthropic Session variant bodies", () =>
|
||||
it.effect("overlays selected Anthropic Session variant settings", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = model({ type: "aisdk", package: "@ai-sdk/anthropic", url: "https://anthropic.example/v1" }, [
|
||||
{
|
||||
id: ModelV2.VariantID.make("high"),
|
||||
settings: { thinking: { type: "enabled", budgetTokens: 12000 } },
|
||||
headers: {},
|
||||
body: { thinking: { type: "enabled", budget_tokens: 12000 } },
|
||||
body: {},
|
||||
},
|
||||
])
|
||||
const session = SessionV2.Info.make({
|
||||
@@ -229,7 +234,9 @@ describe("SessionRunnerModel", () => {
|
||||
|
||||
expect(resolved.route.defaults.http?.body).toEqual({
|
||||
custom_extension: { enabled: true },
|
||||
thinking: { type: "enabled", budget_tokens: 12000 },
|
||||
})
|
||||
expect(resolved.route.defaults.providerOptions).toEqual({
|
||||
anthropic: { thinking: { type: "enabled", budgetTokens: 12000 } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -252,7 +259,7 @@ describe("SessionRunnerModel", () => {
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
ModelV2.Info.make({
|
||||
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
|
||||
request: { headers: {}, body: {} },
|
||||
request: { settings: {}, headers: {}, body: {} },
|
||||
}),
|
||||
Credential.Key.make({ type: "key", key: "secret" }),
|
||||
)
|
||||
@@ -275,7 +282,7 @@ describe("SessionRunnerModel", () => {
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
ModelV2.Info.make({
|
||||
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
|
||||
request: { headers: {}, body: { apiKey: "configured-secret" } },
|
||||
request: { settings: {}, headers: {}, body: { apiKey: "configured-secret" } },
|
||||
}),
|
||||
credential,
|
||||
)
|
||||
@@ -297,7 +304,7 @@ describe("SessionRunnerModel", () => {
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
ModelV2.Info.make({
|
||||
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
|
||||
request: { headers: {}, body: {} },
|
||||
request: { settings: {}, headers: {}, body: {} },
|
||||
}),
|
||||
Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
@@ -313,6 +320,101 @@ describe("SessionRunnerModel", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("routes ChatGPT OAuth credentials to the codex backend", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
ModelV2.Info.make({
|
||||
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
|
||||
request: { headers: {}, body: {} },
|
||||
}),
|
||||
Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: Integration.MethodID.make("chatgpt-browser"),
|
||||
access: "chatgpt-token",
|
||||
refresh: "refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
metadata: { accountID: "acct_123" },
|
||||
}),
|
||||
)
|
||||
const request = LLM.request({ model: resolved, prompt: "Hello" })
|
||||
const headers = yield* resolved.route.auth.apply({
|
||||
request,
|
||||
method: "POST",
|
||||
url: "https://chatgpt.com/backend-api/codex/responses",
|
||||
body: "{}",
|
||||
headers: Headers.empty,
|
||||
})
|
||||
|
||||
expect(resolved.route).toMatchObject({
|
||||
id: "openai-responses",
|
||||
endpoint: { baseURL: "https://chatgpt.com/backend-api/codex" },
|
||||
})
|
||||
expect(headers.authorization).toBe("Bearer chatgpt-token")
|
||||
expect(headers["chatgpt-account-id"]).toBe("acct_123")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("routes ChatGPT OAuth credentials without an account id to the codex backend", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
ModelV2.Info.make({
|
||||
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
|
||||
request: { headers: {}, body: {} },
|
||||
}),
|
||||
Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: Integration.MethodID.make("chatgpt-headless"),
|
||||
access: "chatgpt-token",
|
||||
refresh: "refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
}),
|
||||
)
|
||||
const request = LLM.request({ model: resolved, prompt: "Hello" })
|
||||
const headers = yield* resolved.route.auth.apply({
|
||||
request,
|
||||
method: "POST",
|
||||
url: "https://chatgpt.com/backend-api/codex/responses",
|
||||
body: "{}",
|
||||
headers: Headers.empty,
|
||||
})
|
||||
|
||||
expect(resolved.route.endpoint.baseURL).toBe("https://chatgpt.com/backend-api/codex")
|
||||
expect(headers.authorization).toBe("Bearer chatgpt-token")
|
||||
expect(headers["chatgpt-account-id"]).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps non-ChatGPT OAuth credentials on the configured endpoint", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
ModelV2.Info.make({
|
||||
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
|
||||
request: { headers: {}, body: {} },
|
||||
}),
|
||||
Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: Integration.MethodID.make("device"),
|
||||
access: "oauth-token",
|
||||
refresh: "refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
metadata: { accountID: "acct_123" },
|
||||
}),
|
||||
)
|
||||
const request = LLM.request({ model: resolved, prompt: "Hello" })
|
||||
const headers = yield* resolved.route.auth.apply({
|
||||
request,
|
||||
method: "POST",
|
||||
url: "https://openai.example/v1/responses",
|
||||
body: "{}",
|
||||
headers: Headers.empty,
|
||||
})
|
||||
|
||||
expect(resolved.route.endpoint.baseURL).toBe("https://openai.example/v1")
|
||||
expect(headers.authorization).toBe("Bearer oauth-token")
|
||||
expect(headers["chatgpt-account-id"]).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects catalog APIs without a native route", () =>
|
||||
Effect.gen(function* () {
|
||||
const failure = yield* SessionRunnerModel.fromCatalogModel(
|
||||
|
||||
@@ -31,7 +31,8 @@ import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry"
|
||||
import { SystemContextBuiltIns } from "@opencode-ai/core/system-context/builtins"
|
||||
import { InstructionContext } from "@opencode-ai/core/instruction-context"
|
||||
import { SystemContext } from "@opencode-ai/core/system-context"
|
||||
import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
|
||||
import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
|
||||
@@ -72,7 +73,8 @@ const model = OpenAIChat.route
|
||||
})
|
||||
.model({ id: "gpt-4o-mini" })
|
||||
const models = SessionRunnerModel.layerWith(() => Effect.succeed(model))
|
||||
const systemContext = AppNodeBuilder.build(SystemContextRegistry.node)
|
||||
const systemContext = Layer.mock(SystemContextBuiltIns.Service, { load: () => Effect.succeed(SystemContext.empty) })
|
||||
const instructionContext = Layer.mock(InstructionContext.Service, { load: () => Effect.succeed(SystemContext.empty) })
|
||||
const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
|
||||
const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
|
||||
const mcpGuidance = Layer.mock(McpGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
|
||||
@@ -81,7 +83,8 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
|
||||
[Snapshot.node, Snapshot.noopLayer],
|
||||
[LayerNodePlatform.llmClient, client],
|
||||
[SessionRunnerModel.node, models],
|
||||
[SystemContextRegistry.node, systemContext],
|
||||
[SystemContextBuiltIns.node, systemContext],
|
||||
[InstructionContext.node, instructionContext],
|
||||
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
|
||||
[SkillGuidance.node, skillGuidance],
|
||||
[ReferenceGuidance.node, referenceGuidance],
|
||||
@@ -116,7 +119,8 @@ const it = testEffect(
|
||||
AgentV2.node,
|
||||
ToolRegistry.node,
|
||||
SessionRunnerModel.node,
|
||||
SystemContextRegistry.node,
|
||||
SystemContextBuiltIns.node,
|
||||
InstructionContext.node,
|
||||
SkillGuidance.node,
|
||||
ReferenceGuidance.node,
|
||||
Config.node,
|
||||
@@ -129,7 +133,8 @@ const it = testEffect(
|
||||
[PermissionV2.node, permission],
|
||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||
[SessionRunnerModel.node, models],
|
||||
[SystemContextRegistry.node, systemContext],
|
||||
[SystemContextBuiltIns.node, systemContext],
|
||||
[InstructionContext.node, instructionContext],
|
||||
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
|
||||
[SkillGuidance.node, skillGuidance],
|
||||
[ReferenceGuidance.node, referenceGuidance],
|
||||
|
||||
@@ -25,7 +25,6 @@ import { QuestionV2 } from "@opencode-ai/core/question"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||
import { ContextSnapshotDecodeError } from "@opencode-ai/core/session/error"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
|
||||
import { SessionTitle } from "@opencode-ai/core/session/title"
|
||||
@@ -46,14 +45,15 @@ import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigCompaction } from "@opencode-ai/core/config/compaction"
|
||||
import { Tool } from "@opencode-ai/core/tool/tool"
|
||||
import {
|
||||
SessionContextEpochTable,
|
||||
SessionContextCheckpointTable,
|
||||
SessionInputTable,
|
||||
SessionMessageTable,
|
||||
SessionTable,
|
||||
} from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { SystemContext } from "@opencode-ai/core/system-context"
|
||||
import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry"
|
||||
import { SystemContextBuiltIns } from "@opencode-ai/core/system-context/builtins"
|
||||
import { InstructionContext } from "@opencode-ai/core/instruction-context"
|
||||
import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
|
||||
import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
|
||||
import { McpGuidance } from "@opencode-ai/core/mcp/guidance"
|
||||
@@ -154,6 +154,14 @@ const echo = Layer.effectDiscard(
|
||||
output: Schema.Struct({}),
|
||||
execute: () => Effect.die("unexpected tool defect"),
|
||||
}),
|
||||
// BigInt output with no model content forces ToolOutputStore.bound onto its
|
||||
// JSON.stringify encode path, which fails with a typed StorageError.
|
||||
storefail: Tool.make({
|
||||
description: "Produce output that cannot be persisted",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Any,
|
||||
execute: () => Effect.succeed({ big: 1n }),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -169,35 +177,28 @@ let systemRemoved = false
|
||||
let systemUnavailable = false
|
||||
let systemLoadHook = Effect.void
|
||||
const skillBaselines = new Map<AgentV2.ID, string>()
|
||||
const systemContext = Layer.effectDiscard(
|
||||
SystemContextRegistry.Service.pipe(
|
||||
Effect.flatMap((registry) =>
|
||||
registry.register({
|
||||
key: systemContextKey,
|
||||
load: Effect.sync(() =>
|
||||
SystemContext.combine(
|
||||
systemRemoved
|
||||
? []
|
||||
: [
|
||||
SystemContext.make({
|
||||
key: systemContextKey,
|
||||
codec: Schema.toCodecJson(Schema.String),
|
||||
load: systemLoadHook.pipe(
|
||||
Effect.andThen(
|
||||
Effect.sync(() => (systemUnavailable ? SystemContext.unavailable : systemBaseline)),
|
||||
),
|
||||
),
|
||||
baseline: String,
|
||||
update: (_previous, current) => current,
|
||||
removed: () => "System context source removed: test/context",
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
}),
|
||||
const systemContext = Layer.mock(SystemContextBuiltIns.Service, {
|
||||
load: () =>
|
||||
Effect.sync(() =>
|
||||
SystemContext.combine(
|
||||
systemRemoved
|
||||
? []
|
||||
: [
|
||||
SystemContext.make({
|
||||
key: systemContextKey,
|
||||
codec: Schema.toCodecJson(Schema.String),
|
||||
load: systemLoadHook.pipe(
|
||||
Effect.andThen(Effect.sync(() => (systemUnavailable ? SystemContext.unavailable : systemBaseline))),
|
||||
),
|
||||
baseline: String,
|
||||
update: (_previous, current) => current,
|
||||
removed: () => "System context source removed: test/context",
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
).pipe(Layer.provideMerge(AppNodeBuilder.build(SystemContextRegistry.node)))
|
||||
})
|
||||
const instructionContext = Layer.mock(InstructionContext.Service, { load: () => Effect.succeed(SystemContext.empty) })
|
||||
const skillGuidance = Layer.mock(SkillGuidance.Service, {
|
||||
load: (agent) =>
|
||||
Effect.succeed(
|
||||
@@ -236,7 +237,8 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
|
||||
[Snapshot.node, Snapshot.noopLayer],
|
||||
[LayerNodePlatform.llmClient, client],
|
||||
[SessionRunnerModel.node, models],
|
||||
[SystemContextRegistry.node, systemContext],
|
||||
[SystemContextBuiltIns.node, systemContext],
|
||||
[InstructionContext.node, instructionContext],
|
||||
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
|
||||
[SkillGuidance.node, skillGuidance],
|
||||
[ReferenceGuidance.node, referenceGuidance],
|
||||
@@ -274,7 +276,8 @@ const it = testEffect(
|
||||
ToolRegistry.toolsNode,
|
||||
echoNode,
|
||||
SessionRunnerModel.node,
|
||||
SystemContextRegistry.node,
|
||||
SystemContextBuiltIns.node,
|
||||
InstructionContext.node,
|
||||
SkillGuidance.node,
|
||||
ReferenceGuidance.node,
|
||||
Config.node,
|
||||
@@ -287,7 +290,8 @@ const it = testEffect(
|
||||
[LayerNodePlatform.llmClient, client],
|
||||
[PermissionV2.node, permission],
|
||||
[SessionRunnerModel.node, models],
|
||||
[SystemContextRegistry.node, systemContext],
|
||||
[SystemContextBuiltIns.node, systemContext],
|
||||
[InstructionContext.node, instructionContext],
|
||||
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
|
||||
[SkillGuidance.node, skillGuidance],
|
||||
[ReferenceGuidance.node, referenceGuidance],
|
||||
@@ -647,6 +651,7 @@ describe("SessionRunnerLLM", () => {
|
||||
response = []
|
||||
|
||||
const message = yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Run automatically" }) })
|
||||
yield* session.wait(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(yield* session.messages({ sessionID })).toMatchObject([
|
||||
@@ -671,7 +676,7 @@ describe("SessionRunnerLLM", () => {
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.model).toBe(model)
|
||||
expect(requests[0]?.tools.map((tool) => tool.name)).toEqual(["echo", "defect"])
|
||||
expect(requests[0]?.tools.map((tool) => tool.name)).toEqual(["echo", "defect", "storefail"])
|
||||
expect(requests[0]?.messages.map((message) => ({ role: message.role, content: message.content }))).toEqual([
|
||||
{ role: "user", content: [{ type: "text", text: "First" }] },
|
||||
{ role: "user", content: [{ type: "text", text: "Second" }] },
|
||||
@@ -699,13 +704,14 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(
|
||||
yield* db
|
||||
.select()
|
||||
.from(SessionContextEpochTable)
|
||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||
.from(SessionContextCheckpointTable)
|
||||
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
|
||||
.get(),
|
||||
).toBeUndefined()
|
||||
|
||||
systemUnavailable = false
|
||||
yield* session.prompt({ id: messageID, sessionID, prompt: Prompt.make({ text: "First" }) })
|
||||
yield* session.wait(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user"])
|
||||
@@ -731,8 +737,8 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(
|
||||
yield* db
|
||||
.select()
|
||||
.from(SessionContextEpochTable)
|
||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||
.from(SessionContextCheckpointTable)
|
||||
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
|
||||
.get(),
|
||||
).toBeUndefined()
|
||||
|
||||
@@ -745,7 +751,36 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails gracefully when a stored context snapshot cannot be decoded", () =>
|
||||
it.effect("copies the context checkpoint to a fork", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const { db } = yield* Database.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
|
||||
response = []
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
const forked = yield* session.fork({ sessionID })
|
||||
|
||||
const parent = yield* db
|
||||
.select()
|
||||
.from(SessionContextCheckpointTable)
|
||||
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
expect(parent).toBeDefined()
|
||||
expect(
|
||||
yield* db
|
||||
.select()
|
||||
.from(SessionContextCheckpointTable)
|
||||
.where(eq(SessionContextCheckpointTable.session_id, forked.id))
|
||||
.get()
|
||||
.pipe(Effect.orDie),
|
||||
).toEqual({ ...parent!, session_id: forked.id })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("heals an undecodable stored applied record by re-announcing context", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
@@ -754,19 +789,28 @@ describe("SessionRunnerLLM", () => {
|
||||
response = []
|
||||
yield* session.resume(sessionID)
|
||||
yield* db
|
||||
.update(SessionContextEpochTable)
|
||||
.update(SessionContextCheckpointTable)
|
||||
.set({ snapshot: { invalid: { value: "bad" } } })
|
||||
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
|
||||
requests.length = 0
|
||||
|
||||
const exit = yield* session.resume(sessionID).pipe(Effect.exit)
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(ContextSnapshotDecodeError)
|
||||
expect(requests).toHaveLength(0)
|
||||
// Comparison state was lost, so every source re-announces as new.
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.system.map((part) => part.text)).toEqual([defaultSystem, "Initial context"])
|
||||
expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"])
|
||||
expect(requests[0]?.messages.at(1)?.content).toEqual([{ type: "text", text: "Initial context" }])
|
||||
const healed = yield* db
|
||||
.select({ snapshot: SessionContextCheckpointTable.snapshot })
|
||||
.from(SessionContextCheckpointTable)
|
||||
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
expect(healed?.snapshot).toEqual({ "test/context": { value: "Initial context", removed: expect.any(String) } })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -787,8 +831,8 @@ describe("SessionRunnerLLM", () => {
|
||||
[defaultSystem, "Initial context"],
|
||||
[defaultSystem, "Initial context"],
|
||||
])
|
||||
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"])
|
||||
expect(requests[1]?.messages.at(-1)?.content).toEqual([{ type: "text", text: "Changed context" }])
|
||||
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"])
|
||||
expect(requests[1]?.messages.at(1)?.content).toEqual([{ type: "text", text: "Changed context" }])
|
||||
expect(yield* session.messages({ sessionID })).toHaveLength(3)
|
||||
const { db } = yield* Database.Service
|
||||
expect(
|
||||
@@ -1049,8 +1093,8 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"])
|
||||
expect(requests[1]?.messages.at(-1)?.content).toEqual([
|
||||
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"])
|
||||
expect(requests[1]?.messages.at(1)?.content).toEqual([
|
||||
{ type: "text", text: "System context source removed: test/context" },
|
||||
])
|
||||
expect(yield* session.messages({ sessionID })).toHaveLength(3)
|
||||
@@ -1085,15 +1129,15 @@ describe("SessionRunnerLLM", () => {
|
||||
[defaultSystem, "Initial context"],
|
||||
[defaultSystem, "Initial context"],
|
||||
])
|
||||
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"])
|
||||
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"])
|
||||
expect(requests[2]?.messages.filter((message) => message.role === "system")).toHaveLength(2)
|
||||
expect((yield* session.context(sessionID)).map((message) => message.type)).toEqual([
|
||||
"user",
|
||||
"user",
|
||||
"system",
|
||||
"user",
|
||||
"model-switched",
|
||||
"user",
|
||||
"system",
|
||||
"user",
|
||||
])
|
||||
yield* replaySessionProjection(sessionID)
|
||||
expect(yield* session.messages({ sessionID })).toHaveLength(6)
|
||||
@@ -1361,7 +1405,7 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves effective System updates while compaction rebaseline is blocked", () =>
|
||||
it.effect("rebaselines after compaction from the last-applied belief while unobservable", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
@@ -1393,8 +1437,9 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false })
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([defaultSystem, "Initial context"])
|
||||
expect(systemTexts(requests.at(-1)!)).toContain("Changed context")
|
||||
// The rebaseline proceeds while the source is unobservable, restating the model's belief.
|
||||
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([defaultSystem, "Changed context"])
|
||||
expect(systemTexts(requests.at(-1)!)).not.toContain("Changed context")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1456,7 +1501,7 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.tools.map((tool) => tool.name)).toEqual(["echo", "defect"])
|
||||
expect(requests[0]?.tools.map((tool) => tool.name)).toEqual(["echo", "defect", "storefail"])
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user", text: "Use tools" },
|
||||
{
|
||||
@@ -2292,7 +2337,7 @@ describe("SessionRunnerLLM", () => {
|
||||
const session = yield* SessionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Recover interrupted tool" }), resume: false })
|
||||
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID, Number.MAX_SAFE_INTEGER)
|
||||
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID)
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
yield* events.publish(SessionEvent.Step.Started, {
|
||||
sessionID,
|
||||
@@ -2356,7 +2401,7 @@ describe("SessionRunnerLLM", () => {
|
||||
prompt: Prompt.make({ text: "Recover interrupted hosted tool" }),
|
||||
resume: false,
|
||||
})
|
||||
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID, Number.MAX_SAFE_INTEGER)
|
||||
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID)
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
yield* events.publish(SessionEvent.Step.Started, {
|
||||
sessionID,
|
||||
@@ -2416,7 +2461,7 @@ describe("SessionRunnerLLM", () => {
|
||||
prompt: Prompt.make({ text: "Recover interrupted tool input" }),
|
||||
resume: false,
|
||||
})
|
||||
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID, Number.MAX_SAFE_INTEGER)
|
||||
yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID)
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
yield* events.publish(SessionEvent.Step.Started, {
|
||||
sessionID,
|
||||
@@ -2529,8 +2574,9 @@ describe("SessionRunnerLLM", () => {
|
||||
|
||||
const first = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(streamStarted)
|
||||
streamStarted = yield* Deferred.make<void>()
|
||||
const second = yield* session.resume(otherSessionID).pipe(Effect.forkChild)
|
||||
yield* Effect.yieldNow
|
||||
yield* Deferred.await(streamStarted)
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(requests.map((request) => request.providerOptions?.openai?.promptCacheKey)).toEqual([
|
||||
@@ -2700,6 +2746,49 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails the drain when tool output persistence fails", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Call storefail" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
responses = [
|
||||
[
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.toolCall({ id: "call-storefail", name: "storefail", input: {} }),
|
||||
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||
LLMEvent.finish({ reason: "tool-calls" }),
|
||||
],
|
||||
[],
|
||||
]
|
||||
|
||||
const exit = yield* session.resume(sessionID).pipe(Effect.exit)
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user", text: "Call storefail" },
|
||||
{
|
||||
type: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call-storefail",
|
||||
state: {
|
||||
status: "error",
|
||||
error: {
|
||||
type: "unknown",
|
||||
message: expect.stringContaining("Tool execution failed: Failed to encode tool output"),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("interrupts runner continuation when a question is dismissed", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
|
||||
@@ -39,7 +39,14 @@ const client = Layer.mock(LLMClient.Service)({
|
||||
const models = Layer.mock(SessionRunnerModel.Service)({ resolve: () => Effect.succeed(model) })
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, AgentV2.node, SessionTitle.node]),
|
||||
LayerNode.group([
|
||||
Database.node,
|
||||
EventV2.node,
|
||||
SessionProjector.node,
|
||||
SessionStore.node,
|
||||
AgentV2.node,
|
||||
SessionTitle.node,
|
||||
]),
|
||||
[
|
||||
[llmClient, client],
|
||||
[SessionRunnerModel.node, models],
|
||||
@@ -74,9 +81,17 @@ const insertSession = (id: SessionV2.ID) =>
|
||||
const prompt = (sessionID: SessionV2.ID, text: string) =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const messageID = SessionMessage.ID.create()
|
||||
yield* events.publish(SessionEvent.PromptAdmitted, {
|
||||
sessionID,
|
||||
messageID,
|
||||
timestamp: DateTime.makeUnsafe(0),
|
||||
prompt: Prompt.make({ text }),
|
||||
delivery: "steer",
|
||||
})
|
||||
yield* events.publish(SessionEvent.Prompted, {
|
||||
sessionID,
|
||||
messageID: SessionMessage.ID.create(),
|
||||
messageID,
|
||||
timestamp: DateTime.makeUnsafe(0),
|
||||
prompt: Prompt.make({ text }),
|
||||
delivery: "steer",
|
||||
@@ -99,9 +114,9 @@ it.effect("generates a title from the sole user message and renames the session"
|
||||
yield* prompt(sessionID, "Help me debug the failing build")
|
||||
|
||||
const store = yield* SessionStore.Service
|
||||
const session = yield* store.get(sessionID).pipe(
|
||||
Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))),
|
||||
)
|
||||
const session = yield* store
|
||||
.get(sessionID)
|
||||
.pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))))
|
||||
const title = yield* SessionTitle.Service
|
||||
yield* title.generateForFirstPrompt(session)
|
||||
|
||||
@@ -129,9 +144,9 @@ it.effect("does not generate once a second user message exists", () =>
|
||||
yield* prompt(sessionID, "Second message")
|
||||
|
||||
const store = yield* SessionStore.Service
|
||||
const session = yield* store.get(sessionID).pipe(
|
||||
Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))),
|
||||
)
|
||||
const session = yield* store
|
||||
.get(sessionID)
|
||||
.pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))))
|
||||
const title = yield* SessionTitle.Service
|
||||
yield* title.generateForFirstPrompt(session)
|
||||
|
||||
@@ -177,9 +192,9 @@ it.effect("does not generate for a child session", () =>
|
||||
yield* prompt(sessionID, "Do this subtask")
|
||||
|
||||
const store = yield* SessionStore.Service
|
||||
const session = yield* store.get(sessionID).pipe(
|
||||
Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))),
|
||||
)
|
||||
const session = yield* store
|
||||
.get(sessionID)
|
||||
.pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))))
|
||||
const title = yield* SessionTitle.Service
|
||||
yield* title.generateForFirstPrompt(session)
|
||||
|
||||
@@ -195,9 +210,9 @@ it.effect("does not generate when the title agent is removed", () =>
|
||||
yield* prompt(sessionID, "Help me debug the failing build")
|
||||
|
||||
const store = yield* SessionStore.Service
|
||||
const session = yield* store.get(sessionID).pipe(
|
||||
Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))),
|
||||
)
|
||||
const session = yield* store
|
||||
.get(sessionID)
|
||||
.pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))))
|
||||
const title = yield* SessionTitle.Service
|
||||
yield* title.generateForFirstPrompt(session)
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ describe("SkillGuidance", () => {
|
||||
.load({ id: agent.id, info: agent })
|
||||
.pipe(Effect.flatMap(SystemContext.initialize))
|
||||
|
||||
expect(initialized.baseline).toBe(
|
||||
expect(initialized.text).toBe(
|
||||
[
|
||||
"Skills provide specialized instructions and workflows for specific tasks.",
|
||||
"Use the skill tool to load a skill when a task matches its description.",
|
||||
@@ -65,16 +65,82 @@ describe("SkillGuidance", () => {
|
||||
"</available_skills>",
|
||||
].join("\n"),
|
||||
)
|
||||
expect(initialized.baseline).not.toContain("manual")
|
||||
expect(initialized.text).not.toContain("manual")
|
||||
|
||||
skills = []
|
||||
expect(
|
||||
yield* guidance
|
||||
.load({ id: agent.id, info: agent })
|
||||
.pipe(Effect.flatMap((context) => SystemContext.reconcile(context, initialized.snapshot))),
|
||||
.pipe(Effect.flatMap((context) => SystemContext.reconcile(context, initialized.applied))),
|
||||
).toMatchObject({
|
||||
_tag: "Updated",
|
||||
text: expect.stringContaining("No skills are currently available."),
|
||||
text: "The following skills are no longer available and must not be used: effect.",
|
||||
})
|
||||
}).pipe(Effect.provide(layer(() => skills)))
|
||||
})
|
||||
|
||||
it.effect("announces added and removed skills as deltas without restating the list", () => {
|
||||
const agent = AgentV2.Info.make(AgentV2.Info.empty(build))
|
||||
const debugging = SkillV2.Info.make({
|
||||
name: "debugging",
|
||||
description: "Diagnose hard bugs",
|
||||
location: AbsolutePath.make(path.resolve("/skills/debugging/SKILL.md")),
|
||||
content: "Debugging guidance",
|
||||
})
|
||||
let skills = [effect]
|
||||
return Effect.gen(function* () {
|
||||
const guidance = yield* SkillGuidance.Service
|
||||
const initialized = yield* guidance
|
||||
.load({ id: agent.id, info: agent })
|
||||
.pipe(Effect.flatMap(SystemContext.initialize))
|
||||
|
||||
skills = [effect, debugging]
|
||||
const added = yield* guidance
|
||||
.load({ id: agent.id, info: agent })
|
||||
.pipe(Effect.flatMap((context) => SystemContext.reconcile(context, initialized.applied)))
|
||||
expect(added).toMatchObject({
|
||||
_tag: "Updated",
|
||||
text: [
|
||||
"New skills are available in addition to those previously listed:",
|
||||
" <skill>",
|
||||
" <name>debugging</name>",
|
||||
" <description>Diagnose hard bugs</description>",
|
||||
" </skill>",
|
||||
].join("\n"),
|
||||
})
|
||||
|
||||
skills = [debugging]
|
||||
const removed = yield* guidance
|
||||
.load({ id: agent.id, info: agent })
|
||||
.pipe(
|
||||
Effect.flatMap((context) => SystemContext.reconcile(context, added._tag === "Updated" ? added.applied : {})),
|
||||
)
|
||||
expect(removed).toMatchObject({
|
||||
_tag: "Updated",
|
||||
text: "The following skills are no longer available and must not be used: effect.",
|
||||
})
|
||||
}).pipe(Effect.provide(layer(() => skills)))
|
||||
})
|
||||
|
||||
it.effect("restates the full skill list when a description changes", () => {
|
||||
const agent = AgentV2.Info.make(AgentV2.Info.empty(build))
|
||||
let skills = [effect]
|
||||
return Effect.gen(function* () {
|
||||
const guidance = yield* SkillGuidance.Service
|
||||
const initialized = yield* guidance
|
||||
.load({ id: agent.id, info: agent })
|
||||
.pipe(Effect.flatMap(SystemContext.initialize))
|
||||
|
||||
skills = [SkillV2.Info.make({ ...effect, description: "Build applications with Effect v4" })]
|
||||
expect(
|
||||
yield* guidance
|
||||
.load({ id: agent.id, info: agent })
|
||||
.pipe(Effect.flatMap((context) => SystemContext.reconcile(context, initialized.applied))),
|
||||
).toMatchObject({
|
||||
_tag: "Updated",
|
||||
text: expect.stringContaining(
|
||||
"The available skills have changed. This list supersedes the previous available skills list.",
|
||||
),
|
||||
})
|
||||
}).pipe(Effect.provide(layer(() => skills)))
|
||||
})
|
||||
@@ -89,8 +155,8 @@ describe("SkillGuidance", () => {
|
||||
expect(
|
||||
yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(SystemContext.initialize)),
|
||||
).toEqual({
|
||||
baseline: "",
|
||||
snapshot: {},
|
||||
text: "",
|
||||
applied: {},
|
||||
})
|
||||
}).pipe(Effect.provide(layer(() => [effect])))
|
||||
})
|
||||
@@ -108,8 +174,8 @@ describe("SkillGuidance", () => {
|
||||
expect(
|
||||
yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(SystemContext.initialize)),
|
||||
).toEqual({
|
||||
baseline: "",
|
||||
snapshot: {},
|
||||
text: "",
|
||||
applied: {},
|
||||
})
|
||||
}).pipe(Effect.provide(layer(() => [effect])))
|
||||
})
|
||||
@@ -125,7 +191,7 @@ describe("SkillGuidance", () => {
|
||||
return Effect.gen(function* () {
|
||||
const guidance = yield* SkillGuidance.Service
|
||||
expect(
|
||||
(yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(SystemContext.initialize))).baseline,
|
||||
(yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(SystemContext.initialize))).text,
|
||||
).toContain("<name>effect</name>")
|
||||
}).pipe(Effect.provide(layer(() => [effect])))
|
||||
})
|
||||
@@ -144,8 +210,8 @@ describe("SkillGuidance", () => {
|
||||
expect(
|
||||
yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(SystemContext.initialize)),
|
||||
).toEqual({
|
||||
baseline: "",
|
||||
snapshot: {},
|
||||
text: "",
|
||||
applied: {},
|
||||
})
|
||||
}).pipe(Effect.provide(layer(() => [effect])))
|
||||
})
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Global } from "@opencode-ai/core/global"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SystemContext } from "@opencode-ai/core/system-context"
|
||||
import { SystemContextBuiltIns } from "@opencode-ai/core/system-context/builtins"
|
||||
import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry"
|
||||
import { InstructionContext } from "@opencode-ai/core/instruction-context"
|
||||
import { location } from "../fixture/location"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
@@ -27,7 +27,7 @@ const locationLayer = Layer.succeed(
|
||||
),
|
||||
),
|
||||
)
|
||||
const builtInsNode = LayerNode.group([SystemContextBuiltIns.node, SystemContextRegistry.node])
|
||||
const builtInsNode = LayerNode.group([SystemContextBuiltIns.node, InstructionContext.node])
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(builtInsNode, [
|
||||
[Location.node, locationLayer],
|
||||
@@ -58,10 +58,10 @@ describe("SystemContextBuiltIns", () => {
|
||||
it.effect("loads location-scoped environment and host-local date context", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* TestClock.setTime(timestamp)
|
||||
const context = yield* SystemContextRegistry.Service
|
||||
const context = yield* SystemContextBuiltIns.Service
|
||||
const initialized = yield* SystemContext.initialize(yield* context.load())
|
||||
|
||||
expect(initialized.baseline).toBe(
|
||||
expect(initialized.text).toBe(
|
||||
[
|
||||
"Here is some useful information about the environment you are running in:",
|
||||
"<env>",
|
||||
@@ -80,11 +80,11 @@ describe("SystemContextBuiltIns", () => {
|
||||
it.effect("reconciles the date without repeating unchanged environment context", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* TestClock.setTime(timestamp)
|
||||
const context = yield* SystemContextRegistry.Service
|
||||
const context = yield* SystemContextBuiltIns.Service
|
||||
const initialized = yield* SystemContext.initialize(yield* context.load())
|
||||
|
||||
yield* TestClock.setTime(timestamp + 24 * 60 * 60 * 1000)
|
||||
const refreshed = yield* SystemContext.reconcile(yield* context.load(), initialized.snapshot)
|
||||
const refreshed = yield* SystemContext.reconcile(yield* context.load(), initialized.applied)
|
||||
|
||||
expect(refreshed).toMatchObject({
|
||||
_tag: "Updated",
|
||||
@@ -96,20 +96,24 @@ describe("SystemContextBuiltIns", () => {
|
||||
it.effect("does not update again within the same local calendar day", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* TestClock.setTime(timestamp)
|
||||
const context = yield* SystemContextRegistry.Service
|
||||
const context = yield* SystemContextBuiltIns.Service
|
||||
const initialized = yield* SystemContext.initialize(yield* context.load())
|
||||
|
||||
yield* TestClock.setTime(timestamp + 60 * 60 * 1000)
|
||||
expect(yield* SystemContext.reconcile(yield* context.load(), initialized.snapshot)).toEqual({ _tag: "Unchanged" })
|
||||
expect(yield* SystemContext.reconcile(yield* context.load(), initialized.applied)).toEqual({ _tag: "Unchanged" })
|
||||
}),
|
||||
)
|
||||
|
||||
itWithInstructions.effect("composes ambient instructions after built-in context", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* TestClock.setTime(timestamp)
|
||||
const context = yield* SystemContextRegistry.Service
|
||||
const builtIns = yield* SystemContextBuiltIns.Service
|
||||
const instructions = yield* InstructionContext.Service
|
||||
const context = {
|
||||
load: () => Effect.all([builtIns.load(), instructions.load()]).pipe(Effect.map(SystemContext.combine)),
|
||||
}
|
||||
|
||||
expect((yield* SystemContext.initialize(yield* context.load())).baseline).toBe(
|
||||
expect((yield* SystemContext.initialize(yield* context.load())).text).toBe(
|
||||
[
|
||||
"Here is some useful information about the environment you are running in:",
|
||||
"<env>",
|
||||
|
||||
@@ -32,11 +32,11 @@ describe("SystemContext", () => {
|
||||
removed: () => "Date removed",
|
||||
})
|
||||
|
||||
expect((yield* SystemContext.initialize(context)).snapshot["core/date"].value).toBe("2026-06-03T12:00:00.000Z")
|
||||
expect((yield* SystemContext.initialize(context)).applied["core/date"].value).toBe("2026-06-03T12:00:00.000Z")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("loads once and initializes a baseline with a structured snapshot", () =>
|
||||
it.effect("loads once and initializes a baseline with the applied values", () =>
|
||||
Effect.gen(function* () {
|
||||
let loads = 0
|
||||
const context = SystemContext.combine([
|
||||
@@ -55,8 +55,8 @@ describe("SystemContext", () => {
|
||||
])
|
||||
|
||||
expect(yield* SystemContext.initialize(context)).toEqual({
|
||||
baseline: "Today's date is 2026-06-03.\n\nDirectory: /repo",
|
||||
snapshot: {
|
||||
text: "Today's date is 2026-06-03.\n\nDirectory: /repo",
|
||||
applied: {
|
||||
"core/date": { value: "2026-06-03", removed: "The date was removed." },
|
||||
"core/location": { value: "/repo" },
|
||||
},
|
||||
@@ -84,7 +84,7 @@ describe("SystemContext", () => {
|
||||
expect(yield* SystemContext.reconcile(changed, previous)).toEqual({
|
||||
_tag: "Updated",
|
||||
text: "The date changed from 2026-06-03 to 2026-06-04.",
|
||||
snapshot: {
|
||||
applied: {
|
||||
"core/date": { value: "2026-06-04", removed: "The date was removed." },
|
||||
"core/location": { value: "/repo", removed: "Removed: /repo" },
|
||||
},
|
||||
@@ -113,19 +113,17 @@ describe("SystemContext", () => {
|
||||
expect(yield* SystemContext.reconcile(context, {})).toEqual({
|
||||
_tag: "Updated",
|
||||
text: "Available skill: effect",
|
||||
snapshot: { "core/skills": { value: "effect" } },
|
||||
applied: { "core/skills": { value: "effect" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retains admitted snapshots while a source is temporarily unavailable", () =>
|
||||
it.effect("retains the belief while a source is temporarily unavailable", () =>
|
||||
Effect.gen(function* () {
|
||||
const previous = { "core/remote": { value: "instructions", removed: "Instructions removed" } }
|
||||
const context = stringContext({ key: "core/remote", value: SystemContext.unavailable })
|
||||
|
||||
expect(yield* SystemContext.reconcile(context, previous)).toEqual({ _tag: "Unchanged" })
|
||||
expect(yield* SystemContext.replace(context, previous)).toEqual({ _tag: "ReplacementBlocked" })
|
||||
expect(yield* SystemContext.replace(context, {})).toMatchObject({ _tag: "ReplacementReady" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -152,17 +150,29 @@ describe("SystemContext", () => {
|
||||
).toEqual({
|
||||
_tag: "Updated",
|
||||
text: "Instructions removed; stop applying them.",
|
||||
snapshot: {},
|
||||
applied: {},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("requests replacement when a source without removal text disappears", () =>
|
||||
it.effect("retains an unannounced removal silently", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(yield* SystemContext.reconcile(SystemContext.empty, { "core/date": { value: "2026-06-04" } })).toEqual({
|
||||
_tag: "Unchanged",
|
||||
})
|
||||
|
||||
// The retained belief survives alongside other updates.
|
||||
expect(
|
||||
yield* SystemContext.reconcile(SystemContext.empty, { "core/date": { value: "2026-06-04" } }),
|
||||
).toMatchObject({
|
||||
_tag: "ReplacementReady",
|
||||
yield* SystemContext.reconcile(stringContext({ key: "core/skills", value: "effect" }), {
|
||||
"core/date": { value: "2026-06-04" },
|
||||
}),
|
||||
).toEqual({
|
||||
_tag: "Updated",
|
||||
text: "effect",
|
||||
applied: {
|
||||
"core/skills": { value: "effect" },
|
||||
"core/date": { value: "2026-06-04" },
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -189,17 +199,48 @@ describe("SystemContext", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("requests replacement when a stored value no longer decodes", () =>
|
||||
it.effect("re-announces the baseline when a stored value no longer decodes", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(
|
||||
yield* SystemContext.reconcile(stringContext({ key: "core/date", value: "2026-06-04" }), {
|
||||
"core/date": { value: 42, removed: "Date removed" },
|
||||
}),
|
||||
).toMatchObject({ _tag: "ReplacementReady" })
|
||||
).toEqual({
|
||||
_tag: "Updated",
|
||||
text: "2026-06-04",
|
||||
applied: { "core/date": { value: "2026-06-04" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replaces from one coherent source observation", () =>
|
||||
it.effect("renders undecodable re-announcements alongside other updates", () =>
|
||||
Effect.gen(function* () {
|
||||
const context = SystemContext.combine([
|
||||
stringContext({
|
||||
key: "core/date",
|
||||
value: "2026-06-04",
|
||||
update: (before, current) => `${before} -> ${current}`,
|
||||
}),
|
||||
stringContext({ key: "core/location", value: "/repo" }),
|
||||
])
|
||||
|
||||
expect(
|
||||
yield* SystemContext.reconcile(context, {
|
||||
"core/date": { value: "2026-06-03" },
|
||||
"core/location": { value: 42 },
|
||||
}),
|
||||
).toEqual({
|
||||
_tag: "Updated",
|
||||
text: "2026-06-03 -> 2026-06-04\n\n/repo",
|
||||
applied: {
|
||||
"core/date": { value: "2026-06-04" },
|
||||
"core/location": { value: "/repo" },
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rebaselines from one coherent source observation", () =>
|
||||
Effect.gen(function* () {
|
||||
let loads = 0
|
||||
const context = SystemContext.make({
|
||||
@@ -213,52 +254,83 @@ describe("SystemContext", () => {
|
||||
update: (_previous, current) => current,
|
||||
})
|
||||
|
||||
expect(yield* SystemContext.reconcile(context, { "core/date": { value: 42 } })).toMatchObject({
|
||||
_tag: "ReplacementReady",
|
||||
generation: { baseline: "2026-06-04" },
|
||||
expect(yield* SystemContext.rebaseline(context, { "core/date": { value: "2026-06-03" } })).toEqual({
|
||||
text: "2026-06-04",
|
||||
applied: { "core/date": { value: "2026-06-04" } },
|
||||
})
|
||||
expect(loads).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not render discarded updates while replacing", () =>
|
||||
it.effect("rebaselines an unavailable source from the last-applied belief", () =>
|
||||
Effect.gen(function* () {
|
||||
let updates = 0
|
||||
const context = SystemContext.combine([
|
||||
stringContext({ key: "core/date", value: "2026-06-04" }),
|
||||
stringContext({
|
||||
key: "core/date",
|
||||
value: "2026-06-04",
|
||||
update: () => {
|
||||
updates++
|
||||
return "updated"
|
||||
},
|
||||
key: "core/remote",
|
||||
value: SystemContext.unavailable,
|
||||
baseline: (value) => `Instructions: ${value}`,
|
||||
}),
|
||||
stringContext({ key: "core/location", value: "/repo" }),
|
||||
])
|
||||
|
||||
expect(
|
||||
yield* SystemContext.reconcile(context, {
|
||||
"core/date": { value: "2026-06-03" },
|
||||
"core/location": { value: 42 },
|
||||
yield* SystemContext.rebaseline(context, {
|
||||
"core/remote": { value: "contents", removed: "Instructions removed" },
|
||||
}),
|
||||
).toMatchObject({ _tag: "ReplacementReady" })
|
||||
expect(updates).toBe(0)
|
||||
).toEqual({
|
||||
text: "2026-06-04\n\nInstructions: contents",
|
||||
applied: {
|
||||
"core/date": { value: "2026-06-04" },
|
||||
"core/remote": { value: "contents", removed: "Instructions removed" },
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("blocks an incompatible replacement while another admitted source is unavailable", () =>
|
||||
it.effect("drops undecodable beliefs and removed sources at rebaseline", () =>
|
||||
Effect.gen(function* () {
|
||||
const previous = {
|
||||
"core/date": { value: 42, removed: "Date removed" },
|
||||
"core/remote": { value: "instructions", removed: "Instructions removed" },
|
||||
}
|
||||
const context = SystemContext.combine([
|
||||
stringContext({ key: "core/date", value: "2026-06-04" }),
|
||||
stringContext({ key: "core/remote", value: SystemContext.unavailable }),
|
||||
])
|
||||
const context = stringContext({ key: "core/remote", value: SystemContext.unavailable })
|
||||
|
||||
expect(yield* SystemContext.reconcile(context, previous)).toEqual({ _tag: "ReplacementBlocked" })
|
||||
expect(yield* SystemContext.replace(context, previous)).toEqual({ _tag: "ReplacementBlocked" })
|
||||
// Undecodable belief cannot be restated; removed source entries self-clean.
|
||||
expect(
|
||||
yield* SystemContext.rebaseline(context, {
|
||||
"core/remote": { value: 42 },
|
||||
"core/gone": { value: "gone" },
|
||||
}),
|
||||
).toEqual({ text: "", applied: {} })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("diffs list values by key with a changed comparator", () =>
|
||||
Effect.sync(() => {
|
||||
const previous = [
|
||||
{ name: "effect", description: "Build with Effect" },
|
||||
{ name: "debugging", description: "Diagnose bugs" },
|
||||
{ name: "retired", description: "Old" },
|
||||
]
|
||||
const current = [
|
||||
{ name: "effect", description: "Build with Effect v4" },
|
||||
{ name: "debugging", description: "Diagnose bugs" },
|
||||
{ name: "writing", description: "Write prose" },
|
||||
]
|
||||
|
||||
expect(
|
||||
SystemContext.diffByKey(
|
||||
previous,
|
||||
current,
|
||||
(value) => value.name,
|
||||
(before, after) => before.description !== after.description,
|
||||
),
|
||||
).toEqual({
|
||||
added: [{ name: "writing", description: "Write prose" }],
|
||||
removed: [{ name: "retired", description: "Old" }],
|
||||
changed: [
|
||||
{
|
||||
previous: { name: "effect", description: "Build with Effect" },
|
||||
current: { name: "effect", description: "Build with Effect v4" },
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -281,7 +353,7 @@ describe("SystemContext", () => {
|
||||
stringContext({ key: "core/date", value: "date" }),
|
||||
stringContext({ key: "core/location", value: "location" }),
|
||||
]),
|
||||
)).baseline,
|
||||
)).text,
|
||||
).toBe("date\n\nlocation")
|
||||
}),
|
||||
)
|
||||
@@ -295,13 +367,13 @@ describe("SystemContext", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("requires namespaced durable snapshot keys", () =>
|
||||
it.effect("requires namespaced applied keys", () =>
|
||||
Effect.sync(() => {
|
||||
const decodeSnapshot = Schema.decodeUnknownSync(SystemContext.Snapshot)
|
||||
const decodeApplied = Schema.decodeUnknownSync(SystemContext.Applied)
|
||||
|
||||
expect(Object.keys(decodeSnapshot({ "core/date": { value: "date" } }))).toEqual(["core/date"])
|
||||
expect(() => decodeSnapshot({ date: { value: "date" } })).toThrow()
|
||||
expect(() => decodeSnapshot({ "core/date": { value: "date", removed: "" } })).toThrow()
|
||||
expect(Object.keys(decodeApplied({ "core/date": { value: "date" } }))).toEqual(["core/date"])
|
||||
expect(() => decodeApplied({ date: { value: "date" } })).toThrow()
|
||||
expect(() => decodeApplied({ "core/date": { value: "date", removed: "" } })).toThrow()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Cause, Effect, Exit, Schema, Scope } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { SystemContext } from "@opencode-ai/core/system-context"
|
||||
import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const entry = (key: string, text: string, sourceKey = key) => ({
|
||||
key: SystemContext.Key.make(key),
|
||||
load: Effect.succeed(
|
||||
SystemContext.make({
|
||||
key: SystemContext.Key.make(sourceKey),
|
||||
codec: Schema.toCodecJson(Schema.String),
|
||||
load: Effect.succeed(text),
|
||||
baseline: String,
|
||||
update: (_previous, current) => current,
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(SystemContextRegistry.node))
|
||||
|
||||
describe("SystemContextRegistry", () => {
|
||||
it.effect("loads empty system context when there are no entries", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* SystemContextRegistry.Service
|
||||
|
||||
expect(yield* SystemContext.initialize(yield* registry.load())).toEqual({ baseline: "", snapshot: {} })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("loads scoped entries in stable key order", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* SystemContextRegistry.Service
|
||||
yield* registry.register(entry("test/second", "second"))
|
||||
yield* registry.register(entry("test/first", "first"))
|
||||
|
||||
expect((yield* SystemContext.initialize(yield* registry.load())).baseline).toBe("first\n\nsecond")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("re-evaluates entry producers on each load", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* SystemContextRegistry.Service
|
||||
let loads = 0
|
||||
yield* registry.register({
|
||||
key: SystemContext.Key.make("test/dynamic"),
|
||||
load: Effect.sync(() => {
|
||||
loads++
|
||||
return SystemContext.empty
|
||||
}),
|
||||
})
|
||||
|
||||
yield* registry.load()
|
||||
yield* registry.load()
|
||||
|
||||
expect(loads).toBe(2)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("propagates entry producer failures", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* SystemContextRegistry.Service
|
||||
const failure = new Error("entry failed")
|
||||
yield* registry.register({ key: SystemContext.Key.make("test/failure"), load: Effect.die(failure) })
|
||||
|
||||
const exit = yield* registry.load().pipe(Effect.exit)
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBe(failure)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects duplicate source keys from separate entries", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* SystemContextRegistry.Service
|
||||
yield* registry.register(entry("test/first", "first", "test/duplicate"))
|
||||
yield* registry.register(entry("test/second", "second", "test/duplicate"))
|
||||
|
||||
const exit = yield* registry.load().pipe(Effect.exit)
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
expect(Cause.squash(exit.cause)).toBeInstanceOf(SystemContext.DuplicateKeyError)
|
||||
expect(Cause.squash(exit.cause)).toMatchObject({ key: SystemContext.Key.make("test/duplicate") })
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects duplicate entry keys", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* SystemContextRegistry.Service
|
||||
yield* registry.register(entry("test/duplicate", "first"))
|
||||
|
||||
const exit = yield* registry.register(entry("test/duplicate", "second", "test/other")).pipe(Effect.exit)
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("Duplicate system context entry key")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("removes an entry when its owning scope closes", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* SystemContextRegistry.Service
|
||||
const scope = yield* Scope.make()
|
||||
yield* registry.register(entry("test/scoped", "scoped")).pipe(Scope.provide(scope))
|
||||
|
||||
expect((yield* SystemContext.initialize(yield* registry.load())).baseline).toBe("scoped")
|
||||
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(yield* SystemContext.initialize(yield* registry.load())).toEqual({ baseline: "", snapshot: {} })
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -148,9 +148,22 @@ const AnthropicToolChoice = Schema.Union([
|
||||
Schema.Struct({ type: Schema.tag("tool"), name: Schema.String }),
|
||||
])
|
||||
|
||||
const AnthropicThinking = Schema.Struct({
|
||||
type: Schema.tag("enabled"),
|
||||
budget_tokens: Schema.Number,
|
||||
const AnthropicThinking = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.tag("enabled"),
|
||||
budget_tokens: Schema.Number,
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.tag("adaptive"),
|
||||
display: Schema.optional(Schema.Literals(["summarized", "omitted"])),
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.tag("disabled"),
|
||||
}),
|
||||
])
|
||||
|
||||
const AnthropicOutputConfig = Schema.Struct({
|
||||
effort: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const AnthropicBodyFields = {
|
||||
@@ -166,6 +179,7 @@ const AnthropicBodyFields = {
|
||||
top_k: Schema.optional(Schema.Number),
|
||||
stop_sequences: optionalArray(Schema.String),
|
||||
thinking: Schema.optional(AnthropicThinking),
|
||||
output_config: Schema.optional(AnthropicOutputConfig),
|
||||
}
|
||||
const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields)
|
||||
export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesBody>
|
||||
@@ -492,7 +506,18 @@ const anthropicOptions = (request: LLMRequest) => request.providerOptions?.anthr
|
||||
|
||||
const lowerThinking = Effect.fn("AnthropicMessages.lowerThinking")(function* (request: LLMRequest) {
|
||||
const thinking = anthropicOptions(request)?.thinking
|
||||
if (!ProviderShared.isRecord(thinking) || thinking.type !== "enabled") return undefined
|
||||
if (!ProviderShared.isRecord(thinking)) return undefined
|
||||
if (thinking.type === "adaptive") {
|
||||
const display =
|
||||
thinking.display === "summarized"
|
||||
? ("summarized" as const)
|
||||
: thinking.display === "omitted"
|
||||
? ("omitted" as const)
|
||||
: undefined
|
||||
return { type: "adaptive" as const, ...(display === undefined ? {} : { display }) }
|
||||
}
|
||||
if (thinking.type === "disabled") return { type: "disabled" as const }
|
||||
if (thinking.type !== "enabled") return undefined
|
||||
const budget =
|
||||
typeof thinking.budgetTokens === "number"
|
||||
? thinking.budgetTokens
|
||||
@@ -503,6 +528,11 @@ const lowerThinking = Effect.fn("AnthropicMessages.lowerThinking")(function* (re
|
||||
return { type: "enabled" as const, budget_tokens: budget }
|
||||
})
|
||||
|
||||
const outputConfig = (request: LLMRequest) => {
|
||||
const effort = anthropicOptions(request)?.effort
|
||||
return typeof effort === "string" ? { effort } : undefined
|
||||
}
|
||||
|
||||
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
|
||||
const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined
|
||||
const generation = request.generation
|
||||
@@ -549,6 +579,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
top_k: generation?.topK,
|
||||
stop_sequences: generation?.stop,
|
||||
thinking: yield* lowerThinking(request),
|
||||
output_config: outputConfig(request),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -168,8 +168,6 @@ interface ParserState {
|
||||
readonly lifecycle: Lifecycle.State
|
||||
}
|
||||
|
||||
const invalid = ProviderShared.invalidRequest
|
||||
|
||||
// =============================================================================
|
||||
// Request Lowering
|
||||
// =============================================================================
|
||||
@@ -333,8 +331,6 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
|
||||
const lowerOptions = Effect.fn("OpenAIChat.lowerOptions")(function* (request: LLMRequest) {
|
||||
const store = OpenAIOptions.store(request)
|
||||
const reasoningEffort = OpenAIOptions.reasoningEffort(request)
|
||||
if (reasoningEffort && !OpenAIOptions.isReasoningEffort(reasoningEffort))
|
||||
return yield* invalid(`OpenAI Chat does not support reasoning effort ${reasoningEffort}`)
|
||||
return {
|
||||
...(store !== undefined ? { store } : {}),
|
||||
...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}),
|
||||
|
||||
@@ -457,8 +457,6 @@ const lowerOptions = Effect.fn("OpenAIResponses.lowerOptions")(function* (reques
|
||||
const store = OpenAIOptions.store(request)
|
||||
const promptCacheKey = OpenAIOptions.promptCacheKey(request)
|
||||
const effort = OpenAIOptions.reasoningEffort(request)
|
||||
if (effort && !OpenAIOptions.isReasoningEffort(effort))
|
||||
return yield* invalid(`OpenAI Responses does not support reasoning effort ${effort}`)
|
||||
const summary = OpenAIOptions.reasoningSummary(request)
|
||||
const include = OpenAIOptions.include(request)
|
||||
const verbosity = OpenAIOptions.textVerbosity(request)
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Schema } from "effect"
|
||||
import type { LLMRequest, ReasoningEffort, TextVerbosity as TextVerbosityValue } from "../../schema"
|
||||
import type { LLMRequest, TextVerbosity as TextVerbosityValue } from "../../schema"
|
||||
import { ReasoningEfforts, TextVerbosity } from "../../schema"
|
||||
|
||||
export const OpenAIReasoningEfforts = ReasoningEfforts.filter(
|
||||
(effort): effort is Exclude<ReasoningEffort, "max"> => effort !== "max",
|
||||
)
|
||||
export type OpenAIReasoningEffort = (typeof OpenAIReasoningEfforts)[number]
|
||||
export const OpenAIReasoningEfforts = ReasoningEfforts
|
||||
export type OpenAIReasoningEffort = string
|
||||
|
||||
// Mirrors OpenAI's `ResponseIncludable` union from the official SDK. Keep this
|
||||
// in lockstep with `openai-node/src/resources/responses/responses.ts`.
|
||||
@@ -23,22 +21,16 @@ export type OpenAIResponseIncludable = (typeof OpenAIResponseIncludables)[number
|
||||
export const OpenAIServiceTiers = ["auto", "default", "flex", "priority"] as const
|
||||
export type OpenAIServiceTier = (typeof OpenAIServiceTiers)[number]
|
||||
|
||||
const REASONING_EFFORTS = new Set<string>(ReasoningEfforts)
|
||||
const OPENAI_REASONING_EFFORTS = new Set<string>(OpenAIReasoningEfforts)
|
||||
const TEXT_VERBOSITY = new Set<string>(["low", "medium", "high"])
|
||||
const INCLUDABLES = new Set<string>(OpenAIResponseIncludables)
|
||||
const SERVICE_TIERS = new Set<string>(OpenAIServiceTiers)
|
||||
|
||||
export const OpenAIReasoningEffort = Schema.Literals(OpenAIReasoningEfforts)
|
||||
export const OpenAIReasoningEffort = Schema.String
|
||||
export const OpenAITextVerbosity = TextVerbosity
|
||||
export const OpenAIResponseIncludable = Schema.Literals(OpenAIResponseIncludables)
|
||||
export const OpenAIServiceTier = Schema.Literals(OpenAIServiceTiers)
|
||||
|
||||
const isAnyReasoningEffort = (effort: unknown): effort is ReasoningEffort =>
|
||||
typeof effort === "string" && REASONING_EFFORTS.has(effort)
|
||||
|
||||
export const isReasoningEffort = (effort: unknown): effort is OpenAIReasoningEffort =>
|
||||
typeof effort === "string" && OPENAI_REASONING_EFFORTS.has(effort)
|
||||
export const isReasoningEffort = (effort: unknown): effort is OpenAIReasoningEffort => typeof effort === "string"
|
||||
|
||||
const isTextVerbosity = (value: unknown): value is TextVerbosityValue =>
|
||||
typeof value === "string" && TEXT_VERBOSITY.has(value)
|
||||
@@ -50,9 +42,9 @@ export const store = (request: LLMRequest): boolean | undefined => {
|
||||
return typeof value === "boolean" ? value : undefined
|
||||
}
|
||||
|
||||
export const reasoningEffort = (request: LLMRequest): ReasoningEffort | undefined => {
|
||||
export const reasoningEffort = (request: LLMRequest): string | undefined => {
|
||||
const value = options(request)?.reasoningEffort
|
||||
return isAnyReasoningEffort(value) ? value : undefined
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
|
||||
export const reasoningSummary = (request: LLMRequest): "auto" | undefined =>
|
||||
|
||||
@@ -27,7 +27,7 @@ export const ToolCallID = Schema.String
|
||||
export type ToolCallID = Schema.Schema.Type<typeof ToolCallID>
|
||||
|
||||
export const ReasoningEfforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const
|
||||
export const ReasoningEffort = Schema.Literals(ReasoningEfforts)
|
||||
export const ReasoningEffort = Schema.String
|
||||
export type ReasoningEffort = Schema.Schema.Type<typeof ReasoningEffort>
|
||||
|
||||
export const TextVerbosity = Schema.Literals(["low", "medium", "high"])
|
||||
|
||||
@@ -57,6 +57,23 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers adaptive thinking settings with effort", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||
LLM.updateRequest(request, {
|
||||
providerOptions: {
|
||||
anthropic: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
thinking: { type: "adaptive", display: "summarized" },
|
||||
output_config: { effort: "low" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers chronological system updates natively for Claude Opus 4.8 with cache hints", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||
|
||||
@@ -98,12 +98,26 @@ describe("OpenAI Chat route", () => {
|
||||
LLM.request({
|
||||
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).chat("gpt-4o-mini"),
|
||||
prompt: "think",
|
||||
providerOptions: { openai: { reasoningEffort: "low" } },
|
||||
providerOptions: { openai: { reasoningEffort: "max" } },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.store).toBe(false)
|
||||
expect(prepared.body.reasoning_effort).toBe("low")
|
||||
expect(prepared.body.reasoning_effort).toBe("max")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("passes through custom OpenAI-compatible reasoning effort strings", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "think",
|
||||
providerOptions: { openai: { reasoningEffort: "experimental" } },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.reasoning_effort).toBe("experimental")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -69,6 +69,16 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("passes through custom OpenAI reasoning effort strings", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
LLM.updateRequest(request, { providerOptions: { openai: { reasoningEffort: "experimental" } } }),
|
||||
)
|
||||
|
||||
expect(prepared.body.reasoning).toEqual({ effort: "experimental" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits unsupported semantic service tiers", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
|
||||
@@ -43,32 +43,8 @@ export const AttachCommand = cmd({
|
||||
alias: ["u"],
|
||||
type: "string",
|
||||
describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')",
|
||||
})
|
||||
.option("mini", {
|
||||
type: "boolean",
|
||||
describe: "start the minimal interactive interface",
|
||||
default: false,
|
||||
})
|
||||
.option("replay", {
|
||||
type: "boolean",
|
||||
hidden: true,
|
||||
})
|
||||
.option("no-replay", {
|
||||
type: "boolean",
|
||||
describe: "disable mini session history replay on resume and after resize",
|
||||
})
|
||||
.option("replay-limit", {
|
||||
type: "number",
|
||||
describe: "cap visible mini replay to the newest N messages",
|
||||
}),
|
||||
handler: async (args) => {
|
||||
if (args.replay === true) {
|
||||
UI.error("--replay is not supported; replay is enabled by default")
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
const noReplay = args.replay === false || args.noReplay === true
|
||||
|
||||
const directory = (() => {
|
||||
if (!args.dir) return undefined
|
||||
try {
|
||||
@@ -80,32 +56,6 @@ export const AttachCommand = cmd({
|
||||
}
|
||||
})()
|
||||
|
||||
if (args.mini) {
|
||||
const { runMini } = await import("./run")
|
||||
await runMini({
|
||||
attach: args.url,
|
||||
directory,
|
||||
password: args.password,
|
||||
username: args.username,
|
||||
continue: args.continue,
|
||||
session: args.session,
|
||||
fork: args.fork,
|
||||
replay: noReplay ? false : undefined,
|
||||
replayLimit: args.replayLimit,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const unsupported = [
|
||||
["--no-replay", noReplay],
|
||||
["--replay-limit", args.replayLimit !== undefined],
|
||||
].find((entry) => entry[1])?.[0]
|
||||
if (unsupported) {
|
||||
UI.error(`${unsupported} requires --mini`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
const { TuiConfig } = await import("@/config/tui")
|
||||
if (args.fork && !args.continue && !args.session) {
|
||||
UI.error("--fork requires --continue or --session")
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { Argv } from "yargs"
|
||||
import { cmd } from "./cmd"
|
||||
import { UI } from "@/cli/ui"
|
||||
import { resolveThreadDirectory } from "./tui"
|
||||
|
||||
type ReplayArgs = {
|
||||
replay?: boolean
|
||||
noReplay?: boolean
|
||||
}
|
||||
|
||||
type MiniArgs = ReplayArgs & {
|
||||
continue?: boolean
|
||||
session?: string
|
||||
fork?: boolean
|
||||
replayLimit?: number
|
||||
}
|
||||
|
||||
type MiniLocalArgs = MiniArgs & {
|
||||
project?: string
|
||||
model?: string
|
||||
agent?: string
|
||||
prompt?: string
|
||||
demo?: boolean
|
||||
}
|
||||
|
||||
type MiniAttachArgs = MiniArgs & {
|
||||
url: string
|
||||
dir?: string
|
||||
password?: string
|
||||
username?: string
|
||||
}
|
||||
|
||||
function replay(args: ReplayArgs) {
|
||||
if (args.replay === true) {
|
||||
UI.error("--replay is not supported; replay is enabled by default")
|
||||
process.exitCode = 1
|
||||
return "invalid" as const
|
||||
}
|
||||
return args.replay === false || args.noReplay === true ? false : undefined
|
||||
}
|
||||
|
||||
function miniOptions<T>(yargs: Argv<T>) {
|
||||
return yargs
|
||||
.option("continue", {
|
||||
alias: ["c"],
|
||||
describe: "continue the last session",
|
||||
type: "boolean",
|
||||
})
|
||||
.option("session", {
|
||||
alias: ["s"],
|
||||
describe: "session id to continue",
|
||||
type: "string",
|
||||
})
|
||||
.option("fork", {
|
||||
type: "boolean",
|
||||
describe: "fork the session when continuing (use with --continue or --session)",
|
||||
})
|
||||
.option("replay", {
|
||||
type: "boolean",
|
||||
hidden: true,
|
||||
})
|
||||
.option("no-replay", {
|
||||
type: "boolean",
|
||||
describe: "disable session history replay on resume and after resize",
|
||||
})
|
||||
.option("replay-limit", {
|
||||
type: "number",
|
||||
describe: "cap visible replay to the newest N messages",
|
||||
})
|
||||
}
|
||||
|
||||
/** @internal Exported for CLI parser tests. */
|
||||
export const MiniLocalCommand = cmd<{}, MiniLocalArgs>({
|
||||
command: "$0 [project]",
|
||||
describe: "start the minimal interactive interface",
|
||||
builder: (yargs) =>
|
||||
miniOptions(
|
||||
yargs
|
||||
.positional("project", {
|
||||
type: "string",
|
||||
describe: "path to start opencode in",
|
||||
})
|
||||
.option("model", {
|
||||
type: "string",
|
||||
alias: ["m"],
|
||||
describe: "model to use in the format of provider/model",
|
||||
})
|
||||
.option("agent", {
|
||||
type: "string",
|
||||
describe: "agent to use",
|
||||
})
|
||||
.option("prompt", {
|
||||
type: "string",
|
||||
describe: "prompt to use",
|
||||
})
|
||||
.option("demo", {
|
||||
type: "boolean",
|
||||
hidden: true,
|
||||
}),
|
||||
),
|
||||
handler: async (args) => {
|
||||
const shouldReplay = replay(args)
|
||||
if (shouldReplay === "invalid") return
|
||||
|
||||
const { runMini } = await import("./run")
|
||||
await runMini({
|
||||
directory: resolveThreadDirectory(args.project),
|
||||
continue: args.continue,
|
||||
session: args.session,
|
||||
fork: args.fork,
|
||||
model: args.model,
|
||||
agent: args.agent,
|
||||
prompt: args.prompt,
|
||||
replay: shouldReplay,
|
||||
replayLimit: args.replayLimit,
|
||||
demo: args.demo,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
/** @internal Exported for CLI parser tests. */
|
||||
export const MiniAttachCommand = cmd<{}, MiniAttachArgs>({
|
||||
command: "attach <url>",
|
||||
describe: "attach to a running opencode server with the minimal interface",
|
||||
builder: (yargs) =>
|
||||
miniOptions(
|
||||
yargs
|
||||
.positional("url", {
|
||||
type: "string",
|
||||
describe: "http://localhost:4096",
|
||||
demandOption: true,
|
||||
})
|
||||
.option("dir", {
|
||||
type: "string",
|
||||
describe: "directory on the remote server",
|
||||
})
|
||||
.option("password", {
|
||||
alias: ["p"],
|
||||
type: "string",
|
||||
describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)",
|
||||
})
|
||||
.option("username", {
|
||||
alias: ["u"],
|
||||
type: "string",
|
||||
describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')",
|
||||
}),
|
||||
),
|
||||
handler: async (args) => {
|
||||
const shouldReplay = replay(args)
|
||||
if (shouldReplay === "invalid") return
|
||||
|
||||
const { runMini } = await import("./run")
|
||||
await runMini({
|
||||
attach: args.url,
|
||||
directory: args.dir,
|
||||
password: args.password,
|
||||
username: args.username,
|
||||
continue: args.continue,
|
||||
session: args.session,
|
||||
fork: args.fork,
|
||||
replay: shouldReplay,
|
||||
replayLimit: args.replayLimit,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const MiniCommand = cmd({
|
||||
command: "mini",
|
||||
describe: "start the minimal interactive interface",
|
||||
builder: (yargs) => yargs.command(MiniLocalCommand).command(MiniAttachCommand).demandCommand(),
|
||||
handler: async () => {},
|
||||
})
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { PermissionV1 } from "@opencode-ai/core/v1/permission"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
// CLI entry point for `opencode run` and `opencode --mini`.
|
||||
// CLI entry point for `opencode run` and `opencode mini`.
|
||||
//
|
||||
// Handles three modes:
|
||||
// 1. Non-interactive (default): sends a single prompt, streams events to
|
||||
// stdout, and exits when the session goes idle.
|
||||
// 2. Interactive local (`opencode --mini`): boots the split-footer direct mode
|
||||
// 2. Interactive local (`opencode mini`): boots the split-footer direct mode
|
||||
// with an in-process server (no external HTTP).
|
||||
// 3. Interactive attach (`opencode --mini --attach`): connects to a running
|
||||
// 3. Interactive attach (`opencode mini attach`): connects to a running
|
||||
// opencode server and runs interactive mode against it.
|
||||
//
|
||||
// Also supports `--command` for slash-command execution, `--format json` for
|
||||
@@ -25,6 +25,8 @@ import { Filesystem } from "@/util/filesystem"
|
||||
import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import { FormatError, FormatUnknownError } from "../error"
|
||||
import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./run/runtime.stdin"
|
||||
import { isImageAttachment, isPdfAttachment } from "@/util/media"
|
||||
import { loadRunAgents } from "./run/catalog.shared"
|
||||
|
||||
type ModelInput = Parameters<OpencodeClient["session"]["prompt"]>[0]["model"]
|
||||
|
||||
@@ -49,6 +51,14 @@ function resolveRunInput(value?: string, piped?: string): string | undefined {
|
||||
return value + "\n" + piped
|
||||
}
|
||||
|
||||
function isBinaryContent(bytes: Uint8Array) {
|
||||
if (bytes.length === 0) return false
|
||||
if (bytes.includes(0)) return true
|
||||
return (
|
||||
bytes.reduce((count, byte) => count + Number(byte < 9 || (byte > 13 && byte < 32)), 0) / bytes.length > 0.3
|
||||
)
|
||||
}
|
||||
|
||||
type FilePart = {
|
||||
type: "file"
|
||||
url: string
|
||||
@@ -68,6 +78,7 @@ type SessionInfo = {
|
||||
id: string
|
||||
title?: string
|
||||
directory?: string
|
||||
current?: boolean
|
||||
}
|
||||
|
||||
function inline(info: Inline) {
|
||||
@@ -158,10 +169,6 @@ export const RunCommand = effectCmd({
|
||||
describe: "fork the session before continuing (requires --continue or --session)",
|
||||
type: "boolean",
|
||||
})
|
||||
.option("share", {
|
||||
type: "boolean",
|
||||
describe: "share the session",
|
||||
})
|
||||
.option("model", {
|
||||
type: "string",
|
||||
alias: ["m"],
|
||||
@@ -217,11 +224,6 @@ export const RunCommand = effectCmd({
|
||||
type: "boolean",
|
||||
describe: "show thinking blocks",
|
||||
})
|
||||
.option("mini", {
|
||||
type: "boolean",
|
||||
hidden: true,
|
||||
default: false,
|
||||
})
|
||||
.option("replay", {
|
||||
type: "boolean",
|
||||
default: true,
|
||||
@@ -270,7 +272,7 @@ export const RunCommand = effectCmd({
|
||||
const localInstance = yield* InstanceRef
|
||||
yield* Effect.promise(async () => {
|
||||
const rawMessage = [...args.message, ...(args["--"] || [])].join(" ")
|
||||
const interactive = args.mini
|
||||
const interactive = (args as typeof args & { mini?: boolean }).mini === true
|
||||
const auto = args.auto || args.yolo || args["dangerously-skip-permissions"]
|
||||
const thinking = interactive ? (args.thinking ?? true) : (args.thinking ?? false)
|
||||
const die = (message: string): never => {
|
||||
@@ -290,23 +292,23 @@ export const RunCommand = effectCmd({
|
||||
.join(" ")
|
||||
|
||||
if (interactive && args.command) {
|
||||
die("--mini cannot be used with --command")
|
||||
die("opencode mini cannot be used with --command")
|
||||
}
|
||||
|
||||
if (interactive && args._?.[0] !== "mini") {
|
||||
die("--mini must be used without the run subcommand")
|
||||
die("opencode mini must be run with the mini command")
|
||||
}
|
||||
|
||||
if (args.demo && !interactive) {
|
||||
die("--demo requires --mini")
|
||||
die("--demo requires opencode mini")
|
||||
}
|
||||
|
||||
if (interactive && args.format === "json") {
|
||||
die("--mini cannot be used with --format json")
|
||||
die("opencode mini cannot be used with --format json")
|
||||
}
|
||||
|
||||
if (args["replay-limit"] !== undefined && !interactive) {
|
||||
die("--replay-limit requires --mini")
|
||||
die("--replay-limit requires opencode mini")
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -317,7 +319,7 @@ export const RunCommand = effectCmd({
|
||||
}
|
||||
|
||||
if (interactive && !process.stdout.isTTY) {
|
||||
die("--mini requires a TTY stdout")
|
||||
die("opencode mini requires a TTY stdout")
|
||||
}
|
||||
|
||||
if (interactive) {
|
||||
@@ -355,6 +357,12 @@ export const RunCommand = effectCmd({
|
||||
}
|
||||
|
||||
const files: FilePart[] = []
|
||||
const fileInputs: Array<{
|
||||
filePath: string
|
||||
resolvedPath: string
|
||||
stat: ReturnType<typeof Filesystem.stat>
|
||||
isDirectory: boolean
|
||||
}> = []
|
||||
if (args.file) {
|
||||
const list = Array.isArray(args.file) ? args.file : [args.file]
|
||||
|
||||
@@ -371,45 +379,7 @@ export const RunCommand = effectCmd({
|
||||
UI.error(`Cannot attach local directory without a shared filesystem: ${filePath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const content = await (async () => {
|
||||
if (!args.attach) return
|
||||
const handle = await open(resolvedPath, "r")
|
||||
try {
|
||||
const opened = await handle.stat()
|
||||
if (!opened.isFile() || Number(opened.size) > ATTACH_FILE_MAX_BYTES) {
|
||||
UI.error(`Cannot attach local file larger than 10 MiB or a special file: ${filePath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
if (opened.size === 0) return Buffer.alloc(0)
|
||||
const buffer = Buffer.alloc(Number(opened.size))
|
||||
let offset = 0
|
||||
while (offset < buffer.length) {
|
||||
const read = await handle.read(buffer, offset, buffer.length - offset, offset)
|
||||
if (read.bytesRead === 0) break
|
||||
offset += read.bytesRead
|
||||
}
|
||||
return buffer.subarray(0, offset)
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
})()
|
||||
const detected = FSUtil.mimeType(resolvedPath)
|
||||
const text = content?.toString("utf8")
|
||||
const mime = !args.attach
|
||||
? isDirectory
|
||||
? "application/x-directory"
|
||||
: "text/plain"
|
||||
: content && text !== undefined && Buffer.from(text, "utf8").equals(content)
|
||||
? "text/plain"
|
||||
: detected
|
||||
|
||||
files.push({
|
||||
type: "file",
|
||||
url: content ? `data:${mime};base64,${content.toString("base64")}` : pathToFileURL(resolvedPath).href,
|
||||
filename: path.basename(resolvedPath),
|
||||
mime,
|
||||
})
|
||||
fileInputs.push({ filePath, resolvedPath, stat, isDirectory })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,6 +416,53 @@ export const RunCommand = effectCmd({
|
||||
pattern: "*",
|
||||
},
|
||||
]
|
||||
const currentPrompt = !interactive && !args.command && fileInputs.every((file) => !file.isDirectory)
|
||||
|
||||
const inlineFiles = interactive || currentPrompt
|
||||
for (const file of fileInputs) {
|
||||
const content = await (async () => {
|
||||
if (file.isDirectory || !inlineFiles) return
|
||||
if (!file.stat?.isFile() || file.stat.size > ATTACH_FILE_MAX_BYTES) {
|
||||
UI.error(`Cannot attach local file larger than 10 MiB or a special file: ${file.filePath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
const handle = await open(file.resolvedPath, "r")
|
||||
try {
|
||||
const opened = await handle.stat()
|
||||
if (!opened.isFile() || Number(opened.size) > ATTACH_FILE_MAX_BYTES) {
|
||||
UI.error(`Cannot attach local file larger than 10 MiB or a special file: ${file.filePath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
if (opened.size === 0) return Buffer.alloc(0)
|
||||
const buffer = Buffer.alloc(Number(opened.size))
|
||||
let offset = 0
|
||||
while (offset < buffer.length) {
|
||||
const read = await handle.read(buffer, offset, buffer.length - offset, offset)
|
||||
if (read.bytesRead === 0) break
|
||||
offset += read.bytesRead
|
||||
}
|
||||
return buffer.subarray(0, offset)
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
})()
|
||||
const detected = FSUtil.mimeType(file.resolvedPath)
|
||||
const text = content?.toString("utf8")
|
||||
const mime = file.isDirectory
|
||||
? "application/x-directory"
|
||||
: isImageAttachment(detected) || isPdfAttachment(detected)
|
||||
? detected
|
||||
: content && !isBinaryContent(content) && text !== undefined && Buffer.from(text, "utf8").equals(content)
|
||||
? "text/plain"
|
||||
: detected
|
||||
|
||||
files.push({
|
||||
type: "file",
|
||||
url: content ? `data:${mime};base64,${content.toString("base64")}` : pathToFileURL(file.resolvedPath).href,
|
||||
filename: path.basename(file.resolvedPath),
|
||||
mime,
|
||||
})
|
||||
}
|
||||
|
||||
function title() {
|
||||
if (args.title === undefined) return
|
||||
@@ -453,58 +470,122 @@ export const RunCommand = effectCmd({
|
||||
return message.slice(0, 50) + (message.length > 50 ? "..." : "")
|
||||
}
|
||||
|
||||
async function session(sdk: OpencodeClient): Promise<SessionInfo | undefined> {
|
||||
if (args.session) {
|
||||
const current = await sdk.session
|
||||
.get({
|
||||
sessionID: args.session,
|
||||
})
|
||||
.catch(() => undefined)
|
||||
async function currentSession(sdk: OpencodeClient, sessionID: string): Promise<SessionInfo | undefined> {
|
||||
const listed = await sdk.v2.session
|
||||
.list({
|
||||
directory: await current(sdk),
|
||||
limit: 50,
|
||||
order: "desc",
|
||||
})
|
||||
.then((result) => result.data?.data.find((item) => item.id === sessionID))
|
||||
.catch(() => undefined)
|
||||
const selected =
|
||||
listed ??
|
||||
(await sdk.v2.session
|
||||
.get({ sessionID })
|
||||
.then((result) => result.data?.data)
|
||||
.catch(() => undefined))
|
||||
const legacy =
|
||||
selected ??
|
||||
(await sdk.session
|
||||
.get({ sessionID })
|
||||
.then((result) => result.data)
|
||||
.catch(() => undefined))
|
||||
const transcript = await transcriptKind(sdk, legacy?.id ?? sessionID)
|
||||
if (!legacy && transcript === "empty") {
|
||||
return
|
||||
}
|
||||
if (interactive && transcript === "legacy") {
|
||||
throw new Error("Mini cannot resume a legacy Session transcript")
|
||||
}
|
||||
|
||||
if (!current?.data) {
|
||||
UI.error("Session not found")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (args.fork) {
|
||||
const forked = await sdk.session.fork({
|
||||
sessionID: args.session,
|
||||
})
|
||||
const id = forked.data?.id
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
title: forked.data?.title ?? current.data.title,
|
||||
directory: forked.data?.directory ?? current.data.directory,
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: legacy?.id ?? sessionID,
|
||||
title: legacy?.title,
|
||||
directory: legacy ? ("location" in legacy ? legacy.location.directory : legacy.directory) : await current(sdk),
|
||||
current: transcript !== "legacy",
|
||||
}
|
||||
}
|
||||
|
||||
async function forkSession(sdk: OpencodeClient, session: SessionInfo): Promise<SessionInfo | undefined> {
|
||||
if (session.current !== false) {
|
||||
const forked = await sdk.v2.session.fork(
|
||||
{ sessionID: session.id, messageID: undefined },
|
||||
{ throwOnError: true },
|
||||
)
|
||||
await waitForFork(sdk, session.id, forked.data.data.id)
|
||||
return {
|
||||
id: current.data.id,
|
||||
title: current.data.title,
|
||||
directory: current.data.directory,
|
||||
id: forked.data.data.id,
|
||||
title: forked.data.data.title,
|
||||
directory: forked.data.data.location.directory,
|
||||
current: true,
|
||||
}
|
||||
}
|
||||
|
||||
const base = args.continue ? (await sdk.session.list()).data?.find((item) => !item.parentID) : undefined
|
||||
const forked = await sdk.session.fork({
|
||||
sessionID: session.id,
|
||||
})
|
||||
const id = forked.data?.id
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
|
||||
if (base && args.fork) {
|
||||
const forked = await sdk.session.fork({
|
||||
sessionID: base.id,
|
||||
})
|
||||
const id = forked.data?.id
|
||||
if (!id) {
|
||||
return {
|
||||
id,
|
||||
title: forked.data?.title ?? session.title,
|
||||
directory: forked.data?.directory ?? session.directory,
|
||||
current: false,
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForFork(sdk: OpencodeClient, parentID: string, sessionID: string) {
|
||||
const parentHasMessages = await sdk.v2.session
|
||||
.messages({ sessionID: parentID, limit: 1 })
|
||||
.then((result) => (result.data?.data.length ?? 0) > 0)
|
||||
.catch(() => false)
|
||||
if (!parentHasMessages) {
|
||||
return
|
||||
}
|
||||
|
||||
const deadline = Date.now() + 3000
|
||||
while (Date.now() < deadline) {
|
||||
const forkedHasMessages = await sdk.v2.session
|
||||
.messages({ sessionID, limit: 1 })
|
||||
.then((result) => (result.data?.data.length ?? 0) > 0)
|
||||
.catch(() => false)
|
||||
if (forkedHasMessages) {
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
title: forked.data?.title ?? base.title,
|
||||
directory: forked.data?.directory ?? base.directory,
|
||||
await Bun.sleep(25)
|
||||
}
|
||||
}
|
||||
|
||||
async function session(sdk: OpencodeClient): Promise<SessionInfo | undefined> {
|
||||
if (args.session) {
|
||||
const current = await currentSession(sdk, args.session)
|
||||
if (!current) {
|
||||
UI.error("Session not found")
|
||||
process.exit(1)
|
||||
}
|
||||
if (!interactive && !currentPrompt && current.current !== false) {
|
||||
throw new Error("This operation is not available for a current Session transcript")
|
||||
}
|
||||
|
||||
if (args.fork) {
|
||||
return forkSession(sdk, current)
|
||||
}
|
||||
|
||||
return current
|
||||
}
|
||||
|
||||
const base = args.continue ? await currentRootSession(sdk) : undefined
|
||||
if (base && !interactive && !currentPrompt && base.current !== false) {
|
||||
throw new Error("This operation is not available for a current Session transcript")
|
||||
}
|
||||
|
||||
if (base && args.fork) {
|
||||
return forkSession(sdk, base)
|
||||
}
|
||||
|
||||
if (base) {
|
||||
@@ -512,6 +593,23 @@ export const RunCommand = effectCmd({
|
||||
id: base.id,
|
||||
title: base.title,
|
||||
directory: base.directory,
|
||||
current: "current" in base ? base.current : false,
|
||||
}
|
||||
}
|
||||
|
||||
if (interactive || currentPrompt) {
|
||||
const name = title()
|
||||
const result = await sdk.v2.session.create({
|
||||
location: { directory: await current(sdk) },
|
||||
})
|
||||
const created = result.data?.data
|
||||
if (!created) return
|
||||
if (name) await sdk.v2.session.rename({ sessionID: created.id, title: name })
|
||||
return {
|
||||
id: created.id,
|
||||
title: name ?? created.title,
|
||||
directory: created.location.directory,
|
||||
current: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -529,30 +627,45 @@ export const RunCommand = effectCmd({
|
||||
id,
|
||||
title: result.data?.title ?? name,
|
||||
directory: result.data?.directory,
|
||||
current: false,
|
||||
}
|
||||
}
|
||||
|
||||
async function share(sdk: OpencodeClient, sessionID: string) {
|
||||
const cfg = await sdk.config.get()
|
||||
if (!cfg.data) return
|
||||
if (cfg.data.share !== "auto" && !flags.autoShare && !args.share) return
|
||||
const res = await sdk.session.share({ sessionID }).catch((error) => {
|
||||
if (error instanceof Error && error.message.includes("disabled")) {
|
||||
UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message)
|
||||
}
|
||||
return { error }
|
||||
async function currentRootSession(sdk: OpencodeClient): Promise<SessionInfo | undefined> {
|
||||
const response = await sdk.v2.session.list({
|
||||
directory: await current(sdk),
|
||||
limit: 50,
|
||||
order: "desc",
|
||||
})
|
||||
if (!res.error && "data" in res && res.data?.share?.url) {
|
||||
UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + res.data.share.url)
|
||||
const root = (response.data?.data ?? [])
|
||||
.filter((session) => !session.parentID)
|
||||
.toSorted((a, b) => b.time.updated - a.time.updated)[0]
|
||||
if (!root) return
|
||||
return currentSession(sdk, root.id)
|
||||
}
|
||||
|
||||
async function transcriptKind(sdk: OpencodeClient, sessionID: string) {
|
||||
const current = await sdk.v2.session.messages({ sessionID, limit: 1 }).then((result) => (result.data?.data.length ?? 0) > 0)
|
||||
// Ordinary prompt flows assume a transcript with current messages is
|
||||
// current-owned; only legacy-only modes (--command, directory
|
||||
// attachments) still probe legacy history for mixed transcripts.
|
||||
if (current && (interactive || currentPrompt)) return "current" as const
|
||||
|
||||
const legacy = await sdk.session.messages({ sessionID, limit: 1 }).then((result) => (result.data?.length ?? 0) > 0)
|
||||
if (current) {
|
||||
if (legacy) throw new Error("Session contains mixed legacy and current transcripts")
|
||||
return "current" as const
|
||||
}
|
||||
if (legacy) return "legacy" as const
|
||||
return "empty" as const
|
||||
}
|
||||
|
||||
async function createFreshSession(
|
||||
sdk: OpencodeClient,
|
||||
input: { agent: string | undefined; model: ModelInput | undefined; variant: string | undefined },
|
||||
): Promise<SessionInfo> {
|
||||
const result = await sdk.session.create({
|
||||
title: args.title !== undefined && args.title !== "" ? args.title : undefined,
|
||||
const name = args.title !== undefined && args.title !== "" ? args.title : undefined
|
||||
const result = await sdk.v2.session.create({
|
||||
agent: input.agent,
|
||||
model: input.model
|
||||
? {
|
||||
@@ -561,17 +674,18 @@ export const RunCommand = effectCmd({
|
||||
variant: input.variant,
|
||||
}
|
||||
: undefined,
|
||||
permission: [...rules],
|
||||
location: { directory: await current(sdk) },
|
||||
})
|
||||
const id = result.data?.id
|
||||
const created = result.data?.data
|
||||
const id = created?.id
|
||||
if (!id) {
|
||||
throw new Error("Failed to create session")
|
||||
}
|
||||
if (name) await sdk.v2.session.rename({ sessionID: id, title: name })
|
||||
|
||||
void share(sdk, id).catch(() => {})
|
||||
return {
|
||||
id,
|
||||
title: result.data?.title,
|
||||
title: name ?? created.title,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -580,8 +694,8 @@ export const RunCommand = effectCmd({
|
||||
return directory ?? root
|
||||
}
|
||||
|
||||
const next = await sdk.path
|
||||
.get()
|
||||
const next = await sdk.v2.location
|
||||
.get(undefined, { throwOnError: true })
|
||||
.then((x) => x.data?.directory)
|
||||
.catch(() => undefined)
|
||||
if (next) {
|
||||
@@ -622,10 +736,7 @@ export const RunCommand = effectCmd({
|
||||
if (!args.agent) return undefined
|
||||
const name = args.agent
|
||||
|
||||
const modes = await sdk.app
|
||||
.agents(undefined, { throwOnError: true })
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => undefined)
|
||||
const modes = await loadRunAgents(sdk, await current(sdk)).catch(() => undefined)
|
||||
|
||||
if (!modes) {
|
||||
UI.println(
|
||||
@@ -636,7 +747,7 @@ export const RunCommand = effectCmd({
|
||||
return undefined
|
||||
}
|
||||
|
||||
const agent = modes.find((a) => a.name === name)
|
||||
const agent = modes.find((item) => item.name === name)
|
||||
if (!agent) {
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
@@ -823,9 +934,33 @@ export const RunCommand = effectCmd({
|
||||
// Validate agent if specified
|
||||
const agent = await pickAgent(client)
|
||||
|
||||
await share(client, sessionID)
|
||||
|
||||
if (!interactive) {
|
||||
if (currentPrompt && sess.current !== false) {
|
||||
const model = pick(args.model)
|
||||
const { runNonInteractivePrompt } = await import("./run/noninteractive")
|
||||
try {
|
||||
await runNonInteractivePrompt({
|
||||
client,
|
||||
sessionID,
|
||||
message,
|
||||
files,
|
||||
agent,
|
||||
model,
|
||||
variant: args.variant,
|
||||
thinking,
|
||||
format: args.format === "json" ? "json" : "default",
|
||||
dangerouslySkipPermissions: args["dangerously-skip-permissions"],
|
||||
renderTool: tool,
|
||||
renderToolError: toolError,
|
||||
})
|
||||
} catch (error) {
|
||||
const output = error instanceof Error ? { type: "unknown", message: error.message } : error
|
||||
if (!emit("error", { error: output })) UI.error(formatRunError(error))
|
||||
process.exitCode = 1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const events = await client.event.subscribe()
|
||||
const completed = loop(client, events).catch((e) => {
|
||||
console.error(e)
|
||||
@@ -880,7 +1015,7 @@ export const RunCommand = effectCmd({
|
||||
directory: cwd,
|
||||
sessionID,
|
||||
sessionTitle: sess.title,
|
||||
resume: Boolean(args.session || args.continue) && !args.fork,
|
||||
resume: Boolean(args.session || args.continue),
|
||||
replay,
|
||||
replayLimit: args["replay-limit"],
|
||||
agent,
|
||||
@@ -917,7 +1052,6 @@ export const RunCommand = effectCmd({
|
||||
fetch: fetchFn,
|
||||
resolveAgent: localAgent,
|
||||
session,
|
||||
share,
|
||||
createSession: createFreshSession,
|
||||
agent: args.agent,
|
||||
model,
|
||||
@@ -984,7 +1118,6 @@ export async function runMini(input: MiniCommandInput) {
|
||||
continue: input.continue,
|
||||
session: input.session,
|
||||
fork: input.fork,
|
||||
share: undefined,
|
||||
model: input.model,
|
||||
agent: input.agent,
|
||||
format: "default",
|
||||
@@ -1007,5 +1140,5 @@ export async function runMini(input: MiniCommandInput) {
|
||||
"dangerously-skip-permissions": false,
|
||||
dangerouslySkipPermissions: false,
|
||||
demo: input.demo ?? false,
|
||||
})
|
||||
} as Parameters<NonNullable<typeof RunCommand.handler>>[0] & { mini: boolean })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import type { RunAgent, RunCommand, RunProvider, RunReference } from "./types"
|
||||
|
||||
type CurrentAgent = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["agent"]["list"]>>["data"]>["data"][number]
|
||||
type CurrentCommand = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["command"]["list"]>>["data"]>["data"][number]
|
||||
type CurrentSkill = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["skill"]["list"]>>["data"]>["data"][number]
|
||||
type CurrentProvider = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["provider"]["list"]>>["data"]>["data"][number]
|
||||
type CurrentModel = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["model"]["list"]>>["data"]>["data"][number]
|
||||
|
||||
function location(directory: string) {
|
||||
return {
|
||||
location: {
|
||||
directory,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function defaultCost(model: CurrentModel) {
|
||||
const picked = model.cost.find((cost) => cost.tier === undefined) ?? model.cost[0]
|
||||
if (!picked) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
...picked,
|
||||
input: model.cost.every((cost) => cost.input === 0) ? 0 : picked.input,
|
||||
}
|
||||
}
|
||||
|
||||
export function runAgent(input: CurrentAgent): RunAgent {
|
||||
return {
|
||||
name: input.id,
|
||||
description: input.description,
|
||||
mode: input.mode,
|
||||
hidden: input.hidden,
|
||||
}
|
||||
}
|
||||
|
||||
export function runCommand(input: CurrentCommand): RunCommand {
|
||||
return {
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
}
|
||||
}
|
||||
|
||||
export function runSkill(input: CurrentSkill): RunCommand {
|
||||
return {
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
source: "skill",
|
||||
}
|
||||
}
|
||||
|
||||
export function runProviders(providers: CurrentProvider[], models: CurrentModel[]): RunProvider[] {
|
||||
const grouped = new Map<string, RunProvider>()
|
||||
|
||||
for (const provider of providers) {
|
||||
grouped.set(provider.id, {
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
models: {},
|
||||
})
|
||||
}
|
||||
|
||||
for (const model of models) {
|
||||
const provider = grouped.get(model.providerID) ?? {
|
||||
id: model.providerID,
|
||||
name: model.providerID,
|
||||
models: {},
|
||||
}
|
||||
provider.models[model.id] = {
|
||||
id: model.id,
|
||||
providerID: model.providerID,
|
||||
name: model.name,
|
||||
capabilities: model.capabilities,
|
||||
cost: defaultCost(model),
|
||||
limit: model.limit,
|
||||
status: model.status,
|
||||
variants: Object.fromEntries(model.variants.map((variant) => [variant.id, {}])),
|
||||
}
|
||||
grouped.set(provider.id, provider)
|
||||
}
|
||||
|
||||
return [...grouped.values()]
|
||||
}
|
||||
|
||||
export async function loadRunAgents(sdk: OpencodeClient, directory: string): Promise<RunAgent[]> {
|
||||
const result = await sdk.v2.agent.list(location(directory), { throwOnError: true })
|
||||
return (result.data?.data ?? []).map(runAgent)
|
||||
}
|
||||
|
||||
export async function loadRunCommands(sdk: OpencodeClient, directory: string): Promise<RunCommand[]> {
|
||||
const [commands, skills] = await Promise.all([
|
||||
sdk.v2.command.list(location(directory), { throwOnError: true }),
|
||||
sdk.v2.skill.list(location(directory), { throwOnError: true }),
|
||||
])
|
||||
return [
|
||||
...(commands.data?.data ?? []).map(runCommand),
|
||||
...(skills.data?.data ?? []).filter((skill) => skill.slash !== false).map(runSkill),
|
||||
]
|
||||
}
|
||||
|
||||
export async function loadRunReferences(sdk: OpencodeClient, directory: string): Promise<RunReference[]> {
|
||||
const result = await sdk.v2.reference.list(location(directory), { throwOnError: true })
|
||||
return (result.data?.data ?? []).filter((reference) => !reference.hidden)
|
||||
}
|
||||
|
||||
export async function loadRunProviders(sdk: OpencodeClient, directory: string): Promise<RunProvider[]> {
|
||||
const [providers, models] = await Promise.all([
|
||||
sdk.v2.provider.list(location(directory), { throwOnError: true }),
|
||||
sdk.v2.model.list(location(directory), { throwOnError: true }),
|
||||
])
|
||||
return runProviders(providers.data?.data ?? [], models.data?.data ?? [])
|
||||
}
|
||||
@@ -141,7 +141,9 @@ export function RunPermissionBody(props: {
|
||||
const info = createMemo(() => permissionInfo(props.request))
|
||||
const ft = createMemo(() => toolFiletype(info().file))
|
||||
const narrow = createMemo(() => footerWidthPolicy(dims().width).dialog.narrow)
|
||||
const opts = createMemo(() => permissionOptions(state().stage))
|
||||
const opts = createMemo(() =>
|
||||
permissionOptions(state().stage).filter((option) => option !== "always" || props.request.always.length > 0),
|
||||
)
|
||||
const busy = createMemo(() => state().submitting)
|
||||
const title = createMemo(() => {
|
||||
if (state().stage === "always") {
|
||||
@@ -165,7 +167,7 @@ export function RunPermissionBody(props: {
|
||||
})
|
||||
|
||||
const shift = (dir: -1 | 1) => {
|
||||
setState((prev) => permissionShift(prev, dir))
|
||||
setState((prev) => permissionShift(prev, dir, opts()))
|
||||
}
|
||||
|
||||
const submit = async (next: PermissionReply) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Prompt composer and its state machine for direct interactive mode.
|
||||
//
|
||||
// createPromptState() wires keymap command layers, history navigation, and
|
||||
// `@` autocomplete for files, subagents, and MCP resources.
|
||||
// `@` autocomplete for files, subagents, and project references.
|
||||
// It produces a PromptState that RunPromptBody renders as a slim single-line
|
||||
// composer while the footer view renders any active menus below it.
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
@@ -27,7 +27,7 @@ import { OPENCODE_BASE_MODE, useBindings } from "@opencode-ai/tui/keymap"
|
||||
import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.editor"
|
||||
import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
|
||||
import type { RunFooterTheme } from "./theme"
|
||||
import type { FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunResource, RunTuiConfig } from "./types"
|
||||
import type { FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunReference, RunTuiConfig } from "./types"
|
||||
|
||||
const AUTOCOMPLETE_ROWS = FOOTER_MENU_ROWS
|
||||
const AUTOCOMPLETE_BOTTOM_ROWS = 1
|
||||
@@ -59,7 +59,7 @@ type PromptInput = {
|
||||
directory: string
|
||||
findFiles: (query: string) => Promise<string[]>
|
||||
agents: Accessor<RunAgent[]>
|
||||
resources: Accessor<RunResource[]>
|
||||
references: Accessor<RunReference[]>
|
||||
commands: Accessor<RunCommand[] | undefined>
|
||||
tuiConfig: RunTuiConfig
|
||||
state: Accessor<FooterState>
|
||||
@@ -333,21 +333,20 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
},
|
||||
}))
|
||||
})
|
||||
const resources = createMemo<Auto[]>(() => {
|
||||
return input.resources().map((item) => ({
|
||||
const references = createMemo<Auto[]>(() => {
|
||||
return input.references().map((item) => ({
|
||||
kind: "mention",
|
||||
display: Locale.truncateMiddle(`@${item.name} (${item.uri})`, width()),
|
||||
display: Locale.truncateMiddle("@" + item.name, width()),
|
||||
value: item.name,
|
||||
description: item.description,
|
||||
description: item.description ?? (item.source.type === "git" ? item.source.repository : item.source.path),
|
||||
part: {
|
||||
type: "file",
|
||||
mime: item.mimeType ?? "text/plain",
|
||||
mime: "application/x-directory",
|
||||
filename: item.name,
|
||||
url: item.uri,
|
||||
url: pathToFileURL(item.path).href,
|
||||
source: {
|
||||
type: "resource",
|
||||
clientName: item.client,
|
||||
uri: item.uri,
|
||||
type: "file",
|
||||
path: item.name,
|
||||
text: {
|
||||
start: 0,
|
||||
end: 0,
|
||||
@@ -402,7 +401,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
},
|
||||
{ initialValue: [] as Auto[] },
|
||||
)
|
||||
const mentionOptions = createMemo(() => [...agents(), ...files(), ...resources()])
|
||||
const mentionOptions = createMemo(() => [...agents(), ...files(), ...references()])
|
||||
const skillCommands = createMemo(() => (input.commands() ?? []).filter((item) => item.source === "skill"))
|
||||
const hasSkillsCommand = createMemo(() =>
|
||||
(input.commands() ?? []).some((item) => item.source !== "skill" && item.name === "skills"),
|
||||
@@ -462,7 +461,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
return [
|
||||
...fuzzysort.go(next, agents(), { keys: ["value", "display", "description"] }).map((item) => item.obj),
|
||||
...files(),
|
||||
...fuzzysort.go(next, resources(), { keys: ["value", "display", "description"] }).map((item) => item.obj),
|
||||
...fuzzysort.go(next, references(), { keys: ["value", "display", "description"] }).map((item) => item.obj),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,9 @@ export function RunFooterSubagentBody(props: {
|
||||
diffStyle?: RunDiffStyle
|
||||
onCycle: (dir: -1 | 1) => void
|
||||
onClose: () => void
|
||||
// Formatted interrupt shortcut from the registered keymap binding; the
|
||||
// command itself is dispatched through the keymap in footer.view.
|
||||
interrupt?: () => string | undefined
|
||||
}) {
|
||||
const theme = createMemo(() => props.theme())
|
||||
const footer = createMemo(() => theme().footer)
|
||||
@@ -89,6 +92,11 @@ export function RunFooterSubagentBody(props: {
|
||||
))
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
|
||||
const interruptHint = createMemo(() => {
|
||||
if (tab()?.status !== "running") return undefined
|
||||
return props.interrupt?.()
|
||||
})
|
||||
|
||||
useKeyboard((event) => {
|
||||
if (!props.active()) {
|
||||
return
|
||||
@@ -139,6 +147,13 @@ export function RunFooterSubagentBody(props: {
|
||||
<span style={{ fg: footer().muted }}>{" " + subtitle()}</span>
|
||||
</Show>
|
||||
</text>
|
||||
<Show when={interruptHint()}>
|
||||
{(hint) => (
|
||||
<text fg={footer().muted} wrapMode="none" truncate flexShrink={0}>
|
||||
{hint()} interrupt
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={props.total() > 1 && props.index() > 0}>
|
||||
<text fg={footer().muted} wrapMode="none" truncate flexShrink={0}>
|
||||
{props.index()} of {props.total()}
|
||||
|
||||
@@ -55,7 +55,7 @@ import type {
|
||||
RunInput,
|
||||
RunPrompt,
|
||||
RunProvider,
|
||||
RunResource,
|
||||
RunReference,
|
||||
RunTuiConfig,
|
||||
StreamCommit,
|
||||
} from "./types"
|
||||
@@ -71,7 +71,7 @@ type RunFooterOptions = {
|
||||
directory: string
|
||||
findFiles: (query: string) => Promise<string[]>
|
||||
agents: RunAgent[]
|
||||
resources: RunResource[]
|
||||
references: RunReference[]
|
||||
commands?: RunCommand[]
|
||||
wrote?: boolean
|
||||
sessionID: () => string | undefined
|
||||
@@ -97,6 +97,7 @@ type RunFooterOptions = {
|
||||
onEditorOpen: (input: { value: string }) => Promise<string | undefined>
|
||||
onExit?: () => void
|
||||
onSubagentSelect?: (sessionID: string | undefined) => void
|
||||
onSubagentInterrupt?: (sessionID: string) => void
|
||||
treeSitterClient?: TreeSitterClient
|
||||
}
|
||||
|
||||
@@ -180,8 +181,8 @@ export class RunFooter implements FooterApi {
|
||||
private rows = TEXTAREA_MIN_ROWS
|
||||
private agents: Accessor<RunAgent[]>
|
||||
private setAgents: Setter<RunAgent[]>
|
||||
private resources: Accessor<RunResource[]>
|
||||
private setResources: Setter<RunResource[]>
|
||||
private references: Accessor<RunReference[]>
|
||||
private setReferences: Setter<RunReference[]>
|
||||
private commands: Accessor<RunCommand[] | undefined>
|
||||
private setCommands: Setter<RunCommand[] | undefined>
|
||||
private providers: Accessor<RunProvider[] | undefined>
|
||||
@@ -255,9 +256,9 @@ export class RunFooter implements FooterApi {
|
||||
const [agents, setAgents] = createSignal(options.agents)
|
||||
this.agents = agents
|
||||
this.setAgents = setAgents
|
||||
const [resources, setResources] = createSignal(options.resources)
|
||||
this.resources = resources
|
||||
this.setResources = setResources
|
||||
const [references, setReferences] = createSignal(options.references)
|
||||
this.references = references
|
||||
this.setReferences = setReferences
|
||||
const [commands, setCommands] = createSignal<RunCommand[] | undefined>(options.commands)
|
||||
this.commands = commands
|
||||
this.setCommands = setCommands
|
||||
@@ -311,7 +312,7 @@ export class RunFooter implements FooterApi {
|
||||
queuedPrompts: footer.queuedPrompts,
|
||||
findFiles: options.findFiles,
|
||||
agents: footer.agents,
|
||||
resources: footer.resources,
|
||||
references: footer.references,
|
||||
commands: footer.commands,
|
||||
providers: footer.providers,
|
||||
currentModel: footer.currentModel,
|
||||
@@ -341,6 +342,7 @@ export class RunFooter implements FooterApi {
|
||||
onLayout: footer.syncLayout,
|
||||
onStatus: footer.setStatus,
|
||||
onSubagentSelect: options.onSubagentSelect,
|
||||
onSubagentInterrupt: options.onSubagentInterrupt,
|
||||
onQueuedRemove: footer.handleQueuedRemove,
|
||||
})
|
||||
},
|
||||
@@ -411,7 +413,7 @@ export class RunFooter implements FooterApi {
|
||||
}
|
||||
|
||||
this.setAgents(next.agents)
|
||||
this.setResources(next.resources)
|
||||
this.setReferences(next.references)
|
||||
if (next.commands !== undefined) {
|
||||
this.setCommands(next.commands)
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ import type {
|
||||
RunInput,
|
||||
RunPrompt,
|
||||
RunProvider,
|
||||
RunResource,
|
||||
RunReference,
|
||||
RunTuiConfig,
|
||||
} from "./types"
|
||||
import type { RunTheme } from "./theme"
|
||||
@@ -74,7 +74,7 @@ type RunFooterViewProps = {
|
||||
directory: string
|
||||
findFiles: (query: string) => Promise<string[]>
|
||||
agents: () => RunAgent[]
|
||||
resources: () => RunResource[]
|
||||
references: () => RunReference[]
|
||||
commands: () => RunCommand[] | undefined
|
||||
providers: () => RunProvider[] | undefined
|
||||
currentModel: () => RunInput["model"]
|
||||
@@ -108,6 +108,7 @@ type RunFooterViewProps = {
|
||||
onLayout: (input: { route: FooterPromptRoute; autocomplete: boolean; subagentRows: number }) => void
|
||||
onStatus: (text: string) => void
|
||||
onSubagentSelect?: (sessionID: string | undefined) => void
|
||||
onSubagentInterrupt?: (sessionID: string) => void
|
||||
onQueuedRemove: (messageID: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
@@ -213,6 +214,15 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
props.tuiConfig,
|
||||
) ?? "",
|
||||
)
|
||||
const subagentInterruptShortcut = useKeymapSelector(
|
||||
(keymap: OpenTuiKeymap) =>
|
||||
formatKeySequence(
|
||||
keymap
|
||||
.getCommandBindings({ visibility: "registered", commands: ["subagent.interrupt"] })
|
||||
.get("subagent.interrupt")?.[0]?.sequence,
|
||||
props.tuiConfig,
|
||||
) ?? "",
|
||||
)
|
||||
const interrupt = useKeymapSelector(
|
||||
(keymap: OpenTuiKeymap) =>
|
||||
formatKeySequence(
|
||||
@@ -358,7 +368,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
directory: props.directory,
|
||||
findFiles: props.findFiles,
|
||||
agents: props.agents,
|
||||
resources: props.resources,
|
||||
references: props.references,
|
||||
commands: props.commands,
|
||||
tuiConfig: props.tuiConfig,
|
||||
state: props.state,
|
||||
@@ -520,7 +530,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
|
||||
useBindings(() => ({
|
||||
mode: OPENCODE_BASE_MODE,
|
||||
enabled: active().type === "prompt" && route().type === "composer" && foregroundSubagents(),
|
||||
enabled: active().type === "prompt" && route().type === "composer" && foregroundSubagents() && !!props.onBackground,
|
||||
priority: 1,
|
||||
commands: [
|
||||
{
|
||||
@@ -561,6 +571,32 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
bindings: props.tuiConfig.keybinds.get("session.queued_prompts"),
|
||||
}))
|
||||
|
||||
useBindings(() => ({
|
||||
mode: OPENCODE_BASE_MODE,
|
||||
enabled:
|
||||
active().type === "prompt" &&
|
||||
route().type === "subagent" &&
|
||||
selectedTab()?.status === "running" &&
|
||||
!!props.onSubagentInterrupt,
|
||||
priority: 1,
|
||||
commands: [
|
||||
{
|
||||
name: "subagent.interrupt",
|
||||
title: "Interrupt subagent",
|
||||
category: "Session",
|
||||
run: () => {
|
||||
const current = selectedTab()
|
||||
if (current?.status !== "running") {
|
||||
return
|
||||
}
|
||||
|
||||
props.onSubagentInterrupt?.(current.sessionID)
|
||||
},
|
||||
},
|
||||
],
|
||||
bindings: [{ key: "ctrl+d", desc: "Interrupt subagent", group: "Subagents", cmd: "subagent.interrupt" }],
|
||||
}))
|
||||
|
||||
createEffect(() => {
|
||||
const current = route()
|
||||
if (current.type !== "subagent") {
|
||||
@@ -935,6 +971,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
diffStyle={props.diffStyle}
|
||||
onCycle={cycleTab}
|
||||
onClose={closeTab}
|
||||
interrupt={() => subagentInterruptShortcut() || undefined}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
import type {
|
||||
OpencodeClient,
|
||||
ReasoningPart,
|
||||
StepFinishPart,
|
||||
StepStartPart,
|
||||
TextPart,
|
||||
ToolPart,
|
||||
V2Event,
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
import { EOL } from "node:os"
|
||||
import { MessageID } from "@/session/schema"
|
||||
import { UI } from "../../ui"
|
||||
|
||||
type Model = {
|
||||
providerID: string
|
||||
modelID: string
|
||||
}
|
||||
|
||||
type File = {
|
||||
url: string
|
||||
filename: string
|
||||
mime: string
|
||||
}
|
||||
|
||||
type Input = {
|
||||
client: OpencodeClient
|
||||
sessionID: string
|
||||
message: string
|
||||
files: File[]
|
||||
agent?: string
|
||||
model?: Model
|
||||
variant?: string
|
||||
thinking: boolean
|
||||
format: "default" | "json"
|
||||
dangerouslySkipPermissions: boolean
|
||||
renderTool: (part: ToolPart) => Promise<void>
|
||||
renderToolError: (part: ToolPart) => Promise<void>
|
||||
}
|
||||
|
||||
type StartedPart = {
|
||||
id: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
type ToolState = StartedPart & {
|
||||
assistantMessageID: string
|
||||
tool: string
|
||||
input: Record<string, unknown>
|
||||
raw?: string
|
||||
provider?: unknown
|
||||
}
|
||||
|
||||
export async function runNonInteractivePrompt(input: Input) {
|
||||
const controller = new AbortController()
|
||||
const events = await input.client.v2.event.subscribe({
|
||||
signal: controller.signal,
|
||||
sseMaxRetryAttempts: 0,
|
||||
throwOnError: true,
|
||||
})
|
||||
const stream = events.stream[Symbol.asyncIterator]() as AsyncGenerator<V2Event>
|
||||
const connected = await stream.next()
|
||||
if (connected.done) throw new Error("Event stream disconnected before prompt admission")
|
||||
|
||||
const messageID = MessageID.ascending()
|
||||
const starts = new Map<string, StartedPart>()
|
||||
const tools = new Map<string, ToolState>()
|
||||
let submitted = false
|
||||
let promoted = false
|
||||
let emittedError = false
|
||||
let questionRejected = false
|
||||
let permissionRejected = false
|
||||
let interrupted = false
|
||||
let admission: AbortController | undefined
|
||||
|
||||
const emit = (type: string, timestamp: number, data: Record<string, unknown>) => {
|
||||
if (input.format !== "json") return false
|
||||
process.stdout.write(JSON.stringify({ type, timestamp, sessionID: input.sessionID, ...data }) + EOL)
|
||||
return true
|
||||
}
|
||||
|
||||
const writeText = (part: TextPart, timestamp: number) => {
|
||||
if (emit("text", timestamp, { part })) return
|
||||
const text = part.text.trim()
|
||||
if (!text) return
|
||||
if (!process.stdout.isTTY) {
|
||||
process.stdout.write(text + EOL)
|
||||
return
|
||||
}
|
||||
UI.empty()
|
||||
UI.println(text)
|
||||
UI.empty()
|
||||
}
|
||||
|
||||
const replyPermission = async (request: { id: string; action: string; resources: string[] }) => {
|
||||
if (!input.dangerouslySkipPermissions) {
|
||||
permissionRejected = true
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL +
|
||||
`permission requested: ${request.action} (${request.resources.join(", ")}); auto-rejecting`,
|
||||
)
|
||||
}
|
||||
await input.client.v2.session.permission
|
||||
.reply({
|
||||
sessionID: input.sessionID,
|
||||
requestID: request.id,
|
||||
reply: input.dangerouslySkipPermissions ? "once" : "reject",
|
||||
})
|
||||
.catch(() => {})
|
||||
if (!input.dangerouslySkipPermissions) {
|
||||
await input.client.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
const rejectQuestion = async (request: { id: string }) => {
|
||||
questionRejected = true
|
||||
await input.client.v2.session.question.reject({ sessionID: input.sessionID, requestID: request.id }).catch(() => {})
|
||||
}
|
||||
|
||||
const consume = async () => {
|
||||
while (!controller.signal.aborted) {
|
||||
const next = await stream.next()
|
||||
if (next.done) throw new Error("Event stream disconnected during prompt execution")
|
||||
const event = next.value
|
||||
|
||||
if (event.type === "permission.v2.asked" && submitted && event.data.sessionID === input.sessionID) {
|
||||
await replyPermission(event.data)
|
||||
continue
|
||||
}
|
||||
if (event.type === "question.v2.asked" && submitted && event.data.sessionID === input.sessionID) {
|
||||
await rejectQuestion(event.data)
|
||||
continue
|
||||
}
|
||||
if (!("sessionID" in event.data) || event.data.sessionID !== input.sessionID) continue
|
||||
const time = "timestamp" in event.data ? toMillis(event.data.timestamp) : Date.now()
|
||||
|
||||
if (event.type === "session.next.prompted") {
|
||||
if (event.data.messageID === messageID) {
|
||||
promoted = true
|
||||
continue
|
||||
}
|
||||
if (promoted && event.data.delivery === "queue") return
|
||||
}
|
||||
if (
|
||||
event.type === "session.next.execution.settled" &&
|
||||
event.data.outcome === "interrupted" &&
|
||||
(interrupted || permissionRejected || questionRejected)
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (!promoted) continue
|
||||
|
||||
if (event.type === "session.next.step.started") {
|
||||
const part: StepStartPart = {
|
||||
id: partID(event.id),
|
||||
sessionID: input.sessionID,
|
||||
messageID: event.data.assistantMessageID,
|
||||
type: "step-start",
|
||||
snapshot: event.data.snapshot,
|
||||
}
|
||||
if (!emit("step_start", time, { part }) && input.format !== "json") {
|
||||
UI.empty()
|
||||
UI.println(`> ${event.data.agent} · ${event.data.model.id}`)
|
||||
UI.empty()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (event.type === "session.next.text.started") {
|
||||
starts.set(event.data.textID, { id: partID(event.id), timestamp: time })
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.next.text.ended") {
|
||||
const started = starts.get(event.data.textID)
|
||||
const part: TextPart = {
|
||||
id: started?.id ?? partID(event.id),
|
||||
sessionID: input.sessionID,
|
||||
messageID: event.data.assistantMessageID,
|
||||
type: "text",
|
||||
text: event.data.text,
|
||||
time: { start: started?.timestamp ?? time, end: time },
|
||||
}
|
||||
writeText(part, time)
|
||||
continue
|
||||
}
|
||||
|
||||
if (event.type === "session.next.reasoning.started") {
|
||||
starts.set(event.data.reasoningID, { id: partID(event.id), timestamp: time })
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.next.reasoning.ended" && input.thinking) {
|
||||
const started = starts.get(event.data.reasoningID)
|
||||
const part: ReasoningPart = {
|
||||
id: started?.id ?? partID(event.id),
|
||||
sessionID: input.sessionID,
|
||||
messageID: event.data.assistantMessageID,
|
||||
type: "reasoning",
|
||||
text: event.data.text,
|
||||
metadata: event.data.providerMetadata,
|
||||
time: { start: started?.timestamp ?? time, end: time },
|
||||
}
|
||||
if (emit("reasoning", time, { part })) continue
|
||||
const text = part.text.trim()
|
||||
if (!text) continue
|
||||
const line = `Thinking: ${text}`
|
||||
if (!process.stdout.isTTY) {
|
||||
process.stdout.write(line + EOL)
|
||||
continue
|
||||
}
|
||||
UI.empty()
|
||||
UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`)
|
||||
UI.empty()
|
||||
continue
|
||||
}
|
||||
|
||||
if (event.type === "session.next.tool.input.started") {
|
||||
tools.set(event.data.callID, {
|
||||
id: partID(event.id),
|
||||
timestamp: time,
|
||||
assistantMessageID: event.data.assistantMessageID,
|
||||
tool: event.data.name,
|
||||
input: {},
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.next.tool.input.ended") {
|
||||
const current = tools.get(event.data.callID)
|
||||
if (current) current.raw = event.data.text
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.next.tool.called") {
|
||||
const current = tools.get(event.data.callID)
|
||||
tools.set(event.data.callID, {
|
||||
id: current?.id ?? partID(event.id),
|
||||
timestamp: current?.timestamp ?? time,
|
||||
assistantMessageID: event.data.assistantMessageID,
|
||||
tool: event.data.tool,
|
||||
input: event.data.input,
|
||||
raw: current?.raw,
|
||||
provider: event.data.provider,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.next.tool.success") {
|
||||
const current = tools.get(event.data.callID) ?? fallbackTool(event)
|
||||
const part: ToolPart = {
|
||||
id: current.id,
|
||||
sessionID: input.sessionID,
|
||||
messageID: event.data.assistantMessageID,
|
||||
type: "tool",
|
||||
callID: event.data.callID,
|
||||
tool: current.tool,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: current.input,
|
||||
output: event.data.content
|
||||
.filter((item) => item.type === "text")
|
||||
.map((item) => item.text)
|
||||
.join("\n"),
|
||||
title: current.tool,
|
||||
metadata: {
|
||||
structured: event.data.structured,
|
||||
content: event.data.content,
|
||||
outputPaths: event.data.outputPaths,
|
||||
result: event.data.result,
|
||||
providerCall: current.provider,
|
||||
providerResult: event.data.provider,
|
||||
rawInput: current.raw,
|
||||
},
|
||||
time: { start: current.timestamp, end: time },
|
||||
},
|
||||
}
|
||||
tools.delete(event.data.callID)
|
||||
if (!emit("tool_use", time, { part })) await input.renderTool(part)
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.next.tool.failed") {
|
||||
const current = tools.get(event.data.callID) ?? fallbackTool(event)
|
||||
const error = event.data.error.message
|
||||
const part: ToolPart = {
|
||||
id: current.id,
|
||||
sessionID: input.sessionID,
|
||||
messageID: event.data.assistantMessageID,
|
||||
type: "tool",
|
||||
callID: event.data.callID,
|
||||
tool: current.tool,
|
||||
state: {
|
||||
status: "error",
|
||||
input: current.input,
|
||||
error,
|
||||
metadata: {
|
||||
result: event.data.result,
|
||||
providerCall: current.provider,
|
||||
providerResult: event.data.provider,
|
||||
rawInput: current.raw,
|
||||
},
|
||||
time: { start: current.timestamp, end: time },
|
||||
},
|
||||
}
|
||||
tools.delete(event.data.callID)
|
||||
if (!emit("tool_use", time, { part })) {
|
||||
await input.renderToolError(part)
|
||||
UI.error(error)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (event.type === "session.next.step.ended") {
|
||||
const part: StepFinishPart = {
|
||||
id: partID(event.id),
|
||||
sessionID: input.sessionID,
|
||||
messageID: event.data.assistantMessageID,
|
||||
type: "step-finish",
|
||||
reason: event.data.finish,
|
||||
snapshot: event.data.snapshot,
|
||||
cost: event.data.cost,
|
||||
tokens: event.data.tokens,
|
||||
}
|
||||
emit("step_finish", time, { part })
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.next.step.failed") {
|
||||
if (interrupted || permissionRejected || questionRejected) continue
|
||||
emittedError = true
|
||||
process.exitCode = 1
|
||||
if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message)
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.next.execution.settled") {
|
||||
if (event.data.outcome === "failure" && !emittedError && !questionRejected) {
|
||||
emittedError = true
|
||||
process.exitCode = 1
|
||||
const error = event.data.error ?? { type: "unknown", message: "Session execution failed" }
|
||||
if (!emit("error", toMillis(event.data.timestamp), { error })) UI.error(error.message)
|
||||
}
|
||||
if (event.data.outcome === "interrupted" && interrupted) process.exitCode = 130
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const interrupt = () => {
|
||||
if (interrupted) process.exit(130)
|
||||
interrupted = true
|
||||
process.exitCode = 130
|
||||
admission?.abort()
|
||||
void input.client.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
|
||||
}
|
||||
process.on("SIGINT", interrupt)
|
||||
|
||||
let completed: Promise<void> | undefined
|
||||
try {
|
||||
if (input.agent) {
|
||||
await input.client.v2.session.switchAgent(
|
||||
{ sessionID: input.sessionID, agent: input.agent },
|
||||
{ throwOnError: true },
|
||||
)
|
||||
}
|
||||
const selected = input.model
|
||||
? { providerID: input.model.providerID, id: input.model.modelID, variant: input.variant }
|
||||
: input.variant
|
||||
? await input.client.v2.session
|
||||
.get({ sessionID: input.sessionID }, { throwOnError: true })
|
||||
.then((result) => result.data.data.model)
|
||||
.then(async (model) => {
|
||||
if (model) return { ...model, variant: input.variant }
|
||||
const result = await input.client.v2.model.default(undefined, { throwOnError: true })
|
||||
const fallback = result.data.data
|
||||
return fallback ? { providerID: fallback.providerID, id: fallback.id, variant: input.variant } : undefined
|
||||
})
|
||||
: undefined
|
||||
if (input.variant && !selected) throw new Error("Cannot select a variant before selecting a model")
|
||||
if (selected) {
|
||||
await input.client.v2.session.switchModel({ sessionID: input.sessionID, model: selected }, { throwOnError: true })
|
||||
}
|
||||
|
||||
const prepared = await Promise.all(input.files.map(prepareFile))
|
||||
if (interrupted) return
|
||||
submitted = true
|
||||
completed = consume()
|
||||
admission = new AbortController()
|
||||
const response = await input.client.v2.session
|
||||
.prompt(
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
id: messageID,
|
||||
prompt: {
|
||||
text: [input.message, ...prepared.flatMap((file) => (file.text ? [file.text] : []))].join("\n\n"),
|
||||
files: prepared.flatMap((file) => (file.attachment ? [file.attachment] : [])),
|
||||
},
|
||||
delivery: "steer",
|
||||
},
|
||||
{ throwOnError: true, signal: admission.signal },
|
||||
)
|
||||
.catch(async (error) => {
|
||||
if (interrupted) {
|
||||
await input.client.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
|
||||
}
|
||||
controller.abort()
|
||||
await completed?.catch(() => {})
|
||||
if (interrupted) return undefined
|
||||
throw error
|
||||
})
|
||||
admission = undefined
|
||||
if (!response) return
|
||||
if (!response.data.data) throw new Error("Prompt was not admitted")
|
||||
if (interrupted) await input.client.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
|
||||
|
||||
const [permissions, questions] = await Promise.all([
|
||||
input.client.v2.session.permission.list({ sessionID: input.sessionID }).catch(() => undefined),
|
||||
input.client.v2.session.question.list({ sessionID: input.sessionID }).catch(() => undefined),
|
||||
])
|
||||
await Promise.all([
|
||||
...(permissions?.data?.data ?? []).map(replyPermission),
|
||||
...(questions?.data?.data ?? []).map(rejectQuestion),
|
||||
])
|
||||
await completed
|
||||
} finally {
|
||||
process.off("SIGINT", interrupt)
|
||||
controller.abort()
|
||||
await stream.return?.(undefined).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
function partID(eventID: string) {
|
||||
return `prt_${eventID.replace(/^evt_/, "")}`
|
||||
}
|
||||
|
||||
function fallbackTool(event: {
|
||||
id: string
|
||||
data: { timestamp: number; assistantMessageID: string; callID: string }
|
||||
}): ToolState {
|
||||
return {
|
||||
id: partID(event.id),
|
||||
timestamp: toMillis(event.data.timestamp),
|
||||
assistantMessageID: event.data.assistantMessageID,
|
||||
tool: "tool",
|
||||
input: {},
|
||||
}
|
||||
}
|
||||
|
||||
function toMillis(value: unknown) {
|
||||
if (typeof value === "number") return value
|
||||
if (typeof value === "string") return new Date(value).getTime()
|
||||
return Date.now()
|
||||
}
|
||||
|
||||
async function prepareFile(file: File) {
|
||||
if (file.mime !== "text/plain") {
|
||||
const uri = file.url.startsWith("data:")
|
||||
? file.url
|
||||
: `data:${file.mime};base64,${Buffer.from(await Bun.file(new URL(file.url)).arrayBuffer()).toString("base64")}`
|
||||
return { attachment: { uri, mime: file.mime, name: file.filename } }
|
||||
}
|
||||
const content = file.url.startsWith("data:")
|
||||
? Buffer.from(file.url.slice(file.url.indexOf(",") + 1), "base64").toString("utf8")
|
||||
: await Bun.file(new URL(file.url)).text()
|
||||
return { text: `<file name="${file.filename}">\n${content}\n</file>` }
|
||||
}
|
||||
@@ -150,8 +150,11 @@ export function permissionReply(requestID: string, reply: PermissionReply["reply
|
||||
}
|
||||
}
|
||||
|
||||
export function permissionShift(state: PermissionBodyState, dir: -1 | 1): PermissionBodyState {
|
||||
const list = permissionOptions(state.stage)
|
||||
export function permissionShift(
|
||||
state: PermissionBodyState,
|
||||
dir: -1 | 1,
|
||||
list = permissionOptions(state.stage),
|
||||
): PermissionBodyState {
|
||||
if (list.length === 0) {
|
||||
return state
|
||||
}
|
||||
|
||||
@@ -8,9 +8,12 @@
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { resolve } from "@opencode-ai/tui/config"
|
||||
import { TuiConfig } from "@/config/tui"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { loadRunProviders } from "./catalog.shared"
|
||||
import { reusePendingTask } from "./runtime.shared"
|
||||
import { resolveSession, sessionHistory } from "./session.shared"
|
||||
import { resolveCurrentSession, sessionHistory } from "./session.shared"
|
||||
import type { RunDiffStyle, RunInput, RunPrompt, RunProvider, RunTuiConfig } from "./types"
|
||||
import { pickVariant } from "./variant.shared"
|
||||
|
||||
@@ -95,20 +98,7 @@ const layer = Layer.effect(
|
||||
directory: string,
|
||||
model: RunInput["model"],
|
||||
) {
|
||||
const connected = yield* Effect.promise(() =>
|
||||
sdk.config
|
||||
.providers({ directory })
|
||||
.then((item) => item.data?.providers)
|
||||
.catch(() => undefined),
|
||||
)
|
||||
const providers = yield* Effect.promise(() =>
|
||||
connected
|
||||
? Promise.resolve(connected)
|
||||
: sdk.provider
|
||||
.list()
|
||||
.then((item) => item.data?.all ?? [])
|
||||
.catch(() => []),
|
||||
)
|
||||
const providers = yield* Effect.promise(() => loadRunProviders(sdk, directory))
|
||||
const limits = Object.fromEntries(
|
||||
providers.flatMap((provider) =>
|
||||
Object.entries(provider.models ?? {}).flatMap(([modelID, info]) => {
|
||||
@@ -143,7 +133,7 @@ const layer = Layer.effect(
|
||||
sessionID: string,
|
||||
model: RunInput["model"],
|
||||
) {
|
||||
const session = yield* Effect.promise(() => resolveSession(sdk, sessionID).catch(() => undefined))
|
||||
const session = yield* Effect.promise(() => resolveCurrentSession(sdk, sessionID).catch(() => undefined))
|
||||
if (!session) {
|
||||
return emptySessionInfo()
|
||||
}
|
||||
@@ -172,7 +162,8 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
const runtime = makeRuntime(Service, layer)
|
||||
const node = makeGlobalNode({ service: Service, layer, deps: [] })
|
||||
const runtime = makeRuntime(Service, AppNodeBuilder.build(node))
|
||||
|
||||
// Fetches available variants and context limits for every provider/model pair.
|
||||
export async function resolveModelInfo(
|
||||
|
||||
@@ -27,7 +27,7 @@ import type {
|
||||
RunAgent,
|
||||
RunInput,
|
||||
RunPrompt,
|
||||
RunResource,
|
||||
RunReference,
|
||||
RunTuiConfig,
|
||||
} from "./types"
|
||||
import { formatModelLabel } from "./variant.shared"
|
||||
@@ -55,7 +55,7 @@ export type LifecycleInput = {
|
||||
directory: string
|
||||
findFiles: (query: string) => Promise<string[]>
|
||||
agents: RunAgent[]
|
||||
resources: RunResource[]
|
||||
references: RunReference[]
|
||||
sessionID: string
|
||||
sessionTitle?: string
|
||||
getSessionID?: () => string | undefined
|
||||
@@ -75,6 +75,7 @@ export type LifecycleInput = {
|
||||
onInterrupt?: () => void
|
||||
onBackground?: () => void
|
||||
onSubagentSelect?: (sessionID: string | undefined) => void
|
||||
onSubagentInterrupt?: (sessionID: string) => void
|
||||
}
|
||||
|
||||
export type Lifecycle = {
|
||||
@@ -233,7 +234,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
||||
directory: input.directory,
|
||||
findFiles: input.findFiles,
|
||||
agents: input.agents,
|
||||
resources: input.resources,
|
||||
references: input.references,
|
||||
sessionID: input.getSessionID ?? (() => input.sessionID),
|
||||
...labels,
|
||||
model: input.model,
|
||||
@@ -276,6 +277,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
||||
}
|
||||
},
|
||||
onSubagentSelect: input.onSubagentSelect,
|
||||
onSubagentInterrupt: input.onSubagentInterrupt,
|
||||
})
|
||||
|
||||
const sigint = () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "fs"
|
||||
import * as tty from "node:tty"
|
||||
|
||||
export const INTERACTIVE_INPUT_ERROR = "--mini requires a controlling terminal for input"
|
||||
export const INTERACTIVE_INPUT_ERROR = "opencode mini requires a controlling terminal for input"
|
||||
|
||||
type InteractiveStdin = {
|
||||
stdin: NodeJS.ReadStream
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user