mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-16 01:19:19 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d7de2de673 |
@@ -1,36 +0,0 @@
|
|||||||
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" })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
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,5 +159,4 @@ 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 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 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 EventV2 replay owner claims separate from clustered Session execution ownership.
|
||||||
- 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.
|
- 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.
|
||||||
- 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.
|
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ export * from "./generated-effect/index"
|
|||||||
export { Agent } from "@opencode-ai/schema/agent"
|
export { Agent } from "@opencode-ai/schema/agent"
|
||||||
export { Command } from "@opencode-ai/schema/command"
|
export { Command } from "@opencode-ai/schema/command"
|
||||||
export { Credential } from "@opencode-ai/schema/credential"
|
export { Credential } from "@opencode-ai/schema/credential"
|
||||||
export { Event } from "@opencode-ai/schema/event"
|
|
||||||
export { EventLog } from "@opencode-ai/schema/event-log"
|
|
||||||
export { FileSystem } from "@opencode-ai/schema/filesystem"
|
export { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||||
export { Integration } from "@opencode-ai/schema/integration"
|
export { Integration } from "@opencode-ai/schema/integration"
|
||||||
export { Location } from "@opencode-ai/schema/location"
|
export { Location } from "@opencode-ai/schema/location"
|
||||||
|
|||||||
@@ -80,7 +80,10 @@ const Endpoint4_1 = (raw: RawClient["server.session"]) => (input?: Endpoint4_1In
|
|||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint4_2 = (raw: RawClient["server.session"]) => () =>
|
const Endpoint4_2 = (raw: RawClient["server.session"]) => () =>
|
||||||
raw["session.active"]({}).pipe(Effect.mapError(mapClientError))
|
raw["session.active"]({}).pipe(
|
||||||
|
Effect.mapError(mapClientError),
|
||||||
|
Effect.map((value) => value.data),
|
||||||
|
)
|
||||||
|
|
||||||
type Endpoint4_3Request = Parameters<RawClient["server.session"]["session.get"]>[0]
|
type Endpoint4_3Request = Parameters<RawClient["server.session"]["session.get"]>[0]
|
||||||
type Endpoint4_3Input = { readonly sessionID: Endpoint4_3Request["params"]["sessionID"] }
|
type Endpoint4_3Input = { readonly sessionID: Endpoint4_3Request["params"]["sessionID"] }
|
||||||
@@ -148,81 +151,36 @@ const Endpoint4_8 = (raw: RawClient["server.session"]) => (input: Endpoint4_8Inp
|
|||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
)
|
)
|
||||||
|
|
||||||
type Endpoint4_9Request = Parameters<RawClient["server.session"]["session.command"]>[0]
|
type Endpoint4_9Request = Parameters<RawClient["server.session"]["session.skill"]>[0]
|
||||||
type Endpoint4_9Input = {
|
type Endpoint4_9Input = {
|
||||||
readonly sessionID: Endpoint4_9Request["params"]["sessionID"]
|
readonly sessionID: Endpoint4_9Request["params"]["sessionID"]
|
||||||
readonly id?: Endpoint4_9Request["payload"]["id"]
|
readonly id?: Endpoint4_9Request["payload"]["id"]
|
||||||
readonly command: Endpoint4_9Request["payload"]["command"]
|
readonly skill: Endpoint4_9Request["payload"]["skill"]
|
||||||
readonly arguments?: Endpoint4_9Request["payload"]["arguments"]
|
|
||||||
readonly agent?: Endpoint4_9Request["payload"]["agent"]
|
|
||||||
readonly model?: Endpoint4_9Request["payload"]["model"]
|
|
||||||
readonly files?: Endpoint4_9Request["payload"]["files"]
|
|
||||||
readonly agents?: Endpoint4_9Request["payload"]["agents"]
|
|
||||||
readonly delivery?: Endpoint4_9Request["payload"]["delivery"]
|
|
||||||
readonly resume?: Endpoint4_9Request["payload"]["resume"]
|
readonly resume?: Endpoint4_9Request["payload"]["resume"]
|
||||||
}
|
}
|
||||||
const Endpoint4_9 = (raw: RawClient["server.session"]) => (input: Endpoint4_9Input) =>
|
const Endpoint4_9 = (raw: RawClient["server.session"]) => (input: Endpoint4_9Input) =>
|
||||||
raw["session.command"]({
|
|
||||||
params: { sessionID: input["sessionID"] },
|
|
||||||
payload: {
|
|
||||||
id: input["id"],
|
|
||||||
command: input["command"],
|
|
||||||
arguments: input["arguments"],
|
|
||||||
agent: input["agent"],
|
|
||||||
model: input["model"],
|
|
||||||
files: input["files"],
|
|
||||||
agents: input["agents"],
|
|
||||||
delivery: input["delivery"],
|
|
||||||
resume: input["resume"],
|
|
||||||
},
|
|
||||||
}).pipe(
|
|
||||||
Effect.mapError(mapClientError),
|
|
||||||
Effect.map((value) => value.data),
|
|
||||||
)
|
|
||||||
|
|
||||||
type Endpoint4_10Request = Parameters<RawClient["server.session"]["session.skill"]>[0]
|
|
||||||
type Endpoint4_10Input = {
|
|
||||||
readonly sessionID: Endpoint4_10Request["params"]["sessionID"]
|
|
||||||
readonly id?: Endpoint4_10Request["payload"]["id"]
|
|
||||||
readonly skill: Endpoint4_10Request["payload"]["skill"]
|
|
||||||
readonly resume?: Endpoint4_10Request["payload"]["resume"]
|
|
||||||
}
|
|
||||||
const Endpoint4_10 = (raw: RawClient["server.session"]) => (input: Endpoint4_10Input) =>
|
|
||||||
raw["session.skill"]({
|
raw["session.skill"]({
|
||||||
params: { sessionID: input["sessionID"] },
|
params: { sessionID: input["sessionID"] },
|
||||||
payload: { id: input["id"], skill: input["skill"], resume: input["resume"] },
|
payload: { id: input["id"], skill: input["skill"], resume: input["resume"] },
|
||||||
}).pipe(Effect.mapError(mapClientError))
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
type Endpoint4_11Request = Parameters<RawClient["server.session"]["session.synthetic"]>[0]
|
type Endpoint4_10Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
|
||||||
type Endpoint4_11Input = {
|
type Endpoint4_10Input = { readonly sessionID: Endpoint4_10Request["params"]["sessionID"] }
|
||||||
readonly sessionID: Endpoint4_11Request["params"]["sessionID"]
|
const Endpoint4_10 = (raw: RawClient["server.session"]) => (input: Endpoint4_10Input) =>
|
||||||
readonly text: Endpoint4_11Request["payload"]["text"]
|
|
||||||
readonly description?: Endpoint4_11Request["payload"]["description"]
|
|
||||||
readonly metadata?: Endpoint4_11Request["payload"]["metadata"]
|
|
||||||
}
|
|
||||||
const Endpoint4_11 = (raw: RawClient["server.session"]) => (input: Endpoint4_11Input) =>
|
|
||||||
raw["session.synthetic"]({
|
|
||||||
params: { sessionID: input["sessionID"] },
|
|
||||||
payload: { text: input["text"], description: input["description"], metadata: input["metadata"] },
|
|
||||||
}).pipe(Effect.mapError(mapClientError))
|
|
||||||
|
|
||||||
type Endpoint4_12Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
|
|
||||||
type Endpoint4_12Input = { readonly sessionID: Endpoint4_12Request["params"]["sessionID"] }
|
|
||||||
const Endpoint4_12 = (raw: RawClient["server.session"]) => (input: Endpoint4_12Input) =>
|
|
||||||
raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
type Endpoint4_13Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
|
type Endpoint4_11Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
|
||||||
type Endpoint4_13Input = { readonly sessionID: Endpoint4_13Request["params"]["sessionID"] }
|
type Endpoint4_11Input = { readonly sessionID: Endpoint4_11Request["params"]["sessionID"] }
|
||||||
const Endpoint4_13 = (raw: RawClient["server.session"]) => (input: Endpoint4_13Input) =>
|
const Endpoint4_11 = (raw: RawClient["server.session"]) => (input: Endpoint4_11Input) =>
|
||||||
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
type Endpoint4_14Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
|
type Endpoint4_12Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
|
||||||
type Endpoint4_14Input = {
|
type Endpoint4_12Input = {
|
||||||
readonly sessionID: Endpoint4_14Request["params"]["sessionID"]
|
readonly sessionID: Endpoint4_12Request["params"]["sessionID"]
|
||||||
readonly messageID: Endpoint4_14Request["payload"]["messageID"]
|
readonly messageID: Endpoint4_12Request["payload"]["messageID"]
|
||||||
readonly files?: Endpoint4_14Request["payload"]["files"]
|
readonly files?: Endpoint4_12Request["payload"]["files"]
|
||||||
}
|
}
|
||||||
const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14Input) =>
|
const Endpoint4_12 = (raw: RawClient["server.session"]) => (input: Endpoint4_12Input) =>
|
||||||
raw["session.revert.stage"]({
|
raw["session.revert.stage"]({
|
||||||
params: { sessionID: input["sessionID"] },
|
params: { sessionID: input["sessionID"] },
|
||||||
payload: { messageID: input["messageID"], files: input["files"] },
|
payload: { messageID: input["messageID"], files: input["files"] },
|
||||||
@@ -231,87 +189,65 @@ const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14I
|
|||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
)
|
)
|
||||||
|
|
||||||
type Endpoint4_15Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
|
type Endpoint4_13Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
|
||||||
type Endpoint4_15Input = { readonly sessionID: Endpoint4_15Request["params"]["sessionID"] }
|
type Endpoint4_13Input = { readonly sessionID: Endpoint4_13Request["params"]["sessionID"] }
|
||||||
const Endpoint4_15 = (raw: RawClient["server.session"]) => (input: Endpoint4_15Input) =>
|
const Endpoint4_13 = (raw: RawClient["server.session"]) => (input: Endpoint4_13Input) =>
|
||||||
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
type Endpoint4_16Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
|
type Endpoint4_14Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
|
||||||
type Endpoint4_16Input = { readonly sessionID: Endpoint4_16Request["params"]["sessionID"] }
|
type Endpoint4_14Input = { readonly sessionID: Endpoint4_14Request["params"]["sessionID"] }
|
||||||
const Endpoint4_16 = (raw: RawClient["server.session"]) => (input: Endpoint4_16Input) =>
|
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.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
type Endpoint4_17Request = Parameters<RawClient["server.session"]["session.context"]>[0]
|
type Endpoint4_15Request = Parameters<RawClient["server.session"]["session.context"]>[0]
|
||||||
type Endpoint4_17Input = { readonly sessionID: Endpoint4_17Request["params"]["sessionID"] }
|
type Endpoint4_15Input = { readonly sessionID: Endpoint4_15Request["params"]["sessionID"] }
|
||||||
const Endpoint4_17 = (raw: RawClient["server.session"]) => (input: Endpoint4_17Input) =>
|
const Endpoint4_15 = (raw: RawClient["server.session"]) => (input: Endpoint4_15Input) =>
|
||||||
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
)
|
)
|
||||||
|
|
||||||
type Endpoint4_18Request = Parameters<RawClient["server.session"]["session.context.entry.list"]>[0]
|
type Endpoint4_16Request = Parameters<RawClient["server.session"]["session.history"]>[0]
|
||||||
type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] }
|
type Endpoint4_16Input = {
|
||||||
const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18Input) =>
|
readonly sessionID: Endpoint4_16Request["params"]["sessionID"]
|
||||||
raw["session.context.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
readonly limit?: Endpoint4_16Request["query"]["limit"]
|
||||||
Effect.mapError(mapClientError),
|
readonly after?: Endpoint4_16Request["query"]["after"]
|
||||||
Effect.map((value) => value.data),
|
|
||||||
)
|
|
||||||
|
|
||||||
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.context.entry.put"]>[0]
|
|
||||||
type Endpoint4_19Input = {
|
|
||||||
readonly sessionID: Endpoint4_19Request["params"]["sessionID"]
|
|
||||||
readonly key: Endpoint4_19Request["params"]["key"]
|
|
||||||
readonly value: Endpoint4_19Request["payload"]["value"]
|
|
||||||
}
|
}
|
||||||
const Endpoint4_19 = (raw: RawClient["server.session"]) => (input: Endpoint4_19Input) =>
|
const Endpoint4_16 = (raw: RawClient["server.session"]) => (input: Endpoint4_16Input) =>
|
||||||
raw["session.context.entry.put"]({
|
raw["session.history"]({
|
||||||
params: { sessionID: input["sessionID"], key: input["key"] },
|
params: { sessionID: input["sessionID"] },
|
||||||
payload: { value: input["value"] },
|
query: { limit: input["limit"], after: input["after"] },
|
||||||
}).pipe(Effect.mapError(mapClientError))
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.context.entry.remove"]>[0]
|
type Endpoint4_17Request = Parameters<RawClient["server.session"]["session.events"]>[0]
|
||||||
type Endpoint4_20Input = {
|
type Endpoint4_17Input = {
|
||||||
readonly sessionID: Endpoint4_20Request["params"]["sessionID"]
|
readonly sessionID: Endpoint4_17Request["params"]["sessionID"]
|
||||||
readonly key: Endpoint4_20Request["params"]["key"]
|
readonly after?: Endpoint4_17Request["query"]["after"]
|
||||||
}
|
}
|
||||||
const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) =>
|
const Endpoint4_17 = (raw: RawClient["server.session"]) => (input: Endpoint4_17Input) =>
|
||||||
raw["session.context.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
|
|
||||||
Effect.mapError(mapClientError),
|
|
||||||
)
|
|
||||||
|
|
||||||
type Endpoint4_21Request = Parameters<RawClient["server.session"]["session.log"]>[0]
|
|
||||||
type Endpoint4_21Input = {
|
|
||||||
readonly sessionID: Endpoint4_21Request["params"]["sessionID"]
|
|
||||||
readonly after?: Endpoint4_21Request["query"]["after"]
|
|
||||||
readonly follow?: Endpoint4_21Request["query"]["follow"]
|
|
||||||
}
|
|
||||||
const Endpoint4_21 = (raw: RawClient["server.session"]) => (input: Endpoint4_21Input) =>
|
|
||||||
Stream.unwrap(
|
Stream.unwrap(
|
||||||
raw["session.log"]({
|
raw["session.events"]({ params: { sessionID: input["sessionID"] }, query: { after: input["after"] } }).pipe(
|
||||||
params: { sessionID: input["sessionID"] },
|
|
||||||
query: { after: input["after"], follow: input["follow"] },
|
|
||||||
}).pipe(
|
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError))),
|
Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError))),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
type Endpoint4_22Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
type Endpoint4_18Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
||||||
type Endpoint4_22Input = { readonly sessionID: Endpoint4_22Request["params"]["sessionID"] }
|
type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] }
|
||||||
const Endpoint4_22 = (raw: RawClient["server.session"]) => (input: Endpoint4_22Input) =>
|
const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18Input) =>
|
||||||
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
type Endpoint4_23Request = Parameters<RawClient["server.session"]["session.background"]>[0]
|
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.background"]>[0]
|
||||||
type Endpoint4_23Input = { readonly sessionID: Endpoint4_23Request["params"]["sessionID"] }
|
type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] }
|
||||||
const Endpoint4_23 = (raw: RawClient["server.session"]) => (input: Endpoint4_23Input) =>
|
const Endpoint4_19 = (raw: RawClient["server.session"]) => (input: Endpoint4_19Input) =>
|
||||||
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
type Endpoint4_24Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||||
type Endpoint4_24Input = {
|
type Endpoint4_20Input = {
|
||||||
readonly sessionID: Endpoint4_24Request["params"]["sessionID"]
|
readonly sessionID: Endpoint4_20Request["params"]["sessionID"]
|
||||||
readonly messageID: Endpoint4_24Request["params"]["messageID"]
|
readonly messageID: Endpoint4_20Request["params"]["messageID"]
|
||||||
}
|
}
|
||||||
const Endpoint4_24 = (raw: RawClient["server.session"]) => (input: Endpoint4_24Input) =>
|
const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) =>
|
||||||
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
|
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
@@ -327,22 +263,18 @@ const adaptGroup4 = (raw: RawClient["server.session"]) => ({
|
|||||||
switchModel: Endpoint4_6(raw),
|
switchModel: Endpoint4_6(raw),
|
||||||
rename: Endpoint4_7(raw),
|
rename: Endpoint4_7(raw),
|
||||||
prompt: Endpoint4_8(raw),
|
prompt: Endpoint4_8(raw),
|
||||||
command: Endpoint4_9(raw),
|
skill: Endpoint4_9(raw),
|
||||||
skill: Endpoint4_10(raw),
|
compact: Endpoint4_10(raw),
|
||||||
synthetic: Endpoint4_11(raw),
|
wait: Endpoint4_11(raw),
|
||||||
compact: Endpoint4_12(raw),
|
revertStage: Endpoint4_12(raw),
|
||||||
wait: Endpoint4_13(raw),
|
revertClear: Endpoint4_13(raw),
|
||||||
revertStage: Endpoint4_14(raw),
|
revertCommit: Endpoint4_14(raw),
|
||||||
revertClear: Endpoint4_15(raw),
|
context: Endpoint4_15(raw),
|
||||||
revertCommit: Endpoint4_16(raw),
|
history: Endpoint4_16(raw),
|
||||||
context: Endpoint4_17(raw),
|
events: Endpoint4_17(raw),
|
||||||
listContextEntries: Endpoint4_18(raw),
|
interrupt: Endpoint4_18(raw),
|
||||||
putContextEntry: Endpoint4_19(raw),
|
background: Endpoint4_19(raw),
|
||||||
removeContextEntry: Endpoint4_20(raw),
|
message: Endpoint4_20(raw),
|
||||||
log: Endpoint4_21(raw),
|
|
||||||
interrupt: Endpoint4_22(raw),
|
|
||||||
background: Endpoint4_23(raw),
|
|
||||||
message: Endpoint4_24(raw),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
type Endpoint5_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0]
|
type Endpoint5_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0]
|
||||||
@@ -365,12 +297,7 @@ type Endpoint6_0Input = { readonly location?: Endpoint6_0Request["query"]["locat
|
|||||||
const Endpoint6_0 = (raw: RawClient["server.model"]) => (input?: Endpoint6_0Input) =>
|
const Endpoint6_0 = (raw: RawClient["server.model"]) => (input?: Endpoint6_0Input) =>
|
||||||
raw["model.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
raw["model.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
type Endpoint6_1Request = Parameters<RawClient["server.model"]["model.default"]>[0]
|
const adaptGroup6 = (raw: RawClient["server.model"]) => ({ list: Endpoint6_0(raw) })
|
||||||
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_0Request = Parameters<RawClient["server.generate"]["generate.text"]>[0]
|
||||||
type Endpoint7_0Input = {
|
type Endpoint7_0Input = {
|
||||||
@@ -684,15 +611,7 @@ const Endpoint17_0 = (raw: RawClient["server.event"]) => () =>
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint17_1 = (raw: RawClient["server.event"]) => () =>
|
const adaptGroup17 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint17_0(raw) })
|
||||||
Stream.unwrap(
|
|
||||||
raw["event.changes"]({}).pipe(
|
|
||||||
Effect.mapError(mapClientError),
|
|
||||||
Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError))),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const adaptGroup17 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint17_0(raw), changes: Endpoint17_1(raw) })
|
|
||||||
|
|
||||||
type Endpoint18_0Request = Parameters<RawClient["server.pty"]["pty.list"]>[0]
|
type Endpoint18_0Request = Parameters<RawClient["server.pty"]["pty.list"]>[0]
|
||||||
type Endpoint18_0Input = { readonly location?: Endpoint18_0Request["query"]["location"] }
|
type Endpoint18_0Input = { readonly location?: Endpoint18_0Request["query"]["location"] }
|
||||||
|
|||||||
@@ -23,12 +23,8 @@ import type {
|
|||||||
SessionRenameOutput,
|
SessionRenameOutput,
|
||||||
SessionPromptInput,
|
SessionPromptInput,
|
||||||
SessionPromptOutput,
|
SessionPromptOutput,
|
||||||
SessionCommandInput,
|
|
||||||
SessionCommandOutput,
|
|
||||||
SessionSkillInput,
|
SessionSkillInput,
|
||||||
SessionSkillOutput,
|
SessionSkillOutput,
|
||||||
SessionSyntheticInput,
|
|
||||||
SessionSyntheticOutput,
|
|
||||||
SessionCompactInput,
|
SessionCompactInput,
|
||||||
SessionCompactOutput,
|
SessionCompactOutput,
|
||||||
SessionWaitInput,
|
SessionWaitInput,
|
||||||
@@ -41,14 +37,10 @@ import type {
|
|||||||
SessionRevertCommitOutput,
|
SessionRevertCommitOutput,
|
||||||
SessionContextInput,
|
SessionContextInput,
|
||||||
SessionContextOutput,
|
SessionContextOutput,
|
||||||
SessionListContextEntriesInput,
|
SessionHistoryInput,
|
||||||
SessionListContextEntriesOutput,
|
SessionHistoryOutput,
|
||||||
SessionPutContextEntryInput,
|
SessionEventsInput,
|
||||||
SessionPutContextEntryOutput,
|
SessionEventsOutput,
|
||||||
SessionRemoveContextEntryInput,
|
|
||||||
SessionRemoveContextEntryOutput,
|
|
||||||
SessionLogInput,
|
|
||||||
SessionLogOutput,
|
|
||||||
SessionInterruptInput,
|
SessionInterruptInput,
|
||||||
SessionInterruptOutput,
|
SessionInterruptOutput,
|
||||||
SessionBackgroundInput,
|
SessionBackgroundInput,
|
||||||
@@ -59,8 +51,6 @@ import type {
|
|||||||
MessageListOutput,
|
MessageListOutput,
|
||||||
ModelListInput,
|
ModelListInput,
|
||||||
ModelListOutput,
|
ModelListOutput,
|
||||||
ModelDefaultInput,
|
|
||||||
ModelDefaultOutput,
|
|
||||||
GenerateTextInput,
|
GenerateTextInput,
|
||||||
GenerateTextOutput,
|
GenerateTextOutput,
|
||||||
ProviderListInput,
|
ProviderListInput,
|
||||||
@@ -116,7 +106,6 @@ import type {
|
|||||||
SkillListInput,
|
SkillListInput,
|
||||||
SkillListOutput,
|
SkillListOutput,
|
||||||
EventSubscribeOutput,
|
EventSubscribeOutput,
|
||||||
EventChangesOutput,
|
|
||||||
PtyListInput,
|
PtyListInput,
|
||||||
PtyListOutput,
|
PtyListOutput,
|
||||||
PtyCreateInput,
|
PtyCreateInput,
|
||||||
@@ -379,7 +368,7 @@ export function make(options: ClientOptions) {
|
|||||||
requestOptions,
|
requestOptions,
|
||||||
).then((value) => value.data),
|
).then((value) => value.data),
|
||||||
active: (requestOptions?: RequestOptions) =>
|
active: (requestOptions?: RequestOptions) =>
|
||||||
request<SessionActiveOutput>(
|
request<{ readonly data: SessionActiveOutput }>(
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: `/api/session/active`,
|
path: `/api/session/active`,
|
||||||
@@ -388,7 +377,7 @@ export function make(options: ClientOptions) {
|
|||||||
empty: false,
|
empty: false,
|
||||||
},
|
},
|
||||||
requestOptions,
|
requestOptions,
|
||||||
),
|
).then((value) => value.data),
|
||||||
get: (input: SessionGetInput, requestOptions?: RequestOptions) =>
|
get: (input: SessionGetInput, requestOptions?: RequestOptions) =>
|
||||||
request<{ readonly data: SessionGetOutput }>(
|
request<{ readonly data: SessionGetOutput }>(
|
||||||
{
|
{
|
||||||
@@ -460,28 +449,6 @@ export function make(options: ClientOptions) {
|
|||||||
},
|
},
|
||||||
requestOptions,
|
requestOptions,
|
||||||
).then((value) => value.data),
|
).then((value) => value.data),
|
||||||
command: (input: SessionCommandInput, requestOptions?: RequestOptions) =>
|
|
||||||
request<{ readonly data: SessionCommandOutput }>(
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/command`,
|
|
||||||
body: {
|
|
||||||
id: input["id"],
|
|
||||||
command: input["command"],
|
|
||||||
arguments: input["arguments"],
|
|
||||||
agent: input["agent"],
|
|
||||||
model: input["model"],
|
|
||||||
files: input["files"],
|
|
||||||
agents: input["agents"],
|
|
||||||
delivery: input["delivery"],
|
|
||||||
resume: input["resume"],
|
|
||||||
},
|
|
||||||
successStatus: 200,
|
|
||||||
declaredStatuses: [409, 404, 500, 400, 401],
|
|
||||||
empty: false,
|
|
||||||
},
|
|
||||||
requestOptions,
|
|
||||||
).then((value) => value.data),
|
|
||||||
skill: (input: SessionSkillInput, requestOptions?: RequestOptions) =>
|
skill: (input: SessionSkillInput, requestOptions?: RequestOptions) =>
|
||||||
request<SessionSkillOutput>(
|
request<SessionSkillOutput>(
|
||||||
{
|
{
|
||||||
@@ -494,18 +461,6 @@ export function make(options: ClientOptions) {
|
|||||||
},
|
},
|
||||||
requestOptions,
|
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) =>
|
compact: (input: SessionCompactInput, requestOptions?: RequestOptions) =>
|
||||||
request<SessionCompactOutput>(
|
request<SessionCompactOutput>(
|
||||||
{
|
{
|
||||||
@@ -573,46 +528,24 @@ export function make(options: ClientOptions) {
|
|||||||
},
|
},
|
||||||
requestOptions,
|
requestOptions,
|
||||||
).then((value) => value.data),
|
).then((value) => value.data),
|
||||||
listContextEntries: (input: SessionListContextEntriesInput, requestOptions?: RequestOptions) =>
|
history: (input: SessionHistoryInput, requestOptions?: RequestOptions) =>
|
||||||
request<{ readonly data: SessionListContextEntriesOutput }>(
|
request<SessionHistoryOutput>(
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/context-entry`,
|
path: `/api/session/${encodeURIComponent(input.sessionID)}/history`,
|
||||||
|
query: { limit: input["limit"], after: input["after"] },
|
||||||
successStatus: 200,
|
successStatus: 200,
|
||||||
declaredStatuses: [404, 400, 401],
|
declaredStatuses: [404, 400, 401],
|
||||||
empty: false,
|
empty: false,
|
||||||
},
|
},
|
||||||
requestOptions,
|
requestOptions,
|
||||||
).then((value) => value.data),
|
|
||||||
putContextEntry: (input: SessionPutContextEntryInput, requestOptions?: RequestOptions) =>
|
|
||||||
request<SessionPutContextEntryOutput>(
|
|
||||||
{
|
|
||||||
method: "PUT",
|
|
||||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/context-entry/${encodeURIComponent(input.key)}`,
|
|
||||||
body: { value: input["value"] },
|
|
||||||
successStatus: 204,
|
|
||||||
declaredStatuses: [404, 400, 401],
|
|
||||||
empty: true,
|
|
||||||
},
|
|
||||||
requestOptions,
|
|
||||||
),
|
),
|
||||||
removeContextEntry: (input: SessionRemoveContextEntryInput, requestOptions?: RequestOptions) =>
|
events: (input: SessionEventsInput, requestOptions?: RequestOptions): AsyncIterable<SessionEventsOutput> =>
|
||||||
request<SessionRemoveContextEntryOutput>(
|
sse<SessionEventsOutput>(
|
||||||
{
|
|
||||||
method: "DELETE",
|
|
||||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/context-entry/${encodeURIComponent(input.key)}`,
|
|
||||||
successStatus: 204,
|
|
||||||
declaredStatuses: [404, 400, 401],
|
|
||||||
empty: true,
|
|
||||||
},
|
|
||||||
requestOptions,
|
|
||||||
),
|
|
||||||
log: (input: SessionLogInput, requestOptions?: RequestOptions): AsyncIterable<SessionLogOutput> =>
|
|
||||||
sse<SessionLogOutput>(
|
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/log`,
|
path: `/api/session/${encodeURIComponent(input.sessionID)}/event`,
|
||||||
query: { after: input["after"], follow: input["follow"] },
|
query: { after: input["after"] },
|
||||||
successStatus: 200,
|
successStatus: 200,
|
||||||
declaredStatuses: [404, 400, 401],
|
declaredStatuses: [404, 400, 401],
|
||||||
empty: false,
|
empty: false,
|
||||||
@@ -680,18 +613,6 @@ export function make(options: ClientOptions) {
|
|||||||
},
|
},
|
||||||
requestOptions,
|
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: {
|
generate: {
|
||||||
text: (input: GenerateTextInput, requestOptions?: RequestOptions) =>
|
text: (input: GenerateTextInput, requestOptions?: RequestOptions) =>
|
||||||
@@ -1054,11 +975,6 @@ export function make(options: ClientOptions) {
|
|||||||
{ method: "GET", path: `/api/event`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
|
{ method: "GET", path: `/api/event`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
|
||||||
requestOptions,
|
requestOptions,
|
||||||
),
|
),
|
||||||
changes: (requestOptions?: RequestOptions): AsyncIterable<EventChangesOutput> =>
|
|
||||||
sse<EventChangesOutput>(
|
|
||||||
{ method: "GET", path: `/api/event/changes`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
|
|
||||||
requestOptions,
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
pty: {
|
pty: {
|
||||||
list: (input?: PtyListInput, requestOptions?: RequestOptions) =>
|
list: (input?: PtyListInput, requestOptions?: RequestOptions) =>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,7 @@
|
|||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { DateTime, Effect, Stream } from "effect"
|
import { DateTime, Effect, Stream } from "effect"
|
||||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||||
import { AbsolutePath, Agent, Event, Location, Model, OpenCode, Prompt, Session, SessionMessage } from "../src/effect"
|
import { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Session, SessionMessage } from "../src/effect"
|
||||||
|
|
||||||
const caughtUp = { type: "log.caught_up" as const, aggregateID: "ses_test", seq: Event.Seq.make(1) }
|
|
||||||
|
|
||||||
test("session.get returns the decoded Effect projection", async () => {
|
test("session.get returns the decoded Effect projection", async () => {
|
||||||
const httpClient = HttpClient.make((request) =>
|
const httpClient = HttpClient.make((request) =>
|
||||||
@@ -62,20 +60,32 @@ test("event.subscribe terminates on Effect protocol decode failures", async () =
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("session methods retain decoded Effect inputs and outputs", async () => {
|
test("session methods retain decoded Effect inputs and outputs", async () => {
|
||||||
const logQueries: Array<Record<string, string>> = []
|
const historyQueries: Array<Record<string, string>> = []
|
||||||
|
let historyPage = 0
|
||||||
const httpClient = HttpClient.make((request) => {
|
const httpClient = HttpClient.make((request) => {
|
||||||
const url = request.url
|
const url = request.url
|
||||||
if (url.includes("/log")) {
|
if (url.includes("/event")) {
|
||||||
logQueries.push(Object.fromEntries(request.urlParams.params))
|
|
||||||
return Effect.succeed(
|
return Effect.succeed(
|
||||||
HttpClientResponse.fromWeb(
|
HttpClientResponse.fromWeb(
|
||||||
request,
|
request,
|
||||||
new Response(`data: ${JSON.stringify(modelSwitchedEvent)}\n\ndata: ${JSON.stringify(caughtUp)}\n\n`, {
|
new Response(`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`, {
|
||||||
headers: { "content-type": "text/event-stream" },
|
headers: { "content-type": "text/event-stream" },
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (url.includes("/history")) {
|
||||||
|
historyPage++
|
||||||
|
historyQueries.push(Object.fromEntries(request.urlParams.params))
|
||||||
|
return Effect.succeed(
|
||||||
|
HttpClientResponse.fromWeb(
|
||||||
|
request,
|
||||||
|
Response.json(
|
||||||
|
historyPage === 1 ? { data: [modelSwitchedEvent], hasMore: true } : { data: [], hasMore: false },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
if (url.includes("/prompt")) {
|
if (url.includes("/prompt")) {
|
||||||
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(admission)))
|
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(admission)))
|
||||||
}
|
}
|
||||||
@@ -87,10 +97,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
|||||||
}
|
}
|
||||||
if (url.endsWith("/api/session/active")) {
|
if (url.endsWith("/api/session/active")) {
|
||||||
return Effect.succeed(
|
return Effect.succeed(
|
||||||
HttpClientResponse.fromWeb(
|
HttpClientResponse.fromWeb(request, Response.json({ data: { ses_test: { type: "running" } } })),
|
||||||
request,
|
|
||||||
Response.json({ data: { ses_test: { type: "running" } }, watermarks: { ses_test: 3 } }),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (request.method === "POST" && url.endsWith("/api/session")) {
|
if (request.method === "POST" && url.endsWith("/api/session")) {
|
||||||
@@ -100,10 +107,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
|||||||
return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 204 })))
|
return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 204 })))
|
||||||
}
|
}
|
||||||
return Effect.succeed(
|
return Effect.succeed(
|
||||||
HttpClientResponse.fromWeb(
|
HttpClientResponse.fromWeb(request, Response.json({ data: [session.data], cursor: { next: "next" } })),
|
||||||
request,
|
|
||||||
Response.json({ data: [session.data], watermarks: { ses_test: 3 }, cursor: { next: "next" } }),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
const result = await Effect.gen(function* () {
|
const result = await Effect.gen(function* () {
|
||||||
@@ -126,20 +130,31 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
|||||||
yield* client.session.compact({ sessionID: Session.ID.make("ses_test") })
|
yield* client.session.compact({ sessionID: Session.ID.make("ses_test") })
|
||||||
yield* client.session.wait({ sessionID: Session.ID.make("ses_test") })
|
yield* client.session.wait({ sessionID: Session.ID.make("ses_test") })
|
||||||
const context = yield* client.session.context({ sessionID: Session.ID.make("ses_test") })
|
const context = yield* client.session.context({ sessionID: Session.ID.make("ses_test") })
|
||||||
const log = yield* client.session
|
const history = yield* client.session.history({
|
||||||
.log({ sessionID: Session.ID.make("ses_test"), after: Event.Seq.make(0) })
|
sessionID: Session.ID.make("ses_test"),
|
||||||
|
after: 0,
|
||||||
|
limit: 1,
|
||||||
|
})
|
||||||
|
const historyNext = history.hasMore
|
||||||
|
? yield* client.session.history({
|
||||||
|
sessionID: Session.ID.make("ses_test"),
|
||||||
|
after: history.data.at(-1)?.durable?.seq,
|
||||||
|
limit: 2,
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
const events = yield* client.session
|
||||||
|
.events({ sessionID: Session.ID.make("ses_test"), after: 0 })
|
||||||
.pipe(Stream.runCollect)
|
.pipe(Stream.runCollect)
|
||||||
yield* client.session.interrupt({ sessionID: Session.ID.make("ses_test") })
|
yield* client.session.interrupt({ sessionID: Session.ID.make("ses_test") })
|
||||||
const message = yield* client.session.message({
|
const message = yield* client.session.message({
|
||||||
sessionID: Session.ID.make("ses_test"),
|
sessionID: Session.ID.make("ses_test"),
|
||||||
messageID: SessionMessage.ID.make("msg_model"),
|
messageID: SessionMessage.ID.make("msg_model"),
|
||||||
})
|
})
|
||||||
return { page, active, created, admitted, context, log, message }
|
return { page, active, created, admitted, context, history, historyNext, events, message }
|
||||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||||
|
|
||||||
expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000)
|
expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000)
|
||||||
expect(result.active).toEqual({ data: { ses_test: { type: "running" } }, watermarks: { ses_test: 3 } })
|
expect(result.active).toEqual({ ses_test: { type: "running" } })
|
||||||
expect(result.page.watermarks).toEqual({ ses_test: 3 })
|
|
||||||
expect(Object.getPrototypeOf(result.page.data[0])).toBe(Object.prototype)
|
expect(Object.getPrototypeOf(result.page.data[0])).toBe(Object.prototype)
|
||||||
expect(Object.getPrototypeOf(result.created)).toBe(Object.prototype)
|
expect(Object.getPrototypeOf(result.created)).toBe(Object.prototype)
|
||||||
expect(result.created.id).toBe("ses_test")
|
expect(result.created.id).toBe("ses_test")
|
||||||
@@ -147,17 +162,16 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
|||||||
expect(Object.getPrototypeOf(result.admitted.prompt)).toBe(Object.prototype)
|
expect(Object.getPrototypeOf(result.admitted.prompt)).toBe(Object.prototype)
|
||||||
expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000)
|
expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000)
|
||||||
expect(result.context).toEqual([])
|
expect(result.context).toEqual([])
|
||||||
expect(logQueries[0]).toEqual({ after: "0" })
|
expect(DateTime.toEpochMillis(result.history.data[0].data.timestamp)).toBe(1_717_171_717_000)
|
||||||
const logged = Array.from(result.log)
|
expect(result.history).toEqual(expect.objectContaining({ hasMore: true }))
|
||||||
expect(logged.map((item) => item.type)).toEqual(["session.next.model.switched", "log.caught_up"])
|
expect(result.historyNext).toEqual({ data: [], hasMore: false })
|
||||||
expect(logged[0]?.type === "session.next.model.switched" && DateTime.toEpochMillis(logged[0].data.timestamp)).toBe(
|
expect(historyQueries[0]).toEqual({ limit: "1", after: "0" })
|
||||||
1_717_171_717_000,
|
expect(historyQueries[1]).toEqual({ limit: "2", after: "1" })
|
||||||
)
|
expect(DateTime.toEpochMillis(result.events[0].data.timestamp)).toBe(1_717_171_717_000)
|
||||||
expect(logged.at(-1)).toEqual(caughtUp)
|
|
||||||
expect(result.message).toEqual(expect.objectContaining({ id: "msg_model", type: "model-switched" }))
|
expect(result.message).toEqual(expect.objectContaining({ id: "msg_model", type: "model-switched" }))
|
||||||
})
|
})
|
||||||
|
|
||||||
test("session.log retains the typed SessionNotFoundError", async () => {
|
test("session.history retains the typed SessionNotFoundError", async () => {
|
||||||
const httpClient = HttpClient.make((request) =>
|
const httpClient = HttpClient.make((request) =>
|
||||||
Effect.succeed(
|
Effect.succeed(
|
||||||
HttpClientResponse.fromWeb(
|
HttpClientResponse.fromWeb(
|
||||||
@@ -171,7 +185,11 @@ test("session.log retains the typed SessionNotFoundError", async () => {
|
|||||||
)
|
)
|
||||||
const error = await Effect.gen(function* () {
|
const error = await Effect.gen(function* () {
|
||||||
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||||
return yield* client.session.log({ sessionID: Session.ID.make("ses_missing") }).pipe(Stream.runCollect, Effect.flip)
|
return yield* client.session
|
||||||
|
.history({
|
||||||
|
sessionID: Session.ID.make("ses_missing"),
|
||||||
|
})
|
||||||
|
.pipe(Effect.flip)
|
||||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||||
|
|
||||||
expect(error._tag).toBe("SessionNotFoundError")
|
expect(error._tag).toBe("SessionNotFoundError")
|
||||||
|
|||||||
@@ -8,14 +8,12 @@ test("exposes every standard HTTP API group", () => {
|
|||||||
"health",
|
"health",
|
||||||
"location",
|
"location",
|
||||||
"agent",
|
"agent",
|
||||||
"plugin",
|
|
||||||
"session",
|
"session",
|
||||||
"message",
|
"message",
|
||||||
"model",
|
"model",
|
||||||
"generate",
|
"generate",
|
||||||
"provider",
|
"provider",
|
||||||
"integration",
|
"integration",
|
||||||
"server.mcp",
|
|
||||||
"credential",
|
"credential",
|
||||||
"project",
|
"project",
|
||||||
"permission",
|
"permission",
|
||||||
@@ -174,6 +172,7 @@ test("event.subscribe terminates on malformed Promise SSE data", async () => {
|
|||||||
|
|
||||||
test("session methods use the public HTTP contract", async () => {
|
test("session methods use the public HTTP contract", async () => {
|
||||||
const requests: Array<{ url: string; init?: RequestInit }> = []
|
const requests: Array<{ url: string; init?: RequestInit }> = []
|
||||||
|
let historyPage = 0
|
||||||
const client = OpenCode.make({
|
const client = OpenCode.make({
|
||||||
baseUrl: "http://localhost:3000",
|
baseUrl: "http://localhost:3000",
|
||||||
fetch: async (input, init) => {
|
fetch: async (input, init) => {
|
||||||
@@ -184,16 +183,16 @@ test("session methods use the public HTTP contract", async () => {
|
|||||||
headers: { "content-type": "text/event-stream" },
|
headers: { "content-type": "text/event-stream" },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (url.includes("/log")) {
|
if (url.includes("/history")) {
|
||||||
return new Response(`data: ${JSON.stringify(modelSwitchedEvent)}\n\ndata: ${JSON.stringify(caughtUp)}\n\n`, {
|
historyPage++
|
||||||
headers: { "content-type": "text/event-stream" },
|
return Response.json(
|
||||||
})
|
historyPage === 1 ? { data: [modelSwitchedEvent], hasMore: true } : { data: [], hasMore: false },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (url.includes("/prompt")) return Response.json(admission)
|
if (url.includes("/prompt")) return Response.json(admission)
|
||||||
if (url.includes("/context")) return Response.json({ data: [] })
|
if (url.includes("/context")) return Response.json({ data: [] })
|
||||||
if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage })
|
if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage })
|
||||||
if (url.endsWith("/api/session/active"))
|
if (url.endsWith("/api/session/active")) return Response.json({ data: { ses_test: { type: "running" } } })
|
||||||
return Response.json({ data: { ses_test: { type: "running" } }, watermarks: { ses_test: 3 } })
|
|
||||||
if (init?.method === "POST" && url.endsWith("/api/session")) return Response.json(session)
|
if (init?.method === "POST" && url.endsWith("/api/session")) return Response.json(session)
|
||||||
if (init?.method === "POST") return new Response(null, { status: 204 })
|
if (init?.method === "POST") return new Response(null, { status: 204 })
|
||||||
return Response.json({ data: [session.data], cursor: { next: "next" } })
|
return Response.json({ data: [session.data], cursor: { next: "next" } })
|
||||||
@@ -216,17 +215,24 @@ test("session methods use the public HTTP contract", async () => {
|
|||||||
await client.session.compact({ sessionID: "ses_test" })
|
await client.session.compact({ sessionID: "ses_test" })
|
||||||
await client.session.wait({ sessionID: "ses_test" })
|
await client.session.wait({ sessionID: "ses_test" })
|
||||||
const context = await client.session.context({ sessionID: "ses_test" })
|
const context = await client.session.context({ sessionID: "ses_test" })
|
||||||
const log = []
|
const history = await client.session.history({ sessionID: "ses_test", after: 0, limit: 1 })
|
||||||
for await (const item of client.session.log({ sessionID: "ses_test", after: 0 })) log.push(item)
|
const historyAfter = history.data.at(-1)?.durable?.seq
|
||||||
|
const historyNext = history.hasMore
|
||||||
|
? await client.session.history({ sessionID: "ses_test", after: historyAfter, limit: 2 })
|
||||||
|
: undefined
|
||||||
|
const events = []
|
||||||
|
for await (const event of client.session.events({ sessionID: "ses_test", after: 0 })) events.push(event)
|
||||||
await client.session.interrupt({ sessionID: "ses_test" })
|
await client.session.interrupt({ sessionID: "ses_test" })
|
||||||
const message = await client.session.message({ sessionID: "ses_test", messageID: "msg_model" })
|
const message = await client.session.message({ sessionID: "ses_test", messageID: "msg_model" })
|
||||||
|
|
||||||
expect(page.cursor.next).toBe("next")
|
expect(page.cursor.next).toBe("next")
|
||||||
expect(active).toEqual({ data: { ses_test: { type: "running" } }, watermarks: { ses_test: 3 } })
|
expect(active).toEqual({ ses_test: { type: "running" } })
|
||||||
expect(created.id).toBe("ses_test")
|
expect(created.id).toBe("ses_test")
|
||||||
expect(admitted.id).toBe("msg_test")
|
expect(admitted.id).toBe("msg_test")
|
||||||
expect(context).toEqual([])
|
expect(context).toEqual([])
|
||||||
expect(log).toEqual([modelSwitchedEvent, caughtUp])
|
expect(history).toEqual({ data: [modelSwitchedEvent], hasMore: true })
|
||||||
|
expect(historyNext).toEqual({ data: [], hasMore: false })
|
||||||
|
expect(events).toEqual([modelSwitchedEvent])
|
||||||
expect(message).toEqual(modelSwitchedMessage)
|
expect(message).toEqual(modelSwitchedMessage)
|
||||||
expect(requests.map((request) => [request.init?.method, request.url])).toEqual([
|
expect(requests.map((request) => [request.init?.method, request.url])).toEqual([
|
||||||
["GET", "http://localhost:3000/api/session?limit=10&order=desc"],
|
["GET", "http://localhost:3000/api/session?limit=10&order=desc"],
|
||||||
@@ -238,7 +244,9 @@ test("session methods use the public HTTP contract", async () => {
|
|||||||
["POST", "http://localhost:3000/api/session/ses_test/compact"],
|
["POST", "http://localhost:3000/api/session/ses_test/compact"],
|
||||||
["POST", "http://localhost:3000/api/session/ses_test/wait"],
|
["POST", "http://localhost:3000/api/session/ses_test/wait"],
|
||||||
["GET", "http://localhost:3000/api/session/ses_test/context"],
|
["GET", "http://localhost:3000/api/session/ses_test/context"],
|
||||||
["GET", "http://localhost:3000/api/session/ses_test/log?after=0"],
|
["GET", "http://localhost:3000/api/session/ses_test/history?limit=1&after=0"],
|
||||||
|
["GET", "http://localhost:3000/api/session/ses_test/history?limit=2&after=1"],
|
||||||
|
["GET", "http://localhost:3000/api/session/ses_test/event?after=0"],
|
||||||
["POST", "http://localhost:3000/api/session/ses_test/interrupt"],
|
["POST", "http://localhost:3000/api/session/ses_test/interrupt"],
|
||||||
["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"],
|
["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"],
|
||||||
])
|
])
|
||||||
@@ -265,7 +273,7 @@ test("middleware errors remain declared client errors", async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("session.log decodes SessionNotFoundError", async () => {
|
test("session.history decodes SessionNotFoundError", async () => {
|
||||||
const client = OpenCode.make({
|
const client = OpenCode.make({
|
||||||
baseUrl: "http://localhost:3000",
|
baseUrl: "http://localhost:3000",
|
||||||
fetch: async () =>
|
fetch: async () =>
|
||||||
@@ -276,7 +284,7 @@ test("session.log decodes SessionNotFoundError", async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client.session.log({ sessionID: "ses_missing" })[Symbol.asyncIterator]().next()
|
await client.session.history({ sessionID: "ses_missing" })
|
||||||
throw new Error("Expected request to fail")
|
throw new Error("Expected request to fail")
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
expect(isSessionNotFoundError(error)).toBe(true)
|
expect(isSessionNotFoundError(error)).toBe(true)
|
||||||
@@ -321,8 +329,6 @@ const modelSwitchedMessage = {
|
|||||||
model: { id: "claude", providerID: "anthropic" },
|
model: { id: "claude", providerID: "anthropic" },
|
||||||
}
|
}
|
||||||
|
|
||||||
const caughtUp = { type: "log.caught_up", aggregateID: "ses_test", seq: 1 }
|
|
||||||
|
|
||||||
const modelSwitchedEvent = {
|
const modelSwitchedEvent = {
|
||||||
id: "evt_model",
|
id: "evt_model",
|
||||||
type: "session.next.model.switched",
|
type: "session.next.model.switched",
|
||||||
|
|||||||
+48
-222
@@ -1,10 +1,8 @@
|
|||||||
{
|
{
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "sqlite",
|
"dialect": "sqlite",
|
||||||
"id": "22e57fed-b9b8-4e94-a3b4-f94bece680a8",
|
"id": "f14a9b18-8207-487e-a3d3-227e629ba9ad",
|
||||||
"prevIds": [
|
"prevIds": ["169a0f0f-d58f-479f-b024-fa1c7b9a09db"],
|
||||||
"f14a9b18-8207-487e-a3d3-227e629ba9ad"
|
|
||||||
],
|
|
||||||
"ddl": [
|
"ddl": [
|
||||||
{
|
{
|
||||||
"name": "workspace",
|
"name": "workspace",
|
||||||
@@ -62,10 +60,6 @@
|
|||||||
"name": "session_context_epoch",
|
"name": "session_context_epoch",
|
||||||
"entityType": "tables"
|
"entityType": "tables"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "session_context_entry",
|
|
||||||
"entityType": "tables"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "session_input",
|
"name": "session_input",
|
||||||
"entityType": "tables"
|
"entityType": "tables"
|
||||||
@@ -926,56 +920,6 @@
|
|||||||
"entityType": "columns",
|
"entityType": "columns",
|
||||||
"table": "session_context_epoch"
|
"table": "session_context_epoch"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"name": "session_id",
|
|
||||||
"entityType": "columns",
|
|
||||||
"table": "session_context_entry"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"name": "key",
|
|
||||||
"entityType": "columns",
|
|
||||||
"table": "session_context_entry"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "text",
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"name": "value",
|
|
||||||
"entityType": "columns",
|
|
||||||
"table": "session_context_entry"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "integer",
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"name": "time_created",
|
|
||||||
"entityType": "columns",
|
|
||||||
"table": "session_context_entry"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "integer",
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"name": "time_updated",
|
|
||||||
"entityType": "columns",
|
|
||||||
"table": "session_context_entry"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
@@ -1537,13 +1481,9 @@
|
|||||||
"table": "session_share"
|
"table": "session_share"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["project_id"],
|
||||||
"project_id"
|
|
||||||
],
|
|
||||||
"tableTo": "project",
|
"tableTo": "project",
|
||||||
"columnsTo": [
|
"columnsTo": ["id"],
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "CASCADE",
|
"onDelete": "CASCADE",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
@@ -1552,13 +1492,9 @@
|
|||||||
"table": "workspace"
|
"table": "workspace"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["active_account_id"],
|
||||||
"active_account_id"
|
|
||||||
],
|
|
||||||
"tableTo": "account",
|
"tableTo": "account",
|
||||||
"columnsTo": [
|
"columnsTo": ["id"],
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "SET NULL",
|
"onDelete": "SET NULL",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
@@ -1567,13 +1503,9 @@
|
|||||||
"table": "account_state"
|
"table": "account_state"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["aggregate_id"],
|
||||||
"aggregate_id"
|
|
||||||
],
|
|
||||||
"tableTo": "event_sequence",
|
"tableTo": "event_sequence",
|
||||||
"columnsTo": [
|
"columnsTo": ["aggregate_id"],
|
||||||
"aggregate_id"
|
|
||||||
],
|
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "CASCADE",
|
"onDelete": "CASCADE",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
@@ -1582,13 +1514,9 @@
|
|||||||
"table": "event"
|
"table": "event"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["project_id"],
|
||||||
"project_id"
|
|
||||||
],
|
|
||||||
"tableTo": "project",
|
"tableTo": "project",
|
||||||
"columnsTo": [
|
"columnsTo": ["id"],
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "CASCADE",
|
"onDelete": "CASCADE",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
@@ -1597,13 +1525,9 @@
|
|||||||
"table": "permission"
|
"table": "permission"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["project_id"],
|
||||||
"project_id"
|
|
||||||
],
|
|
||||||
"tableTo": "project",
|
"tableTo": "project",
|
||||||
"columnsTo": [
|
"columnsTo": ["id"],
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "CASCADE",
|
"onDelete": "CASCADE",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
@@ -1612,13 +1536,9 @@
|
|||||||
"table": "project_directory"
|
"table": "project_directory"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["session_id"],
|
||||||
"session_id"
|
|
||||||
],
|
|
||||||
"tableTo": "session",
|
"tableTo": "session",
|
||||||
"columnsTo": [
|
"columnsTo": ["id"],
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "CASCADE",
|
"onDelete": "CASCADE",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
@@ -1627,13 +1547,9 @@
|
|||||||
"table": "message"
|
"table": "message"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["message_id"],
|
||||||
"message_id"
|
|
||||||
],
|
|
||||||
"tableTo": "message",
|
"tableTo": "message",
|
||||||
"columnsTo": [
|
"columnsTo": ["id"],
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "CASCADE",
|
"onDelete": "CASCADE",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
@@ -1642,13 +1558,9 @@
|
|||||||
"table": "part"
|
"table": "part"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["session_id"],
|
||||||
"session_id"
|
|
||||||
],
|
|
||||||
"tableTo": "session",
|
"tableTo": "session",
|
||||||
"columnsTo": [
|
"columnsTo": ["id"],
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "CASCADE",
|
"onDelete": "CASCADE",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
@@ -1657,28 +1569,9 @@
|
|||||||
"table": "session_context_epoch"
|
"table": "session_context_epoch"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["session_id"],
|
||||||
"session_id"
|
|
||||||
],
|
|
||||||
"tableTo": "session",
|
"tableTo": "session",
|
||||||
"columnsTo": [
|
"columnsTo": ["id"],
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onUpdate": "NO ACTION",
|
|
||||||
"onDelete": "CASCADE",
|
|
||||||
"nameExplicit": false,
|
|
||||||
"name": "fk_session_context_entry_session_id_session_id_fk",
|
|
||||||
"entityType": "fks",
|
|
||||||
"table": "session_context_entry"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"columns": [
|
|
||||||
"session_id"
|
|
||||||
],
|
|
||||||
"tableTo": "session",
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "CASCADE",
|
"onDelete": "CASCADE",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
@@ -1687,13 +1580,9 @@
|
|||||||
"table": "session_input"
|
"table": "session_input"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["session_id"],
|
||||||
"session_id"
|
|
||||||
],
|
|
||||||
"tableTo": "session",
|
"tableTo": "session",
|
||||||
"columnsTo": [
|
"columnsTo": ["id"],
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "CASCADE",
|
"onDelete": "CASCADE",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
@@ -1702,13 +1591,9 @@
|
|||||||
"table": "session_message"
|
"table": "session_message"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["project_id"],
|
||||||
"project_id"
|
|
||||||
],
|
|
||||||
"tableTo": "project",
|
"tableTo": "project",
|
||||||
"columnsTo": [
|
"columnsTo": ["id"],
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "CASCADE",
|
"onDelete": "CASCADE",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
@@ -1717,13 +1602,9 @@
|
|||||||
"table": "session"
|
"table": "session"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["session_id"],
|
||||||
"session_id"
|
|
||||||
],
|
|
||||||
"tableTo": "session",
|
"tableTo": "session",
|
||||||
"columnsTo": [
|
"columnsTo": ["id"],
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "CASCADE",
|
"onDelete": "CASCADE",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
@@ -1732,13 +1613,9 @@
|
|||||||
"table": "todo"
|
"table": "todo"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["session_id"],
|
||||||
"session_id"
|
|
||||||
],
|
|
||||||
"tableTo": "session",
|
"tableTo": "session",
|
||||||
"columnsTo": [
|
"columnsTo": ["id"],
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "CASCADE",
|
"onDelete": "CASCADE",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
@@ -1747,184 +1624,133 @@
|
|||||||
"table": "session_share"
|
"table": "session_share"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["email", "url"],
|
||||||
"email",
|
|
||||||
"url"
|
|
||||||
],
|
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "control_account_pk",
|
"name": "control_account_pk",
|
||||||
"entityType": "pks",
|
"entityType": "pks",
|
||||||
"table": "control_account"
|
"table": "control_account"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["project_id", "directory"],
|
||||||
"project_id",
|
|
||||||
"directory"
|
|
||||||
],
|
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "project_directory_pk",
|
"name": "project_directory_pk",
|
||||||
"entityType": "pks",
|
"entityType": "pks",
|
||||||
"table": "project_directory"
|
"table": "project_directory"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["session_id", "position"],
|
||||||
"session_id",
|
|
||||||
"key"
|
|
||||||
],
|
|
||||||
"nameExplicit": false,
|
|
||||||
"name": "session_context_entry_pk",
|
|
||||||
"entityType": "pks",
|
|
||||||
"table": "session_context_entry"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"columns": [
|
|
||||||
"session_id",
|
|
||||||
"position"
|
|
||||||
],
|
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "todo_pk",
|
"name": "todo_pk",
|
||||||
"entityType": "pks",
|
"entityType": "pks",
|
||||||
"table": "todo"
|
"table": "todo"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["id"],
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "workspace_pk",
|
"name": "workspace_pk",
|
||||||
"table": "workspace",
|
"table": "workspace",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["name"],
|
||||||
"name"
|
|
||||||
],
|
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "data_migration_pk",
|
"name": "data_migration_pk",
|
||||||
"table": "data_migration",
|
"table": "data_migration",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["id"],
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "account_state_pk",
|
"name": "account_state_pk",
|
||||||
"table": "account_state",
|
"table": "account_state",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["id"],
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "account_pk",
|
"name": "account_pk",
|
||||||
"table": "account",
|
"table": "account",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["id"],
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "credential_pk",
|
"name": "credential_pk",
|
||||||
"table": "credential",
|
"table": "credential",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["aggregate_id"],
|
||||||
"aggregate_id"
|
|
||||||
],
|
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "event_sequence_pk",
|
"name": "event_sequence_pk",
|
||||||
"table": "event_sequence",
|
"table": "event_sequence",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["id"],
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "event_pk",
|
"name": "event_pk",
|
||||||
"table": "event",
|
"table": "event",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["id"],
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "permission_pk",
|
"name": "permission_pk",
|
||||||
"table": "permission",
|
"table": "permission",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["id"],
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "project_pk",
|
"name": "project_pk",
|
||||||
"table": "project",
|
"table": "project",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["id"],
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "message_pk",
|
"name": "message_pk",
|
||||||
"table": "message",
|
"table": "message",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["id"],
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "part_pk",
|
"name": "part_pk",
|
||||||
"table": "part",
|
"table": "part",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["session_id"],
|
||||||
"session_id"
|
|
||||||
],
|
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "session_context_epoch_pk",
|
"name": "session_context_epoch_pk",
|
||||||
"table": "session_context_epoch",
|
"table": "session_context_epoch",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["id"],
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "session_input_pk",
|
"name": "session_input_pk",
|
||||||
"table": "session_input",
|
"table": "session_input",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["id"],
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "session_message_pk",
|
"name": "session_message_pk",
|
||||||
"table": "session_message",
|
"table": "session_message",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["id"],
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "session_pk",
|
"name": "session_pk",
|
||||||
"table": "session",
|
"table": "session",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["session_id"],
|
||||||
"session_id"
|
|
||||||
],
|
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "session_share_pk",
|
"name": "session_share_pk",
|
||||||
"table": "session_share",
|
"table": "session_share",
|
||||||
@@ -2242,4 +2068,4 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"renames": []
|
"renames": []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,6 @@ const layer = Layer.effect(
|
|||||||
? { ...model.api, settings: { ...provider.api.settings, ...model.api.settings } }
|
? { ...model.api, settings: { ...provider.api.settings, ...model.api.settings } }
|
||||||
: model.api
|
: model.api
|
||||||
const request = {
|
const request = {
|
||||||
settings: { ...provider.request.settings, ...model.request.settings },
|
|
||||||
headers: { ...provider.request.headers, ...model.request.headers },
|
headers: { ...provider.request.headers, ...model.request.headers },
|
||||||
body: { ...provider.request.body, ...model.request.body },
|
body: { ...provider.request.body, ...model.request.body },
|
||||||
variant: model.request.variant,
|
variant: model.request.variant,
|
||||||
|
|||||||
@@ -1,39 +1,17 @@
|
|||||||
export * as CommandV2 from "./command"
|
export * as CommandV2 from "./command"
|
||||||
|
|
||||||
import { makeLocationNode } from "./effect/app-node"
|
import { makeLocationNode } from "./effect/app-node"
|
||||||
import { Context, Effect, Layer, Schema, Types } from "effect"
|
import { Context, Effect, Layer, Types } from "effect"
|
||||||
import { Command } from "@opencode-ai/schema/command"
|
import { Command } from "@opencode-ai/schema/command"
|
||||||
import { State } from "./state"
|
import { State } from "./state"
|
||||||
import { MCP } from "./mcp/index"
|
|
||||||
import { EventV2 } from "./event"
|
|
||||||
import { AppProcess } from "./process"
|
|
||||||
import { ChildProcess } from "effect/unstable/process"
|
|
||||||
import { Config } from "./config"
|
|
||||||
import { Location } from "./location"
|
|
||||||
import { ShellSelect } from "./shell/select"
|
|
||||||
|
|
||||||
export const Info = Command.Info
|
export const Info = Command.Info
|
||||||
export type Info = Command.Info
|
export type Info = Command.Info
|
||||||
export const Event = Command.Event
|
|
||||||
|
|
||||||
export type Evaluation = {
|
|
||||||
readonly text: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Data = {
|
export type Data = {
|
||||||
commands: Map<string, Types.DeepMutable<Info>>
|
commands: Map<string, Types.DeepMutable<Info>>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Command.NotFoundError", {
|
|
||||||
command: Schema.String,
|
|
||||||
message: Schema.String,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export class EvaluationError extends Schema.TaggedErrorClass<EvaluationError>()("Command.EvaluationError", {
|
|
||||||
command: Schema.String,
|
|
||||||
message: Schema.String,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export type Draft = {
|
export type Draft = {
|
||||||
list: () => readonly Info[]
|
list: () => readonly Info[]
|
||||||
get: (name: string) => Info | undefined
|
get: (name: string) => Info | undefined
|
||||||
@@ -44,22 +22,13 @@ export type Draft = {
|
|||||||
export interface Interface extends State.Transformable<Draft> {
|
export interface Interface extends State.Transformable<Draft> {
|
||||||
readonly get: (name: string) => Effect.Effect<Info | undefined>
|
readonly get: (name: string) => Effect.Effect<Info | undefined>
|
||||||
readonly list: () => Effect.Effect<Info[]>
|
readonly list: () => Effect.Effect<Info[]>
|
||||||
readonly evaluate: (input: {
|
|
||||||
readonly name: string
|
|
||||||
readonly arguments?: string
|
|
||||||
}) => Effect.Effect<Evaluation, NotFoundError | EvaluationError>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Command") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Command") {}
|
||||||
|
|
||||||
const layer = Layer.effect(
|
const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.sync(() => {
|
||||||
const mcp = yield* MCP.Service
|
|
||||||
const events = yield* EventV2.Service
|
|
||||||
const processes = yield* AppProcess.Service
|
|
||||||
const config = yield* Config.Service
|
|
||||||
const location = yield* Location.Service
|
|
||||||
const state = State.create<Data, Draft>({
|
const state = State.create<Data, Draft>({
|
||||||
initial: () => ({ commands: new Map() }),
|
initial: () => ({ commands: new Map() }),
|
||||||
draft: (draft) => ({
|
draft: (draft) => ({
|
||||||
@@ -75,172 +44,19 @@ const layer = Layer.effect(
|
|||||||
draft.commands.delete(name)
|
draft.commands.delete(name)
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid),
|
|
||||||
})
|
|
||||||
const staticCommand = (name: string) => state.get().commands.get(name) as Info | undefined
|
|
||||||
const mcpCommands = Effect.fnUntraced(function* () {
|
|
||||||
return (yield* mcp.prompts()).map((prompt) =>
|
|
||||||
Info.make({
|
|
||||||
name: mcpCommandName(prompt.server, prompt.name),
|
|
||||||
template: "",
|
|
||||||
description: prompt.description,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return Service.of({
|
return Service.of({
|
||||||
reload: state.reload,
|
reload: state.reload,
|
||||||
transform: state.transform,
|
transform: state.transform,
|
||||||
get: Effect.fn("CommandV2.get")(function* (name) {
|
get: Effect.fn("CommandV2.get")(function* (name) {
|
||||||
const command = staticCommand(name)
|
return state.get().commands.get(name)
|
||||||
if (command) return command
|
|
||||||
return (yield* mcpCommands()).find((command) => command.name === name)
|
|
||||||
}),
|
}),
|
||||||
list: Effect.fn("CommandV2.list")(function* () {
|
list: Effect.fn("CommandV2.list")(function* () {
|
||||||
const commands = Array.from(state.get().commands.values()) as Info[]
|
return Array.from(state.get().commands.values())
|
||||||
const names = new Set(commands.map((command) => command.name))
|
|
||||||
return [
|
|
||||||
...commands,
|
|
||||||
...(yield* mcpCommands()).filter((command) => !names.has(command.name)),
|
|
||||||
]
|
|
||||||
}),
|
|
||||||
evaluate: Effect.fn("CommandV2.evaluate")(function* (input) {
|
|
||||||
const command = staticCommand(input.name)
|
|
||||||
if (command) return yield* evaluateTemplate(input.name, command.template, input.arguments ?? "", {
|
|
||||||
config,
|
|
||||||
location,
|
|
||||||
processes,
|
|
||||||
})
|
|
||||||
|
|
||||||
const prompt = (yield* mcp.prompts()).find((prompt) => mcpCommandName(prompt.server, prompt.name) === input.name)
|
|
||||||
if (!prompt) return yield* new NotFoundError({ command: input.name, message: `Command not found: ${input.name}` })
|
|
||||||
const result = yield* mcp
|
|
||||||
.prompt({
|
|
||||||
server: prompt.server,
|
|
||||||
name: prompt.name,
|
|
||||||
args: Object.fromEntries(
|
|
||||||
(prompt.arguments ?? []).map((argument, index) => [
|
|
||||||
argument.name,
|
|
||||||
parseArguments(input.arguments ?? "")[index] ?? "",
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
})
|
|
||||||
.pipe(
|
|
||||||
Effect.catchTag(
|
|
||||||
"MCP.NotFoundError",
|
|
||||||
() =>
|
|
||||||
Effect.fail(
|
|
||||||
new EvaluationError({
|
|
||||||
command: input.name,
|
|
||||||
message: `MCP server could not be found while evaluating prompt: ${prompt.server}`,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (!result)
|
|
||||||
return yield* new EvaluationError({
|
|
||||||
command: input.name,
|
|
||||||
message: `MCP prompt could not be evaluated: ${prompt.server}:${prompt.name}`,
|
|
||||||
})
|
|
||||||
return { text: result.messages.map((message) => promptMessageText(message.content)).join("\n").trim() }
|
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
function evaluateTemplate(
|
export const node = makeLocationNode({ service: Service, layer, deps: [] })
|
||||||
command: string,
|
|
||||||
template: string,
|
|
||||||
input: string,
|
|
||||||
services: {
|
|
||||||
readonly config: Config.Interface
|
|
||||||
readonly location: Location.Info
|
|
||||||
readonly processes: AppProcess.Interface
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
return Effect.gen(function* () {
|
|
||||||
const expanded = evaluateArguments(template, input)
|
|
||||||
return { text: yield* evaluateShell(command, expanded, services) }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function evaluateArguments(template: string, input: string) {
|
|
||||||
const args = parseArguments(input)
|
|
||||||
const placeholders = template.match(placeholderRegex) ?? []
|
|
||||||
const last = Math.max(0, ...placeholders.map((item) => Number(item.slice(1))))
|
|
||||||
const expanded = template.replaceAll(placeholderRegex, (_, index) => {
|
|
||||||
const position = Number(index)
|
|
||||||
const argIndex = position - 1
|
|
||||||
if (argIndex >= args.length) return ""
|
|
||||||
if (position === last) return args.slice(argIndex).join(" ")
|
|
||||||
return args[argIndex]
|
|
||||||
})
|
|
||||||
const withArguments = expanded.replaceAll("$ARGUMENTS", input)
|
|
||||||
if (placeholders.length === 0 && !template.includes("$ARGUMENTS") && input.trim()) return `${withArguments}\n\n${input}`.trim()
|
|
||||||
return withArguments.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
const evaluateShell = Effect.fnUntraced(function* (
|
|
||||||
command: string,
|
|
||||||
text: string,
|
|
||||||
services: {
|
|
||||||
readonly config: Config.Interface
|
|
||||||
readonly location: Location.Info
|
|
||||||
readonly processes: AppProcess.Interface
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const matches = Array.from(text.matchAll(shellRegex))
|
|
||||||
if (matches.length === 0) return text
|
|
||||||
const shell = ShellSelect.preferred(Config.latest(yield* services.config.entries(), "shell"))
|
|
||||||
const outputs = yield* Effect.forEach(
|
|
||||||
matches,
|
|
||||||
(match) => {
|
|
||||||
const source = match[1] ?? ""
|
|
||||||
return services.processes
|
|
||||||
.run(ChildProcess.make(shell, ShellSelect.args(shell, source), { cwd: services.location.directory, stdin: "ignore" }), {
|
|
||||||
combineOutput: true,
|
|
||||||
})
|
|
||||||
.pipe(
|
|
||||||
Effect.map((result) => (result.output ?? Buffer.concat([result.stdout, result.stderr])).toString("utf8")),
|
|
||||||
Effect.mapError(
|
|
||||||
(error) =>
|
|
||||||
new EvaluationError({ command, message: `Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}` }),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{ concurrency: 2 },
|
|
||||||
)
|
|
||||||
const iterator = outputs[Symbol.iterator]()
|
|
||||||
return text.replace(shellRegex, () => iterator.next().value ?? "")
|
|
||||||
})
|
|
||||||
|
|
||||||
function parseArguments(input: string) {
|
|
||||||
return (input.match(argsRegex) ?? []).map((arg) => arg.replace(quoteTrimRegex, ""))
|
|
||||||
}
|
|
||||||
|
|
||||||
function promptMessageText(content: unknown) {
|
|
||||||
if (typeof content === "string") return content
|
|
||||||
if (!content || typeof content !== "object") return ""
|
|
||||||
if (!("type" in content) || content.type !== "text") return ""
|
|
||||||
if (!("text" in content) || typeof content.text !== "string") return ""
|
|
||||||
return content.text
|
|
||||||
}
|
|
||||||
|
|
||||||
function mcpCommandName(server: string, prompt: string) {
|
|
||||||
return `${sanitize(server)}:${sanitize(prompt)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitize(value: string) {
|
|
||||||
return value.replace(/[^a-zA-Z0-9_-]/g, "_")
|
|
||||||
}
|
|
||||||
|
|
||||||
const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
|
|
||||||
const placeholderRegex = /\$(\d+)/g
|
|
||||||
const quoteTrimRegex = /^["']|["']$/g
|
|
||||||
const shellRegex = /!`([^`]+)`/g
|
|
||||||
|
|
||||||
export const node = makeLocationNode({
|
|
||||||
service: Service,
|
|
||||||
layer,
|
|
||||||
deps: [MCP.node, EventV2.node, AppProcess.node, Config.node, Location.node],
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { define } from "../../plugin/internal"
|
|||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Config } from "../../config"
|
import { Config } from "../../config"
|
||||||
import { ModelV2 } from "../../model"
|
import { ModelV2 } from "../../model"
|
||||||
|
import { ProviderV2 } from "../../provider"
|
||||||
|
|
||||||
export const Plugin = define({
|
export const Plugin = define({
|
||||||
id: "config-provider",
|
id: "config-provider",
|
||||||
@@ -53,7 +54,6 @@ export const Plugin = define({
|
|||||||
if (item.name !== undefined) provider.name = item.name
|
if (item.name !== undefined) provider.name = item.name
|
||||||
if (item.api !== undefined) provider.api = { ...item.api }
|
if (item.api !== undefined) provider.api = { ...item.api }
|
||||||
if (item.request !== undefined) {
|
if (item.request !== undefined) {
|
||||||
Object.assign(provider.request.settings, item.request.settings)
|
|
||||||
Object.assign(provider.request.headers, item.request.headers)
|
Object.assign(provider.request.headers, item.request.headers)
|
||||||
Object.assign(provider.request.body, item.request.body)
|
Object.assign(provider.request.body, item.request.body)
|
||||||
}
|
}
|
||||||
@@ -71,7 +71,6 @@ export const Plugin = define({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (config.request !== undefined) {
|
if (config.request !== undefined) {
|
||||||
Object.assign(model.request.settings, config.request.settings)
|
|
||||||
Object.assign(model.request.headers, config.request.headers)
|
Object.assign(model.request.headers, config.request.headers)
|
||||||
Object.assign(model.request.body, config.request.body)
|
Object.assign(model.request.body, config.request.body)
|
||||||
if (config.request.variant !== undefined) model.request.variant = config.request.variant
|
if (config.request.variant !== undefined) model.request.variant = config.request.variant
|
||||||
@@ -82,13 +81,11 @@ export const Plugin = define({
|
|||||||
if (!existing) {
|
if (!existing) {
|
||||||
existing = {
|
existing = {
|
||||||
id: variant.id,
|
id: variant.id,
|
||||||
settings: {},
|
|
||||||
headers: {},
|
headers: {},
|
||||||
body: {},
|
body: {},
|
||||||
}
|
}
|
||||||
model.variants.push(existing)
|
model.variants.push(existing)
|
||||||
}
|
}
|
||||||
Object.assign(existing.settings, variant.settings)
|
|
||||||
Object.assign(existing.headers, variant.headers)
|
Object.assign(existing.headers, variant.headers)
|
||||||
Object.assign(existing.body, variant.body)
|
Object.assign(existing.body, variant.body)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { ProviderV2 } from "../provider"
|
|||||||
import { ModelV2 } from "../model"
|
import { ModelV2 } from "../model"
|
||||||
|
|
||||||
export class Request extends Schema.Class<Request>("ConfigV2.Provider.Request")({
|
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),
|
headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
|
||||||
body: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
body: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||||
}) {}
|
}) {}
|
||||||
|
|||||||
-1
@@ -40,6 +40,5 @@ export const migrations = (
|
|||||||
import("./migration/20260622142730_simplify_session_context_epoch"),
|
import("./migration/20260622142730_simplify_session_context_epoch"),
|
||||||
import("./migration/20260622170816_reset_v2_session_state"),
|
import("./migration/20260622170816_reset_v2_session_state"),
|
||||||
import("./migration/20260622202450_simplify_session_input"),
|
import("./migration/20260622202450_simplify_session_input"),
|
||||||
import("./migration/20260702134641_add_session_context_entry"),
|
|
||||||
])
|
])
|
||||||
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
import { Effect } from "effect"
|
|
||||||
import type { DatabaseMigration } from "../migration"
|
|
||||||
|
|
||||||
export default {
|
|
||||||
id: "20260702134641_add_session_context_entry",
|
|
||||||
up(tx) {
|
|
||||||
return Effect.gen(function* () {
|
|
||||||
yield* tx.run(`
|
|
||||||
CREATE TABLE \`session_context_entry\` (
|
|
||||||
\`session_id\` text NOT NULL,
|
|
||||||
\`key\` text NOT NULL,
|
|
||||||
\`value\` text NOT NULL,
|
|
||||||
\`time_created\` integer NOT NULL,
|
|
||||||
\`time_updated\` integer NOT NULL,
|
|
||||||
CONSTRAINT \`session_context_entry_pk\` PRIMARY KEY(\`session_id\`, \`key\`),
|
|
||||||
CONSTRAINT \`fk_session_context_entry_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
`)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
} satisfies DatabaseMigration.Migration
|
|
||||||
@@ -154,17 +154,6 @@ export default {
|
|||||||
CONSTRAINT \`fk_session_context_epoch_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
CONSTRAINT \`fk_session_context_epoch_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
`)
|
`)
|
||||||
yield* tx.run(`
|
|
||||||
CREATE TABLE \`session_context_entry\` (
|
|
||||||
\`session_id\` text NOT NULL,
|
|
||||||
\`key\` text NOT NULL,
|
|
||||||
\`value\` text NOT NULL,
|
|
||||||
\`time_created\` integer NOT NULL,
|
|
||||||
\`time_updated\` integer NOT NULL,
|
|
||||||
CONSTRAINT \`session_context_entry_pk\` PRIMARY KEY(\`session_id\`, \`key\`),
|
|
||||||
CONSTRAINT \`fk_session_context_entry_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
`)
|
|
||||||
yield* tx.run(`
|
yield* tx.run(`
|
||||||
CREATE TABLE \`session_input\` (
|
CREATE TABLE \`session_input\` (
|
||||||
\`id\` text PRIMARY KEY,
|
\`id\` text PRIMARY KEY,
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
import { buildLocationServiceMap } from "../location-services"
|
import { buildLocationServiceMap } from "../location-services"
|
||||||
import { LocationServiceMap } from "../location-service-map"
|
import { LocationServiceMap } from "../location-service-map"
|
||||||
|
import { PluginRuntime } from "../plugin/runtime"
|
||||||
import { LayerNode } from "./layer-node"
|
import { LayerNode } from "./layer-node"
|
||||||
import { makeGlobalNode } from "./app-node"
|
import { makeGlobalNode } from "./app-node"
|
||||||
|
|
||||||
export function build<A, E>(root: LayerNode.Node<A, E, any>, replacements: LayerNode.Replacements = []) {
|
export function build<A, E>(root: LayerNode.Node<A, E, any>, replacements: LayerNode.Replacements = []) {
|
||||||
let allReplacements = replacements
|
const bridge = PluginRuntime.makeBridge()
|
||||||
|
let allReplacements = replacements.concat([
|
||||||
|
[PluginRuntime.node, PluginRuntime.nodeWithBridge(bridge)],
|
||||||
|
[PluginRuntime.providerNode, PluginRuntime.providerNodeWithBridge(bridge)],
|
||||||
|
])
|
||||||
|
|
||||||
// Only build the location service map if it's actually needed
|
// Only build the location service map if it's actually needed
|
||||||
if (LayerNode.hasUnbound(root, LocationServiceMap.node) && !hasReplacement(replacements, LocationServiceMap.node)) {
|
if (LayerNode.hasUnbound(root, LocationServiceMap.node) && !hasReplacement(allReplacements, LocationServiceMap.node)) {
|
||||||
const locationMap = buildLocationServiceMap(replacements)
|
const locationMap = buildLocationServiceMap(allReplacements)
|
||||||
const locationMapNode = makeGlobalNode({ service: LocationServiceMap.Service, layer: locationMap, deps: [] })
|
const locationMapNode = makeGlobalNode({ service: LocationServiceMap.Service, layer: locationMap, deps: [] })
|
||||||
allReplacements = replacements.concat([[LocationServiceMap.node, locationMapNode]])
|
allReplacements = allReplacements.concat([[LocationServiceMap.node, locationMapNode]])
|
||||||
}
|
}
|
||||||
|
|
||||||
return LayerNode.compile(root, allReplacements)
|
return LayerNode.compile(root, allReplacements)
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
File to save in: ~/.local/share/opencode/worktree/012780/location-layer-tiers/packages/core/src/effect/
|
||||||
+83
-179
@@ -3,7 +3,6 @@ export * as EventV2 from "./event"
|
|||||||
import { Cause, Context, Effect, Layer, Option, PubSub, Queue, Schema, Stream } from "effect"
|
import { Cause, Context, Effect, Layer, Option, PubSub, Queue, Schema, Stream } from "effect"
|
||||||
import { Event } from "@opencode-ai/schema/event"
|
import { Event } from "@opencode-ai/schema/event"
|
||||||
import type { Data, Definition, Payload } from "@opencode-ai/schema/event"
|
import type { Data, Definition, Payload } from "@opencode-ai/schema/event"
|
||||||
import type { EventLog } from "@opencode-ai/schema/event-log"
|
|
||||||
import { and, asc, eq, gt, inArray, sql } from "drizzle-orm"
|
import { and, asc, eq, gt, inArray, sql } from "drizzle-orm"
|
||||||
import { Database } from "./database/database"
|
import { Database } from "./database/database"
|
||||||
import { EventSequenceTable, EventTable } from "./event/sql"
|
import { EventSequenceTable, EventTable } from "./event/sql"
|
||||||
@@ -14,10 +13,6 @@ import { Durable } from "@opencode-ai/schema/durable-event-manifest"
|
|||||||
|
|
||||||
export const ID = Event.ID
|
export const ID = Event.ID
|
||||||
export type ID = import("@opencode-ai/schema/event").ID
|
export type ID = import("@opencode-ai/schema/event").ID
|
||||||
export const Seq = Event.Seq
|
|
||||||
export type Seq = import("@opencode-ai/schema/event").Seq
|
|
||||||
export const Version = Event.Version
|
|
||||||
export type Version = import("@opencode-ai/schema/event").Version
|
|
||||||
export type { Data, Definition, Payload } from "@opencode-ai/schema/event"
|
export type { Data, Definition, Payload } from "@opencode-ai/schema/event"
|
||||||
|
|
||||||
export type Subscriber<D extends Definition = Definition> = (event: Payload<D>) => Effect.Effect<void>
|
export type Subscriber<D extends Definition = Definition> = (event: Payload<D>) => Effect.Effect<void>
|
||||||
@@ -68,12 +63,6 @@ export class InvalidDurableEventError extends Schema.TaggedErrorClass<InvalidDur
|
|||||||
},
|
},
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
const envelope = (aggregateID: string, seq: number, version: number) => ({
|
|
||||||
aggregateID,
|
|
||||||
seq: Seq.make(seq),
|
|
||||||
version: Version.make(version),
|
|
||||||
})
|
|
||||||
|
|
||||||
const decodeSerializedEvent = (event: SerializedEvent): Payload => {
|
const decodeSerializedEvent = (event: SerializedEvent): Payload => {
|
||||||
const definition = Durable.get(event.type)
|
const definition = Durable.get(event.type)
|
||||||
if (!definition?.durable) {
|
if (!definition?.durable) {
|
||||||
@@ -82,11 +71,58 @@ const decodeSerializedEvent = (event: SerializedEvent): Payload => {
|
|||||||
return {
|
return {
|
||||||
id: event.id,
|
id: event.id,
|
||||||
type: definition.type,
|
type: definition.type,
|
||||||
durable: envelope(event.aggregateID, event.seq, definition.durable.version),
|
durable: { aggregateID: event.aggregateID, seq: event.seq, version: definition.durable.version },
|
||||||
data: Schema.decodeUnknownSync(definition.data)(event.data),
|
data: Schema.decodeUnknownSync(definition.data)(event.data),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const readAggregate = Effect.fn("EventV2.readAggregate")(function* <A>(
|
||||||
|
db: Database.Interface["db"],
|
||||||
|
input: {
|
||||||
|
readonly aggregateID: string
|
||||||
|
readonly after?: number
|
||||||
|
readonly limit: number
|
||||||
|
readonly manifest: {
|
||||||
|
readonly definitions: ReadonlyMap<string, Definition>
|
||||||
|
readonly schema: Schema.Decoder<A, never>
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const after = input.after ?? -1
|
||||||
|
const rows = yield* db
|
||||||
|
.select()
|
||||||
|
.from(EventTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(EventTable.aggregate_id, input.aggregateID),
|
||||||
|
gt(EventTable.seq, after),
|
||||||
|
inArray(EventTable.type, Array.from(input.manifest.definitions.keys())),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(asc(EventTable.seq))
|
||||||
|
.limit(input.limit + 1)
|
||||||
|
.all()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
const page = rows.slice(0, input.limit)
|
||||||
|
const decode = Schema.decodeUnknownSync(input.manifest.schema)
|
||||||
|
const events = page.map((event) =>
|
||||||
|
decode({
|
||||||
|
id: event.id,
|
||||||
|
type: input.manifest.definitions.get(event.type)?.type ?? event.type,
|
||||||
|
durable: {
|
||||||
|
aggregateID: event.aggregate_id,
|
||||||
|
seq: event.seq,
|
||||||
|
version: input.manifest.definitions.get(event.type)?.durable?.version,
|
||||||
|
},
|
||||||
|
data: event.data,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
events,
|
||||||
|
hasMore: rows.length > input.limit,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
export class SubscriberOverflowError extends Schema.TaggedErrorClass<SubscriberOverflowError>()(
|
export class SubscriberOverflowError extends Schema.TaggedErrorClass<SubscriberOverflowError>()(
|
||||||
"EventV2.SubscriberOverflow",
|
"EventV2.SubscriberOverflow",
|
||||||
{ capacity: Schema.Int },
|
{ capacity: Schema.Int },
|
||||||
@@ -103,11 +139,6 @@ export interface PublishOptions {
|
|||||||
readonly commit?: (seq: number) => Effect.Effect<void>
|
readonly commit?: (seq: number) => Effect.Effect<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Marker/event union emitted by `log`. Markers carry no event `id`. */
|
|
||||||
export type LogItem = Payload | EventLog.CaughtUp
|
|
||||||
|
|
||||||
export const isCaughtUp = (item: LogItem): item is EventLog.CaughtUp => !("id" in item)
|
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly publish: <D extends Definition>(
|
readonly publish: <D extends Definition>(
|
||||||
definition: D,
|
definition: D,
|
||||||
@@ -115,31 +146,8 @@ export interface Interface {
|
|||||||
options?: PublishOptions,
|
options?: PublishOptions,
|
||||||
) => Effect.Effect<Payload<D>>
|
) => Effect.Effect<Payload<D>>
|
||||||
readonly subscribe: <D extends Definition>(definition: D) => Stream.Stream<Payload<D>>
|
readonly subscribe: <D extends Definition>(definition: D) => Stream.Stream<Payload<D>>
|
||||||
/**
|
readonly all: () => Stream.Stream<Payload>
|
||||||
* Volatile live channel: every event published from now on, nothing before,
|
readonly durable: (input: { readonly aggregateID: string; readonly after?: number }) => Stream.Stream<Payload>
|
||||||
* nothing across a disconnect. The only channel that carries non-durable
|
|
||||||
* events; consumers that need reliability combine `changes` with `log`.
|
|
||||||
*/
|
|
||||||
readonly live: () => Stream.Stream<Payload>
|
|
||||||
/**
|
|
||||||
* Durable, ordered, gap-free per-aggregate log read. `follow: false`
|
|
||||||
* completes at the end of the log; `follow: true` replays then transitions
|
|
||||||
* to live. Both modes emit a `CaughtUp` marker at the replay boundary; the
|
|
||||||
* marker may be re-emitted after internal re-attaches.
|
|
||||||
*/
|
|
||||||
readonly log: (input: {
|
|
||||||
readonly aggregateID: string
|
|
||||||
readonly after?: number
|
|
||||||
readonly follow?: boolean
|
|
||||||
}) => Stream.Stream<LogItem>
|
|
||||||
/**
|
|
||||||
* Coalescing hint channel: latest committed seq per aggregate, never a
|
|
||||||
* delivery guarantee. Emits `SweepRequired` first on every subscribe and
|
|
||||||
* whenever per-key retention is exceeded. Never fails under backpressure.
|
|
||||||
*/
|
|
||||||
readonly changes: () => Stream.Stream<EventLog.Change>
|
|
||||||
/** Latest committed seq per aggregate. Aggregates without events are absent. */
|
|
||||||
readonly sequences: (aggregateIDs: ReadonlyArray<string>) => Effect.Effect<ReadonlyMap<string, Seq>>
|
|
||||||
/** @deprecated Use `all()` and consume the returned stream. */
|
/** @deprecated Use `all()` and consume the returned stream. */
|
||||||
readonly listen: (listener: Subscriber) => Effect.Effect<Unsubscribe>
|
readonly listen: (listener: Subscriber) => Effect.Effect<Unsubscribe>
|
||||||
readonly project: <D extends Definition>(definition: D, projector: Subscriber<D>) => Effect.Effect<void>
|
readonly project: <D extends Definition>(definition: D, projector: Subscriber<D>) => Effect.Effect<void>
|
||||||
@@ -157,7 +165,7 @@ export interface Interface {
|
|||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Event") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Event") {}
|
||||||
|
|
||||||
export const liveBounded = (events: Interface, capacity: number) =>
|
export const allBounded = (events: Interface, capacity: number) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const queue = yield* Queue.dropping<Payload, SubscriberOverflowError>(capacity)
|
const queue = yield* Queue.dropping<Payload, SubscriberOverflowError>(capacity)
|
||||||
const unsubscribe = yield* events.listen((event) =>
|
const unsubscribe = yield* events.listen((event) =>
|
||||||
@@ -173,11 +181,6 @@ export const liveBounded = (events: Interface, capacity: number) =>
|
|||||||
|
|
||||||
export interface LayerOptions {
|
export interface LayerOptions {
|
||||||
readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect<void>
|
readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect<void>
|
||||||
/**
|
|
||||||
* Maximum distinct aggregates buffered per changes subscriber before the
|
|
||||||
* buffer is abandoned and the subscriber is told to sweep.
|
|
||||||
*/
|
|
||||||
readonly changesKeyCapacity?: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const layerWith = (options?: LayerOptions) =>
|
export const layerWith = (options?: LayerOptions) =>
|
||||||
@@ -185,19 +188,13 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const pubsub = {
|
const pubsub = {
|
||||||
live: yield* PubSub.unbounded<Payload>(),
|
all: yield* PubSub.unbounded<Payload>(),
|
||||||
durable: new Map<string, Set<PubSub.PubSub<void>>>(),
|
durable: new Map<string, Set<PubSub.PubSub<void>>>(),
|
||||||
typed: new Map<string, PubSub.PubSub<Payload>>(),
|
typed: new Map<string, PubSub.PubSub<Payload>>(),
|
||||||
}
|
}
|
||||||
const projectors = new Map<string, Subscriber[]>()
|
const projectors = new Map<string, Subscriber[]>()
|
||||||
// TODO: Bind durable projectors to exact type+version before supporting incompatible historical payloads.
|
// TODO: Bind durable projectors to exact type+version before supporting incompatible historical payloads.
|
||||||
const listeners = new Array<Subscriber>()
|
const listeners = new Array<Subscriber>()
|
||||||
const changesKeyCapacity = options?.changesKeyCapacity ?? 4096
|
|
||||||
const changesSubscribers = new Set<{
|
|
||||||
readonly hints: Map<string, number>
|
|
||||||
sweepRequired: boolean
|
|
||||||
readonly wake: PubSub.PubSub<void>
|
|
||||||
}>()
|
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
|
|
||||||
const getOrCreate = (definition: Definition) =>
|
const getOrCreate = (definition: Definition) =>
|
||||||
@@ -211,16 +208,13 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
|
|
||||||
yield* Effect.addFinalizer(() =>
|
yield* Effect.addFinalizer(() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* PubSub.shutdown(pubsub.live)
|
yield* PubSub.shutdown(pubsub.all)
|
||||||
yield* Effect.forEach(
|
yield* Effect.forEach(
|
||||||
pubsub.durable.values(),
|
pubsub.durable.values(),
|
||||||
(pubsubs) => Effect.forEach(pubsubs, PubSub.shutdown, { discard: true }),
|
(pubsubs) => Effect.forEach(pubsubs, PubSub.shutdown, { discard: true }),
|
||||||
{ discard: true },
|
{ discard: true },
|
||||||
)
|
)
|
||||||
yield* Effect.forEach(pubsub.typed.values(), PubSub.shutdown, { discard: true })
|
yield* Effect.forEach(pubsub.typed.values(), PubSub.shutdown, { discard: true })
|
||||||
yield* Effect.forEach(changesSubscribers, (subscriber) => PubSub.shutdown(subscriber.wake), {
|
|
||||||
discard: true,
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -379,27 +373,6 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
(wake) => PubSub.publish(wake, undefined),
|
(wake) => PubSub.publish(wake, undefined),
|
||||||
{ discard: true },
|
{ discard: true },
|
||||||
)
|
)
|
||||||
yield* Effect.forEach(
|
|
||||||
changesSubscribers,
|
|
||||||
(subscriber) =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
// Coalesce to the latest seq per aggregate. Overflowing key
|
|
||||||
// cardinality abandons the buffer instead of dropping hints silently.
|
|
||||||
if (
|
|
||||||
subscriber.hints.size >= changesKeyCapacity &&
|
|
||||||
!subscriber.hints.has(committed.aggregateID)
|
|
||||||
) {
|
|
||||||
subscriber.hints.clear()
|
|
||||||
subscriber.sweepRequired = true
|
|
||||||
} else if (!subscriber.sweepRequired) {
|
|
||||||
subscriber.hints.set(
|
|
||||||
committed.aggregateID,
|
|
||||||
Math.max(subscriber.hints.get(committed.aggregateID) ?? -1, committed.seq),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}).pipe(Effect.andThen(PubSub.publish(subscriber.wake, undefined)), Effect.asVoid),
|
|
||||||
{ discard: true },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return committed
|
return committed
|
||||||
}),
|
}),
|
||||||
@@ -423,7 +396,11 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
if (committed) {
|
if (committed) {
|
||||||
event = {
|
event = {
|
||||||
...event,
|
...event,
|
||||||
durable: envelope(committed.aggregateID, committed.seq, definition.durable.version),
|
durable: {
|
||||||
|
aggregateID: committed.aggregateID,
|
||||||
|
seq: committed.seq,
|
||||||
|
version: definition.durable.version,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
yield* notify(event as Payload, true)
|
yield* notify(event as Payload, true)
|
||||||
return event
|
return event
|
||||||
@@ -451,7 +428,7 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
)
|
)
|
||||||
const typed = pubsub.typed.get(event.type)
|
const typed = pubsub.typed.get(event.type)
|
||||||
if (typed) yield* PubSub.publish(typed, event)
|
if (typed) yield* PubSub.publish(typed, event)
|
||||||
yield* PubSub.publish(pubsub.live, event)
|
yield* PubSub.publish(pubsub.all, event)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -503,7 +480,11 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
yield* notify(
|
yield* notify(
|
||||||
{
|
{
|
||||||
...payload,
|
...payload,
|
||||||
durable: envelope(committed.aggregateID, committed.seq, definition.durable.version),
|
durable: {
|
||||||
|
aggregateID: committed.aggregateID,
|
||||||
|
seq: committed.seq,
|
||||||
|
version: definition.durable.version,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
@@ -571,7 +552,7 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
Stream.map((event) => event as Payload<D>),
|
Stream.map((event) => event as Payload<D>),
|
||||||
)
|
)
|
||||||
|
|
||||||
const streamLive = (): Stream.Stream<Payload> => Stream.fromPubSub(pubsub.live)
|
const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(pubsub.all)
|
||||||
|
|
||||||
const readAfter = (aggregateID: string, after: number) =>
|
const readAfter = (aggregateID: string, after: number) =>
|
||||||
(options?.beforeAggregateRead?.(aggregateID) ?? Effect.void).pipe(
|
(options?.beforeAggregateRead?.(aggregateID) ?? Effect.void).pipe(
|
||||||
@@ -584,24 +565,17 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
.all(),
|
.all(),
|
||||||
),
|
),
|
||||||
Effect.orDie,
|
Effect.orDie,
|
||||||
// Skip types missing from the durable manifest instead of failing the
|
Effect.map((rows) =>
|
||||||
// read: the aggregate may hold events this process cannot decode. The
|
rows.map((event) =>
|
||||||
// raw tail seq keeps cursors advancing across the resulting gaps.
|
decodeSerializedEvent({
|
||||||
Effect.map((rows) => ({
|
id: event.id,
|
||||||
seq: rows.at(-1)?.seq,
|
aggregateID: event.aggregate_id,
|
||||||
events: rows.flatMap((event) => {
|
seq: event.seq,
|
||||||
if (!Durable.get(event.type)?.durable) return []
|
type: event.type,
|
||||||
return [
|
data: event.data,
|
||||||
decodeSerializedEvent({
|
}),
|
||||||
id: event.id,
|
),
|
||||||
aggregateID: event.aggregate_id,
|
),
|
||||||
seq: event.seq,
|
|
||||||
type: event.type,
|
|
||||||
data: event.data,
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
}),
|
|
||||||
})),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const subscribeDurable = (aggregateID: string) =>
|
const subscribeDurable = (aggregateID: string) =>
|
||||||
@@ -624,95 +598,27 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
return subscription
|
return subscription
|
||||||
})
|
})
|
||||||
|
|
||||||
const log = (input: {
|
const durable = (input: { readonly aggregateID: string; readonly after?: number }): Stream.Stream<Payload> =>
|
||||||
readonly aggregateID: string
|
|
||||||
readonly after?: number
|
|
||||||
readonly follow?: boolean
|
|
||||||
}): Stream.Stream<LogItem> =>
|
|
||||||
Stream.unwrap(
|
Stream.unwrap(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
const wakes = yield* subscribeDurable(input.aggregateID)
|
||||||
let sequence = input.after ?? -1
|
let sequence = input.after ?? -1
|
||||||
const read = Effect.suspend(() => readAfter(input.aggregateID, sequence)).pipe(
|
const read = Effect.suspend(() => readAfter(input.aggregateID, sequence)).pipe(
|
||||||
Effect.tap((page) =>
|
Effect.tap((events) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
sequence = page.seq ?? sequence
|
sequence = events.at(-1)?.durable?.seq ?? sequence
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
Effect.map((page) => page.events),
|
|
||||||
)
|
)
|
||||||
// Subscribing before the historical read means events committed during
|
|
||||||
// replay either appear in the read or arrive through a post-marker wake.
|
|
||||||
const wakes = input.follow ? yield* subscribeDurable(input.aggregateID) : undefined
|
|
||||||
const historical = yield* read
|
const historical = yield* read
|
||||||
const marker: EventLog.CaughtUp = {
|
|
||||||
type: "log.caught_up",
|
|
||||||
aggregateID: input.aggregateID,
|
|
||||||
...(sequence >= 0 ? { seq: Seq.make(sequence) } : {}),
|
|
||||||
}
|
|
||||||
const replay = Stream.fromIterable<LogItem>(historical).pipe(Stream.concat(Stream.make(marker)))
|
|
||||||
if (!wakes) return replay
|
|
||||||
const live = Stream.fromSubscription(wakes).pipe(
|
const live = Stream.fromSubscription(wakes).pipe(
|
||||||
Stream.mapEffect(() => read),
|
Stream.mapEffect(() => read),
|
||||||
Stream.flattenIterable,
|
Stream.flattenIterable,
|
||||||
)
|
)
|
||||||
return Stream.concat(replay, live)
|
return Stream.concat(Stream.fromIterable(historical), live)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const changes = (): Stream.Stream<EventLog.Change> =>
|
|
||||||
Stream.unwrap(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const wake = yield* PubSub.sliding<void>(1)
|
|
||||||
const subscription = yield* PubSub.subscribe(wake)
|
|
||||||
const subscriber = { hints: new Map<string, number>(), sweepRequired: false, wake }
|
|
||||||
yield* Effect.acquireRelease(
|
|
||||||
Effect.sync(() => changesSubscribers.add(subscriber)),
|
|
||||||
() =>
|
|
||||||
Effect.sync(() => changesSubscribers.delete(subscriber)).pipe(
|
|
||||||
Effect.andThen(PubSub.shutdown(wake)),
|
|
||||||
Effect.asVoid,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const drain = Effect.sync((): ReadonlyArray<EventLog.Change> => {
|
|
||||||
if (subscriber.sweepRequired) {
|
|
||||||
subscriber.sweepRequired = false
|
|
||||||
subscriber.hints.clear()
|
|
||||||
return [{ type: "log.sweep_required" }]
|
|
||||||
}
|
|
||||||
const hints = Array.from(
|
|
||||||
subscriber.hints,
|
|
||||||
([aggregateID, seq]): EventLog.Change => ({ type: "log.hint", aggregateID, seq: Seq.make(seq) }),
|
|
||||||
)
|
|
||||||
subscriber.hints.clear()
|
|
||||||
return hints
|
|
||||||
})
|
|
||||||
// Hints missed while unsubscribed were never buffered, so every
|
|
||||||
// (re)subscribe starts from the sweep contract.
|
|
||||||
const initial: EventLog.Change = { type: "log.sweep_required" }
|
|
||||||
return Stream.make(initial).pipe(
|
|
||||||
Stream.concat(
|
|
||||||
Stream.fromSubscription(subscription).pipe(
|
|
||||||
Stream.mapEffect(() => drain),
|
|
||||||
Stream.flattenIterable,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const sequences = (aggregateIDs: ReadonlyArray<string>): Effect.Effect<ReadonlyMap<string, Seq>> => {
|
|
||||||
if (aggregateIDs.length === 0) return Effect.succeed(new Map())
|
|
||||||
return db
|
|
||||||
.select({ aggregateID: EventSequenceTable.aggregate_id, seq: EventSequenceTable.seq })
|
|
||||||
.from(EventSequenceTable)
|
|
||||||
.where(inArray(EventSequenceTable.aggregate_id, Array.from(aggregateIDs)))
|
|
||||||
.all()
|
|
||||||
.pipe(
|
|
||||||
Effect.orDie,
|
|
||||||
Effect.map((rows) => new Map(rows.map((row) => [row.aggregateID, Seq.make(row.seq)]))),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const listen = (listener: Subscriber): Effect.Effect<Unsubscribe> =>
|
const listen = (listener: Subscriber): Effect.Effect<Unsubscribe> =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
listeners.push(listener)
|
listeners.push(listener)
|
||||||
@@ -732,10 +638,8 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
return Service.of({
|
return Service.of({
|
||||||
publish,
|
publish,
|
||||||
subscribe,
|
subscribe,
|
||||||
live: streamLive,
|
all: streamAll,
|
||||||
log,
|
durable,
|
||||||
changes,
|
|
||||||
sequences,
|
|
||||||
listen,
|
listen,
|
||||||
project,
|
project,
|
||||||
replay,
|
replay,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export * as InstructionContext from "./instruction-context"
|
export * as InstructionContext from "./instruction-context"
|
||||||
|
|
||||||
import { Array, Context, Effect, Layer, Schema } from "effect"
|
import { Array, Effect, Layer, Schema } from "effect"
|
||||||
import { isAbsolute, join, relative, sep } from "path"
|
import { isAbsolute, join, relative, sep } from "path"
|
||||||
import { FSUtil } from "./fs-util"
|
import { FSUtil } from "./fs-util"
|
||||||
import { Flag } from "./flag/flag"
|
import { Flag } from "./flag/flag"
|
||||||
@@ -8,6 +8,7 @@ import { Global } from "./global"
|
|||||||
import { Location } from "./location"
|
import { Location } from "./location"
|
||||||
import { AbsolutePath } from "./schema"
|
import { AbsolutePath } from "./schema"
|
||||||
import { SystemContext } from "./system-context/index"
|
import { SystemContext } from "./system-context/index"
|
||||||
|
import { SystemContextRegistry } from "./system-context/registry"
|
||||||
import { makeLocationNode } from "./effect/app-node"
|
import { makeLocationNode } from "./effect/app-node"
|
||||||
|
|
||||||
class File extends Schema.Class<File>("InstructionContext.File")({
|
class File extends Schema.Class<File>("InstructionContext.File")({
|
||||||
@@ -18,23 +19,16 @@ class File extends Schema.Class<File>("InstructionContext.File")({
|
|||||||
const Files = Schema.Array(File)
|
const Files = Schema.Array(File)
|
||||||
const key = SystemContext.Key.make("core/instructions")
|
const key = SystemContext.Key.make("core/instructions")
|
||||||
|
|
||||||
export interface Interface {
|
const layer = Layer.effectDiscard(
|
||||||
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* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* FSUtil.Service
|
const fs = yield* FSUtil.Service
|
||||||
const global = yield* Global.Service
|
const global = yield* Global.Service
|
||||||
const location = yield* Location.Service
|
const location = yield* Location.Service
|
||||||
|
const registry = yield* SystemContextRegistry.Service
|
||||||
|
|
||||||
const source = (value: ReadonlyArray<File> | SystemContext.Unavailable) =>
|
const source = (value: ReadonlyArray<File> | SystemContext.Unavailable) =>
|
||||||
SystemContext.make({
|
SystemContext.make({
|
||||||
key,
|
key,
|
||||||
description: "Ambient instructions",
|
|
||||||
codec: Schema.toCodecJson(Files),
|
codec: Schema.toCodecJson(Files),
|
||||||
load: Effect.succeed(value),
|
load: Effect.succeed(value),
|
||||||
baseline: render,
|
baseline: render,
|
||||||
@@ -77,24 +71,28 @@ const layer = Layer.effect(
|
|||||||
return files.filter((file): file is File => file !== undefined)
|
return files.filter((file): file is File => file !== undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
return Service.of({
|
yield* registry.register({
|
||||||
load: () =>
|
key,
|
||||||
observe().pipe(
|
load: observe().pipe(
|
||||||
Effect.map((files) =>
|
Effect.map((files) =>
|
||||||
files === SystemContext.unavailable
|
files === SystemContext.unavailable
|
||||||
? source(files)
|
? source(files)
|
||||||
: files.length === 0
|
: files.length === 0
|
||||||
? SystemContext.empty
|
? SystemContext.empty
|
||||||
: source(files),
|
: 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({ service: Service, layer, deps: [FSUtil.node, Global.node, Location.node] })
|
export const node = makeLocationNode({
|
||||||
|
name: "instruction-context",
|
||||||
|
layer,
|
||||||
|
deps: [FSUtil.node, Global.node, Location.node, SystemContextRegistry.node],
|
||||||
|
})
|
||||||
|
|
||||||
function render(files: ReadonlyArray<File>) {
|
function render(files: ReadonlyArray<File>) {
|
||||||
return files.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n")
|
return files.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n")
|
||||||
|
|||||||
@@ -36,10 +36,8 @@ import { SessionTodo } from "./session/todo"
|
|||||||
import { SkillV2 } from "./skill"
|
import { SkillV2 } from "./skill"
|
||||||
import { SkillGuidance } from "./skill/guidance"
|
import { SkillGuidance } from "./skill/guidance"
|
||||||
import { Snapshot } from "./snapshot"
|
import { Snapshot } from "./snapshot"
|
||||||
import { InstructionContext } from "./instruction-context"
|
|
||||||
import { SystemContextBuiltIns } from "./system-context/builtins"
|
import { SystemContextBuiltIns } from "./system-context/builtins"
|
||||||
import { SessionContextEntry } from "./session/context-entry"
|
import { SystemContextRegistry } from "./system-context/registry"
|
||||||
import { SessionInstructions } from "./session/instructions"
|
|
||||||
import { BuiltInTools } from "./tool/builtins"
|
import { BuiltInTools } from "./tool/builtins"
|
||||||
import { McpTool } from "./tool/mcp"
|
import { McpTool } from "./tool/mcp"
|
||||||
import { ReadToolFileSystem } from "./tool/read-filesystem"
|
import { ReadToolFileSystem } from "./tool/read-filesystem"
|
||||||
@@ -69,8 +67,8 @@ export const locationServices = LayerNode.group([
|
|||||||
Pty.node,
|
Pty.node,
|
||||||
Shell.node,
|
Shell.node,
|
||||||
SkillV2.node,
|
SkillV2.node,
|
||||||
|
SystemContextRegistry.node,
|
||||||
SystemContextBuiltIns.node,
|
SystemContextBuiltIns.node,
|
||||||
InstructionContext.node,
|
|
||||||
LocationMutation.node,
|
LocationMutation.node,
|
||||||
FileMutation.node,
|
FileMutation.node,
|
||||||
MCP.node,
|
MCP.node,
|
||||||
@@ -82,13 +80,11 @@ export const locationServices = LayerNode.group([
|
|||||||
SkillGuidance.node,
|
SkillGuidance.node,
|
||||||
ReferenceGuidance.node,
|
ReferenceGuidance.node,
|
||||||
SessionTodo.node,
|
SessionTodo.node,
|
||||||
SessionContextEntry.node,
|
|
||||||
QuestionV2.node,
|
QuestionV2.node,
|
||||||
Generate.node,
|
Generate.node,
|
||||||
ReadToolFileSystem.node,
|
ReadToolFileSystem.node,
|
||||||
BuiltInTools.node,
|
BuiltInTools.node,
|
||||||
McpTool.node,
|
McpTool.node,
|
||||||
SessionInstructions.node,
|
|
||||||
SessionRunnerModel.node,
|
SessionRunnerModel.node,
|
||||||
SessionCompaction.node,
|
SessionCompaction.node,
|
||||||
SessionTitle.node,
|
SessionTitle.node,
|
||||||
|
|||||||
@@ -9,12 +9,8 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
|
|||||||
import { UnauthorizedError, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
|
import { UnauthorizedError, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||||
import {
|
import {
|
||||||
CallToolResultSchema,
|
CallToolResultSchema,
|
||||||
GetPromptResultSchema,
|
|
||||||
ListPromptsResultSchema,
|
|
||||||
ListRootsRequestSchema,
|
ListRootsRequestSchema,
|
||||||
ListToolsResultSchema,
|
ListToolsResultSchema,
|
||||||
PromptListChangedNotificationSchema,
|
|
||||||
PromptSchema,
|
|
||||||
type LoggingMessageNotification,
|
type LoggingMessageNotification,
|
||||||
LoggingMessageNotificationSchema,
|
LoggingMessageNotificationSchema,
|
||||||
ToolListChangedNotificationSchema,
|
ToolListChangedNotificationSchema,
|
||||||
@@ -34,9 +30,6 @@ type Transport = StdioClientTransport | StreamableHTTPClientTransport
|
|||||||
const TolerantListToolsResult = ListToolsResultSchema.extend({
|
const TolerantListToolsResult = ListToolsResultSchema.extend({
|
||||||
tools: ToolSchema.omit({ outputSchema: true }).array(),
|
tools: ToolSchema.omit({ outputSchema: true }).array(),
|
||||||
})
|
})
|
||||||
const TolerantListPromptsResult = ListPromptsResultSchema.extend({
|
|
||||||
prompts: PromptSchema.array(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export class NeedsAuthError extends Schema.TaggedErrorClass<NeedsAuthError>()("MCP.NeedsAuthError", {
|
export class NeedsAuthError extends Schema.TaggedErrorClass<NeedsAuthError>()("MCP.NeedsAuthError", {
|
||||||
server: Schema.String,
|
server: Schema.String,
|
||||||
@@ -53,25 +46,6 @@ export interface ToolDefinition {
|
|||||||
readonly inputSchema: unknown
|
readonly inputSchema: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PromptDefinition {
|
|
||||||
readonly name: string
|
|
||||||
readonly description: string | undefined
|
|
||||||
readonly arguments: ReadonlyArray<{
|
|
||||||
readonly name: string
|
|
||||||
readonly description: string | undefined
|
|
||||||
readonly required: boolean | undefined
|
|
||||||
}> | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PromptMessage {
|
|
||||||
readonly role: string
|
|
||||||
readonly content: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PromptResult {
|
|
||||||
readonly messages: ReadonlyArray<PromptMessage>
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CallToolContent =
|
export type CallToolContent =
|
||||||
| { readonly type: "text"; readonly text: string }
|
| { readonly type: "text"; readonly text: string }
|
||||||
| { readonly type: "media"; readonly data: string; readonly mimeType: string }
|
| { readonly type: "media"; readonly data: string; readonly mimeType: string }
|
||||||
@@ -94,13 +68,6 @@ export interface Connection {
|
|||||||
readonly instructions: string | undefined
|
readonly instructions: string | undefined
|
||||||
/** Lists the server's tools; returns [] when the server doesn't advertise tool support, fails on a transport error. */
|
/** Lists the server's tools; returns [] when the server doesn't advertise tool support, fails on a transport error. */
|
||||||
readonly tools: () => Effect.Effect<ToolDefinition[], Error>
|
readonly tools: () => Effect.Effect<ToolDefinition[], Error>
|
||||||
/** Lists the server's prompts; returns [] when the server doesn't advertise prompt support, fails on a transport error. */
|
|
||||||
readonly prompts: () => Effect.Effect<PromptDefinition[], Error>
|
|
||||||
/** Invokes a prompt on the server. Interruption aborts the in-flight request. */
|
|
||||||
readonly prompt: (input: {
|
|
||||||
readonly name: string
|
|
||||||
readonly args?: Record<string, string>
|
|
||||||
}) => Effect.Effect<PromptResult, Error>
|
|
||||||
/** Invokes a tool on the server. Interruption aborts the in-flight request. */
|
/** Invokes a tool on the server. Interruption aborts the in-flight request. */
|
||||||
readonly callTool: (input: {
|
readonly callTool: (input: {
|
||||||
readonly name: string
|
readonly name: string
|
||||||
@@ -111,8 +78,6 @@ export interface Connection {
|
|||||||
readonly onLog: (callback: (message: LogMessage) => void) => void
|
readonly onLog: (callback: (message: LogMessage) => void) => void
|
||||||
/** Registers a callback fired when the server announces its tool list changed; no-op if unsupported. */
|
/** Registers a callback fired when the server announces its tool list changed; no-op if unsupported. */
|
||||||
readonly onToolsChanged: (callback: () => void) => void
|
readonly onToolsChanged: (callback: () => void) => void
|
||||||
/** Registers a callback fired when the server announces its prompt list changed; no-op if unsupported. */
|
|
||||||
readonly onPromptsChanged: (callback: () => void) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Connects an MCP server; closing the calling scope tears down the transport and any spawned process. */
|
/** Connects an MCP server; closing the calling scope tears down the transport and any spawned process. */
|
||||||
@@ -201,48 +166,6 @@ export const connect = Effect.fnUntraced(function* (
|
|||||||
inputSchema: tool.inputSchema,
|
inputSchema: tool.inputSchema,
|
||||||
}))
|
}))
|
||||||
}),
|
}),
|
||||||
prompts: () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
if (!client.getServerCapabilities()?.prompts) return []
|
|
||||||
const prompts = yield* Effect.tryPromise({
|
|
||||||
try: () =>
|
|
||||||
paginate(
|
|
||||||
async (cursor) => {
|
|
||||||
const params = cursor === undefined ? undefined : { cursor }
|
|
||||||
return client.request({ method: "prompts/list", params }, TolerantListPromptsResult, {
|
|
||||||
timeout: requestTimeout,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
(result) => result.prompts,
|
|
||||||
),
|
|
||||||
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
|
||||||
}).pipe(
|
|
||||||
Effect.tapError((error) => Effect.logWarning("failed to list MCP prompts", { server, error: error.message })),
|
|
||||||
)
|
|
||||||
return prompts.map((prompt) => ({
|
|
||||||
name: prompt.name,
|
|
||||||
description: prompt.description,
|
|
||||||
arguments: prompt.arguments?.map((argument) => ({
|
|
||||||
name: argument.name,
|
|
||||||
description: argument.description,
|
|
||||||
required: argument.required,
|
|
||||||
})),
|
|
||||||
}))
|
|
||||||
}),
|
|
||||||
prompt: (input) =>
|
|
||||||
Effect.tryPromise({
|
|
||||||
try: (signal) =>
|
|
||||||
client.request(
|
|
||||||
{ method: "prompts/get", params: { name: input.name, arguments: input.args ?? {} } },
|
|
||||||
GetPromptResultSchema,
|
|
||||||
{ signal, timeout: requestTimeout, resetTimeoutOnProgress: true, onprogress: () => {} },
|
|
||||||
),
|
|
||||||
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
|
||||||
}).pipe(
|
|
||||||
Effect.map((result) => ({
|
|
||||||
messages: result.messages.map((message) => ({ role: message.role, content: message.content })),
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
callTool: (input) =>
|
callTool: (input) =>
|
||||||
Effect.tryPromise({
|
Effect.tryPromise({
|
||||||
try: (signal) =>
|
try: (signal) =>
|
||||||
@@ -284,10 +207,6 @@ export const connect = Effect.fnUntraced(function* (
|
|||||||
if (!client.getServerCapabilities()?.tools?.listChanged) return
|
if (!client.getServerCapabilities()?.tools?.listChanged) return
|
||||||
client.setNotificationHandler(ToolListChangedNotificationSchema, async () => callback())
|
client.setNotificationHandler(ToolListChangedNotificationSchema, async () => callback())
|
||||||
},
|
},
|
||||||
onPromptsChanged: (callback) => {
|
|
||||||
if (!client.getServerCapabilities()?.prompts?.listChanged) return
|
|
||||||
client.setNotificationHandler(PromptListChangedNotificationSchema, async () => callback())
|
|
||||||
},
|
|
||||||
} satisfies Connection
|
} satisfies Connection
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,53 +14,16 @@ const Summary = Schema.Struct({
|
|||||||
})
|
})
|
||||||
type Summary = typeof Summary.Type
|
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>) =>
|
const render = (servers: ReadonlyArray<Summary>) =>
|
||||||
["<mcp_instructions>", ...entries(servers), "</mcp_instructions>"].join("\n")
|
[
|
||||||
|
"<mcp_instructions>",
|
||||||
const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary>) => {
|
...servers.flatMap((server) => [
|
||||||
const diff = SystemContext.diffByKey(
|
` <server name="${server.server}">`,
|
||||||
previous,
|
...server.instructions.split("\n").map((line) => ` ${line}`),
|
||||||
current,
|
" </server>",
|
||||||
(server) => server.server,
|
]),
|
||||||
(before, after) => before.instructions !== after.instructions,
|
"</mcp_instructions>",
|
||||||
)
|
].join("\n")
|
||||||
const items = SystemContext.diffItems(diff, (server) => ({
|
|
||||||
key: server.server,
|
|
||||||
description: "MCP server 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 {
|
|
||||||
text: [
|
|
||||||
"The available MCP server instructions have changed. This list supersedes the previous one.",
|
|
||||||
render(current),
|
|
||||||
].join("\n"),
|
|
||||||
items,
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
text: [
|
|
||||||
...(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"),
|
|
||||||
items,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly load: (agent: AgentV2.Selection) => Effect.Effect<SystemContext.SystemContext>
|
readonly load: (agent: AgentV2.Selection) => Effect.Effect<SystemContext.SystemContext>
|
||||||
@@ -87,8 +50,7 @@ export const layer = Layer.effect(
|
|||||||
return (
|
return (
|
||||||
owned.length === 0 ||
|
owned.length === 0 ||
|
||||||
owned.some(
|
owned.some(
|
||||||
(tool) =>
|
(tool) => PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny",
|
||||||
PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny",
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -96,11 +58,14 @@ export const layer = Layer.effect(
|
|||||||
if (visible.length === 0) return SystemContext.empty
|
if (visible.length === 0) return SystemContext.empty
|
||||||
return SystemContext.make({
|
return SystemContext.make({
|
||||||
key: SystemContext.Key.make("core/mcp-guidance"),
|
key: SystemContext.Key.make("core/mcp-guidance"),
|
||||||
description: "MCP server instructions",
|
|
||||||
codec: Schema.toCodecJson(Schema.Array(Summary)),
|
codec: Schema.toCodecJson(Schema.Array(Summary)),
|
||||||
load: Effect.succeed(visible),
|
load: Effect.succeed(visible),
|
||||||
baseline: render,
|
baseline: render,
|
||||||
update,
|
update: (_previous, current) =>
|
||||||
|
[
|
||||||
|
"The available MCP server instructions have changed. This list supersedes the previous one.",
|
||||||
|
render(current),
|
||||||
|
].join("\n"),
|
||||||
removed: () => "MCP server instructions are no longer available.",
|
removed: () => "MCP server instructions are no longer available.",
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ export * as MCP from "./index"
|
|||||||
|
|
||||||
import { Mcp } from "@opencode-ai/schema/mcp"
|
import { Mcp } from "@opencode-ai/schema/mcp"
|
||||||
import { McpEvent } from "@opencode-ai/schema/mcp-event"
|
import { McpEvent } from "@opencode-ai/schema/mcp-event"
|
||||||
import { Command } from "@opencode-ai/schema/command"
|
|
||||||
import { createHash } from "node:crypto"
|
import { createHash } from "node:crypto"
|
||||||
import { Cause, Context, Deferred, Effect, Exit, FiberSet, Layer, Schema, Scope, Stream } from "effect"
|
import { Cause, Context, Deferred, Effect, Exit, FiberSet, Layer, Schema, Scope, Stream } from "effect"
|
||||||
import { makeLocationNode } from "../effect/app-node"
|
import { makeLocationNode } from "../effect/app-node"
|
||||||
@@ -140,7 +139,6 @@ type ServerEntry = {
|
|||||||
scope?: Scope.Closeable
|
scope?: Scope.Closeable
|
||||||
client?: MCPClient.Connection
|
client?: MCPClient.Connection
|
||||||
tools?: ReadonlyArray<Tool>
|
tools?: ReadonlyArray<Tool>
|
||||||
prompts?: ReadonlyArray<Prompt>
|
|
||||||
// Set when a remote server is registered as an OAuth integration; the credential lives in the global store.
|
// Set when a remote server is registered as an OAuth integration; the credential lives in the global store.
|
||||||
integrationID?: Integration.ID
|
integrationID?: Integration.ID
|
||||||
}
|
}
|
||||||
@@ -311,21 +309,6 @@ export const layer = Layer.effect(
|
|||||||
const toTool = (server: ServerName, def: MCPClient.ToolDefinition) =>
|
const toTool = (server: ServerName, def: MCPClient.ToolDefinition) =>
|
||||||
new Tool({ server, name: def.name, description: def.description, inputSchema: def.inputSchema })
|
new Tool({ server, name: def.name, description: def.description, inputSchema: def.inputSchema })
|
||||||
|
|
||||||
const toPrompt = (server: ServerName, def: MCPClient.PromptDefinition) =>
|
|
||||||
new Prompt({
|
|
||||||
server,
|
|
||||||
name: def.name,
|
|
||||||
description: def.description,
|
|
||||||
arguments: def.arguments?.map(
|
|
||||||
(argument) =>
|
|
||||||
new PromptArgument({
|
|
||||||
name: argument.name,
|
|
||||||
description: argument.description,
|
|
||||||
required: argument.required,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
})
|
|
||||||
|
|
||||||
const refreshTools = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
|
const refreshTools = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
|
||||||
connection.tools().pipe(
|
connection.tools().pipe(
|
||||||
Effect.map((defs) => {
|
Effect.map((defs) => {
|
||||||
@@ -333,17 +316,6 @@ export const layer = Layer.effect(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const refreshPrompts = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
|
|
||||||
connection.prompts().pipe(
|
|
||||||
Effect.map((defs) => {
|
|
||||||
entry.prompts = defs.map((def) => toPrompt(name, def))
|
|
||||||
}),
|
|
||||||
Effect.andThen(events.publish(Command.Event.Updated, {})),
|
|
||||||
Effect.catch(() =>
|
|
||||||
Effect.sync(() => (entry.prompts = [])).pipe(Effect.andThen(events.publish(Command.Event.Updated, {}))),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const watch = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => {
|
const watch = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => {
|
||||||
connection.onClose(() => {
|
connection.onClose(() => {
|
||||||
// A reconnect closes the previous scope, but the SDK may fire this onclose after the new
|
// A reconnect closes the previous scope, but the SDK may fire this onclose after the new
|
||||||
@@ -351,10 +323,8 @@ export const layer = Layer.effect(
|
|||||||
if (entry.client !== connection) return
|
if (entry.client !== connection) return
|
||||||
entry.client = undefined
|
entry.client = undefined
|
||||||
entry.tools = undefined
|
entry.tools = undefined
|
||||||
entry.prompts = undefined
|
|
||||||
entry.status = { status: "failed", error: "Connection closed" }
|
entry.status = { status: "failed", error: "Connection closed" }
|
||||||
fork(events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore))
|
fork(events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore))
|
||||||
fork(events.publish(Command.Event.Updated, {}).pipe(Effect.ignore))
|
|
||||||
fork(events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore))
|
fork(events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore))
|
||||||
})
|
})
|
||||||
connection.onLog((message) => fork(serverLog(name, message).pipe(Effect.ignore)))
|
connection.onLog((message) => fork(serverLog(name, message).pipe(Effect.ignore)))
|
||||||
@@ -366,9 +336,6 @@ export const layer = Layer.effect(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
connection.onPromptsChanged(() => {
|
|
||||||
fork(refreshPrompts(name, entry, connection).pipe(Effect.ignore))
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const serverLog = (server: ServerName, message: MCPClient.LogMessage) => {
|
const serverLog = (server: ServerName, message: MCPClient.LogMessage) => {
|
||||||
@@ -397,14 +364,13 @@ export const layer = Layer.effect(
|
|||||||
// List tools as part of connect so a failure here marks the server failed rather than
|
// List tools as part of connect so a failure here marks the server failed rather than
|
||||||
// leaving it connected with a silently empty tool list and no path to recover.
|
// leaving it connected with a silently empty tool list and no path to recover.
|
||||||
const result = yield* MCPClient.connect(name, entry.config, location.directory, authProvider).pipe(
|
const result = yield* MCPClient.connect(name, entry.config, location.directory, authProvider).pipe(
|
||||||
Effect.flatMap((connection) => connection.tools().pipe(Effect.map((tools) => ({ connection, tools })))),
|
Effect.flatMap((connection) => connection.tools().pipe(Effect.map((defs) => ({ connection, defs })))),
|
||||||
Scope.provide(scope),
|
Scope.provide(scope),
|
||||||
Effect.exit,
|
Effect.exit,
|
||||||
)
|
)
|
||||||
if (Exit.isSuccess(result)) {
|
if (Exit.isSuccess(result)) {
|
||||||
entry.client = result.value.connection
|
entry.client = result.value.connection
|
||||||
entry.tools = result.value.tools.map((def) => toTool(name, def))
|
entry.tools = result.value.defs.map((def) => toTool(name, def))
|
||||||
entry.prompts = []
|
|
||||||
entry.status = { status: "connected" }
|
entry.status = { status: "connected" }
|
||||||
watch(name, entry, result.value.connection)
|
watch(name, entry, result.value.connection)
|
||||||
yield* Effect.logInfo("mcp connected", { server: name, tools: entry.tools.length })
|
yield* Effect.logInfo("mcp connected", { server: name, tools: entry.tools.length })
|
||||||
@@ -413,7 +379,6 @@ export const layer = Layer.effect(
|
|||||||
// stay invisible to the model.
|
// stay invisible to the model.
|
||||||
yield* events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)
|
yield* events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)
|
||||||
yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||||
fork(refreshPrompts(name, entry, result.value.connection).pipe(Effect.ignore))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
yield* Scope.close(scope, Exit.void)
|
yield* Scope.close(scope, Exit.void)
|
||||||
@@ -451,8 +416,6 @@ export const layer = Layer.effect(
|
|||||||
entry.scope = undefined
|
entry.scope = undefined
|
||||||
entry.client = undefined
|
entry.client = undefined
|
||||||
entry.tools = undefined
|
entry.tools = undefined
|
||||||
entry.prompts = undefined
|
|
||||||
yield* events.publish(Command.Event.Updated, {}).pipe(Effect.ignore)
|
|
||||||
}
|
}
|
||||||
yield* startServer(name, entry)
|
yield* startServer(name, entry)
|
||||||
})
|
})
|
||||||
@@ -526,25 +489,12 @@ export const layer = Layer.effect(
|
|||||||
.toSorted((a, b) => a.server.localeCompare(b.server))
|
.toSorted((a, b) => a.server.localeCompare(b.server))
|
||||||
}),
|
}),
|
||||||
prompts: Effect.fn("MCP.prompts")(function* () {
|
prompts: Effect.fn("MCP.prompts")(function* () {
|
||||||
return Array.from(runtime.values())
|
yield* whenAllReady
|
||||||
.flatMap((entry) => entry.prompts ?? [])
|
return []
|
||||||
.toSorted((a, b) => a.server.localeCompare(b.server) || a.name.localeCompare(b.name))
|
|
||||||
}),
|
}),
|
||||||
prompt: Effect.fn("MCP.prompt")(function* (input) {
|
prompt: Effect.fn("MCP.prompt")(function* (input) {
|
||||||
const target = yield* requireServer(input.server)
|
yield* gate(input.server)
|
||||||
yield* Deferred.await(target.entry.startup)
|
return undefined
|
||||||
if (!target.entry.client) return undefined
|
|
||||||
const result = yield* target.entry.client
|
|
||||||
.prompt({ name: input.name, args: input.args })
|
|
||||||
.pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
||||||
if (!result) return undefined
|
|
||||||
return new PromptResult({
|
|
||||||
server: target.name,
|
|
||||||
name: input.name,
|
|
||||||
messages: result.messages.map(
|
|
||||||
(message) => new PromptMessage({ role: message.role, content: message.content }),
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
resourceCatalog: Effect.fn("MCP.resourceCatalog")(function* () {
|
resourceCatalog: Effect.fn("MCP.resourceCatalog")(function* () {
|
||||||
yield* whenAllReady
|
yield* whenAllReady
|
||||||
|
|||||||
@@ -26,13 +26,8 @@ export type Api = Model.Api
|
|||||||
export const Info = Model.Info
|
export const Info = Model.Info
|
||||||
export type Info = Model.Info
|
export type Info = Model.Info
|
||||||
|
|
||||||
export type MutableRequest = ProviderV2.MutableRequest & { variant?: string }
|
export type MutableInfo = Omit<Types.DeepMutable<Info>, "api"> & {
|
||||||
export type MutableVariant = ProviderV2.MutableRequest & { id: VariantID }
|
|
||||||
|
|
||||||
export type MutableInfo = Omit<Types.DeepMutable<Info>, "api" | "request" | "variants"> & {
|
|
||||||
api: ProviderV2.MutableApi<Api>
|
api: ProviderV2.MutableApi<Api>
|
||||||
request: MutableRequest
|
|
||||||
variants: MutableVariant[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parse(input: string): { providerID: ProviderV2.ID; modelID: ID } {
|
export function parse(input: string): { providerID: ProviderV2.ID; modelID: ID } {
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { Reference } from "./reference"
|
|||||||
import { SkillV2 } from "./skill"
|
import { SkillV2 } from "./skill"
|
||||||
import { State } from "./state"
|
import { State } from "./state"
|
||||||
import { ToolRegistry } from "./tool/registry"
|
import { ToolRegistry } from "./tool/registry"
|
||||||
import { ToolHooks } from "./tool/hooks"
|
|
||||||
|
|
||||||
export const ID = Plugin.ID
|
export const ID = Plugin.ID
|
||||||
export type ID = typeof ID.Type
|
export type ID = typeof ID.Type
|
||||||
@@ -166,7 +165,6 @@ export const node = makeLocationNode({
|
|||||||
Reference.node,
|
Reference.node,
|
||||||
SkillV2.node,
|
SkillV2.node,
|
||||||
ToolRegistry.toolsNode,
|
ToolRegistry.toolsNode,
|
||||||
ToolHooks.node,
|
|
||||||
PluginRuntime.node,
|
PluginRuntime.node,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export const Plugin = define({
|
|||||||
draft.update("review", (command) => {
|
draft.update("review", (command) => {
|
||||||
command.template = PROMPT_REVIEW.replace("${path}", location.project.directory)
|
command.template = PROMPT_REVIEW.replace("${path}", location.project.directory)
|
||||||
command.description = "review changes [commit|branch|pr], defaults to uncommitted"
|
command.description = "review changes [commit|branch|pr], defaults to uncommitted"
|
||||||
|
command.subtask = true
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import { Reference } from "../reference"
|
|||||||
import { AbsolutePath, type DeepMutable } from "../schema"
|
import { AbsolutePath, type DeepMutable } from "../schema"
|
||||||
import { SkillV2 } from "../skill"
|
import { SkillV2 } from "../skill"
|
||||||
import { Tools } from "../tool/tools"
|
import { Tools } from "../tool/tools"
|
||||||
import { ToolHooks } from "../tool/hooks"
|
|
||||||
import { WorkspaceV2 } from "../workspace"
|
import { WorkspaceV2 } from "../workspace"
|
||||||
|
|
||||||
const mutable = <T>(value: T) => value as DeepMutable<T>
|
const mutable = <T>(value: T) => value as DeepMutable<T>
|
||||||
@@ -32,7 +31,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||||||
const reference = yield* Reference.Service
|
const reference = yield* Reference.Service
|
||||||
const skill = yield* SkillV2.Service
|
const skill = yield* SkillV2.Service
|
||||||
const tools = yield* Tools.Service
|
const tools = yield* Tools.Service
|
||||||
const toolHooks = yield* ToolHooks.Service
|
|
||||||
const runtime = yield* PluginRuntime.Service
|
const runtime = yield* PluginRuntime.Service
|
||||||
const locationInfo = () =>
|
const locationInfo = () =>
|
||||||
new Location.Info({
|
new Location.Info({
|
||||||
@@ -249,47 +247,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||||||
},
|
},
|
||||||
tool: {
|
tool: {
|
||||||
register: (input) => tools.register(input),
|
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: {
|
session: {
|
||||||
create: (input) =>
|
create: (input) =>
|
||||||
@@ -302,7 +259,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
|
|||||||
}),
|
}),
|
||||||
get: (input) => runtime.session.get(input.sessionID),
|
get: (input) => runtime.session.get(input.sessionID),
|
||||||
prompt: runtime.session.prompt,
|
prompt: runtime.session.prompt,
|
||||||
command: runtime.session.command,
|
|
||||||
interrupt: (input) => runtime.session.interrupt(input.sessionID),
|
interrupt: (input) => runtime.session.interrupt(input.sessionID),
|
||||||
},
|
},
|
||||||
} satisfies Interface
|
} satisfies Interface
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ export * as PluginInternal from "./internal"
|
|||||||
import { makeLocationNode } from "../effect/app-node"
|
import { makeLocationNode } from "../effect/app-node"
|
||||||
import { httpClient } from "../effect/app-node-platform"
|
import { httpClient } from "../effect/app-node-platform"
|
||||||
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
||||||
import { Context, Effect, Layer, Scope } from "effect"
|
import { Effect, Layer, Scope } from "effect"
|
||||||
import { AgentV2 } from "../agent"
|
import { AgentV2 } from "../agent"
|
||||||
import { Catalog } from "../catalog"
|
import { Catalog } from "../catalog"
|
||||||
import { CommandV2 } from "../command"
|
import { CommandV2 } from "../command"
|
||||||
@@ -27,7 +27,6 @@ import { PluginV2 } from "../plugin"
|
|||||||
import { PluginRuntime } from "../plugin/runtime"
|
import { PluginRuntime } from "../plugin/runtime"
|
||||||
import { PermissionV2 } from "../permission"
|
import { PermissionV2 } from "../permission"
|
||||||
import { Reference } from "../reference"
|
import { Reference } from "../reference"
|
||||||
import { Ripgrep } from "../ripgrep"
|
|
||||||
import { Shell } from "../shell"
|
import { Shell } from "../shell"
|
||||||
import { SkillV2 } from "../skill"
|
import { SkillV2 } from "../skill"
|
||||||
import { State } from "../state"
|
import { State } from "../state"
|
||||||
@@ -41,7 +40,6 @@ import { ProviderPlugins } from "./provider"
|
|||||||
import { SdkPlugins } from "./sdk"
|
import { SdkPlugins } from "./sdk"
|
||||||
import { SkillPlugin } from "./skill"
|
import { SkillPlugin } from "./skill"
|
||||||
import { VariantPlugin } from "./variant"
|
import { VariantPlugin } from "./variant"
|
||||||
import { GlobTool } from "../tool/glob"
|
|
||||||
import { ShellTool } from "../tool/shell"
|
import { ShellTool } from "../tool/shell"
|
||||||
import { SubagentTool } from "../tool/subagent"
|
import { SubagentTool } from "../tool/subagent"
|
||||||
|
|
||||||
@@ -63,7 +61,6 @@ export type Requirements =
|
|||||||
| PermissionV2.Service
|
| PermissionV2.Service
|
||||||
| PluginRuntime.Service
|
| PluginRuntime.Service
|
||||||
| Reference.Service
|
| Reference.Service
|
||||||
| Ripgrep.Service
|
|
||||||
| Shell.Service
|
| Shell.Service
|
||||||
| SkillV2.Service
|
| SkillV2.Service
|
||||||
| Tools.Service
|
| Tools.Service
|
||||||
@@ -79,35 +76,59 @@ export function define<R>(plugin: Plugin<R>) {
|
|||||||
|
|
||||||
const layer = Layer.effectDiscard(
|
const layer = Layer.effectDiscard(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
const catalog = yield* Catalog.Service
|
||||||
|
const commands = yield* CommandV2.Service
|
||||||
const plugin = yield* PluginV2.Service
|
const plugin = yield* PluginV2.Service
|
||||||
const sdkPlugins = yield* SdkPlugins.Service
|
const sdkPlugins = yield* SdkPlugins.Service
|
||||||
const services = Context.mergeAll(
|
const integration = yield* Integration.Service
|
||||||
Context.make(Catalog.Service, yield* Catalog.Service),
|
const agents = yield* AgentV2.Service
|
||||||
Context.make(CommandV2.Service, yield* CommandV2.Service),
|
const config = yield* Config.Service
|
||||||
Context.make(Integration.Service, yield* Integration.Service),
|
const location = yield* Location.Service
|
||||||
Context.make(AgentV2.Service, yield* AgentV2.Service),
|
const modelsDev = yield* ModelsDev.Service
|
||||||
Context.make(Config.Service, yield* Config.Service),
|
const npm = yield* Npm.Service
|
||||||
Context.make(Location.Service, yield* Location.Service),
|
const events = yield* EventV2.Service
|
||||||
Context.make(ModelsDev.Service, yield* ModelsDev.Service),
|
const fs = yield* FSUtil.Service
|
||||||
Context.make(Npm.Service, yield* Npm.Service),
|
const filesystem = yield* FileSystem.Service
|
||||||
Context.make(EventV2.Service, yield* EventV2.Service),
|
const global = yield* Global.Service
|
||||||
Context.make(FSUtil.Service, yield* FSUtil.Service),
|
const http = yield* HttpClient.HttpClient
|
||||||
Context.make(FileSystem.Service, yield* FileSystem.Service),
|
const mutation = yield* LocationMutation.Service
|
||||||
Context.make(Global.Service, yield* Global.Service),
|
const permission = yield* PermissionV2.Service
|
||||||
Context.make(HttpClient.HttpClient, yield* HttpClient.HttpClient),
|
const skill = yield* SkillV2.Service
|
||||||
Context.make(LocationMutation.Service, yield* LocationMutation.Service),
|
const reference = yield* Reference.Service
|
||||||
Context.make(PermissionV2.Service, yield* PermissionV2.Service),
|
const shell = yield* Shell.Service
|
||||||
Context.make(SkillV2.Service, yield* SkillV2.Service),
|
const tools = yield* Tools.Service
|
||||||
Context.make(Reference.Service, yield* Reference.Service),
|
const runtime = yield* PluginRuntime.Service
|
||||||
Context.make(Ripgrep.Service, yield* Ripgrep.Service),
|
const add = <R>(input: Plugin<R>) => {
|
||||||
Context.make(Shell.Service, yield* Shell.Service),
|
const loaded = {
|
||||||
Context.make(Tools.Service, yield* Tools.Service),
|
id: input.id,
|
||||||
Context.make(PluginRuntime.Service, yield* PluginRuntime.Service),
|
effect: (context: PluginContext) =>
|
||||||
)
|
input
|
||||||
const add = (input: Plugin<Requirements | Scope.Scope>) =>
|
.effect(context)
|
||||||
plugin.add(PluginV2.ID.make(input.id), (context: PluginContext) =>
|
.pipe(
|
||||||
input.effect(context).pipe(Effect.provide(services)),
|
Effect.provideService(Catalog.Service, catalog),
|
||||||
)
|
Effect.provideService(CommandV2.Service, commands),
|
||||||
|
Effect.provideService(Integration.Service, integration),
|
||||||
|
Effect.provideService(AgentV2.Service, agents),
|
||||||
|
Effect.provideService(Config.Service, config),
|
||||||
|
Effect.provideService(Location.Service, location),
|
||||||
|
Effect.provideService(ModelsDev.Service, modelsDev),
|
||||||
|
Effect.provideService(Npm.Service, npm),
|
||||||
|
Effect.provideService(EventV2.Service, events),
|
||||||
|
Effect.provideService(FSUtil.Service, fs),
|
||||||
|
Effect.provideService(FileSystem.Service, filesystem),
|
||||||
|
Effect.provideService(Global.Service, global),
|
||||||
|
Effect.provideService(HttpClient.HttpClient, http),
|
||||||
|
Effect.provideService(LocationMutation.Service, mutation),
|
||||||
|
Effect.provideService(PermissionV2.Service, permission),
|
||||||
|
Effect.provideService(SkillV2.Service, skill),
|
||||||
|
Effect.provideService(Reference.Service, reference),
|
||||||
|
Effect.provideService(Shell.Service, shell),
|
||||||
|
Effect.provideService(Tools.Service, tools),
|
||||||
|
Effect.provideService(PluginRuntime.Service, runtime),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return plugin.add(PluginV2.ID.make(loaded.id), loaded.effect)
|
||||||
|
}
|
||||||
|
|
||||||
yield* State.batch(
|
yield* State.batch(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -117,7 +138,6 @@ const layer = Layer.effectDiscard(
|
|||||||
yield* add(SkillPlugin.Plugin)
|
yield* add(SkillPlugin.Plugin)
|
||||||
yield* add(ModelsDevPlugin)
|
yield* add(ModelsDevPlugin)
|
||||||
yield* add(ConfigExternalPlugin.Plugin)
|
yield* add(ConfigExternalPlugin.Plugin)
|
||||||
yield* add(GlobTool.Plugin)
|
|
||||||
yield* add(ShellTool.Plugin)
|
yield* add(ShellTool.Plugin)
|
||||||
yield* add(SubagentTool.Plugin)
|
yield* add(SubagentTool.Plugin)
|
||||||
yield* add(ConfigAgentPlugin.Plugin)
|
yield* add(ConfigAgentPlugin.Plugin)
|
||||||
@@ -155,7 +175,6 @@ export const node = makeLocationNode({
|
|||||||
PermissionV2.node,
|
PermissionV2.node,
|
||||||
SkillV2.node,
|
SkillV2.node,
|
||||||
Reference.node,
|
Reference.node,
|
||||||
Ripgrep.node,
|
|
||||||
Shell.node,
|
Shell.node,
|
||||||
ToolRegistry.toolsNode,
|
ToolRegistry.toolsNode,
|
||||||
PluginRuntime.node,
|
PluginRuntime.node,
|
||||||
|
|||||||
@@ -70,73 +70,25 @@ function mergeCost(base: ModelV2Info["cost"], override: ModelsDev.Model["cost"]
|
|||||||
return [merge(baseDefault ?? { input: 0, output: 0, cache: { read: 0, write: 0 } }, nextDefault), ...tiers.values()]
|
return [merge(baseDefault ?? { input: 0, output: 0, cache: { read: 0, write: 0 } }, nextDefault), ...tiers.values()]
|
||||||
}
|
}
|
||||||
|
|
||||||
const OPENAI_INCLUDE_ENCRYPTED_REASONING = ["reasoning.encrypted_content"]
|
function reasoningVariants(model: ModelsDev.Model, packageName: string | undefined): ModelV2Info["variants"] {
|
||||||
|
const result = new Map<ModelV2.VariantID, ModelV2Info["variants"][number]>()
|
||||||
function reasoningVariants(provider: ModelsDev.Provider, model: ModelsDev.Model): ModelV2Info["variants"] {
|
if (packageName === "@ai-sdk/openai" || packageName === "@ai-sdk/openai-compatible") {
|
||||||
const npm = model.provider?.npm ?? provider.npm
|
const option = model.reasoning_options?.find((option) => option.type === "effort")
|
||||||
const options = model.reasoning_options ?? []
|
for (const value of option?.values ?? []) {
|
||||||
const effort = options.find((option) => option.type === "effort")
|
const id = value === null ? "none" : value
|
||||||
if (effort?.type === "effort") {
|
if (typeof id !== "string") continue
|
||||||
return effort.values.flatMap((value) => {
|
const variantID = ModelV2.VariantID.make(id)
|
||||||
const raw: unknown = value
|
result.set(variantID, {
|
||||||
const id = raw === null ? "none" : typeof raw === "string" ? raw : undefined
|
id: variantID,
|
||||||
if (id === undefined) return []
|
headers: {},
|
||||||
const settings = settingsForEffort(npm, id)
|
body:
|
||||||
return settings ? [{ id, settings, headers: {}, body: {} }] : []
|
packageName === "@ai-sdk/openai"
|
||||||
})
|
? { include: ["reasoning.encrypted_content"], reasoning: { effort: id, summary: "auto" } }
|
||||||
}
|
: { reasoning_effort: id },
|
||||||
|
})
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (npm === "@ai-sdk/openai-compatible") return { reasoningEffort: effort }
|
return [...result.values()]
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
function modeName(model: ModelsDev.Model, mode: string) {
|
||||||
@@ -241,7 +193,7 @@ export const ModelsDevPlugin = define({
|
|||||||
|
|
||||||
for (const model of Object.values(item.models)) {
|
for (const model of Object.values(item.models)) {
|
||||||
const baseCost = cost(model.cost)
|
const baseCost = cost(model.cost)
|
||||||
const variants = reasoningVariants(item, model)
|
const variants = reasoningVariants(model, model.provider?.npm ?? item.npm)
|
||||||
catalog.model.update(providerID, model.id, (draft) => applyModel(draft, model, { cost: baseCost, variants }))
|
catalog.model.update(providerID, model.id, (draft) => applyModel(draft, model, { cost: baseCost, variants }))
|
||||||
for (const [mode, options] of Object.entries(model.experimental?.modes ?? {})) {
|
for (const [mode, options] of Object.entries(model.experimental?.modes ?? {})) {
|
||||||
catalog.model.update(providerID, `${model.id}-${mode}`, (draft) =>
|
catalog.model.update(providerID, `${model.id}-${mode}`, (draft) =>
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
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,17 +1,15 @@
|
|||||||
import { createServer } from "node:http"
|
import { createServer } from "node:http"
|
||||||
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
|
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
|
||||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||||
import { Deferred, Effect, Semaphore, Stream } from "effect"
|
import { Deferred, Effect } from "effect"
|
||||||
import type { Scope } from "effect"
|
import type { Scope } from "effect"
|
||||||
import { Credential } from "../../credential"
|
import { Credential } from "../../credential"
|
||||||
import { EventV2 } from "../../event"
|
|
||||||
import { InstallationVersion } from "../../installation/version"
|
import { InstallationVersion } from "../../installation/version"
|
||||||
import { Integration } from "../../integration"
|
import { Integration } from "../../integration"
|
||||||
import { ModelV2 } from "../../model"
|
import { ModelV2 } from "../../model"
|
||||||
import { OauthCallbackPage } from "../../oauth/page"
|
import { OauthCallbackPage } from "../../oauth/page"
|
||||||
import { ProviderV2 } from "../../provider"
|
import { ProviderV2 } from "../../provider"
|
||||||
import type { PluginInternal } from "../internal"
|
import type { PluginInternal } from "../internal"
|
||||||
import { OpenAICodex } from "./openai-codex"
|
|
||||||
|
|
||||||
const clientID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
const clientID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||||
const issuer = "https://auth.openai.com"
|
const issuer = "https://auth.openai.com"
|
||||||
@@ -156,18 +154,6 @@ const headless = {
|
|||||||
export const OpenAIPlugin = define({
|
export const OpenAIPlugin = define({
|
||||||
id: "openai",
|
id: "openai",
|
||||||
effect: Effect.fn(function* (ctx) {
|
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) => {
|
yield* ctx.integration.transform((draft) => {
|
||||||
draft.method.update(browser)
|
draft.method.update(browser)
|
||||||
draft.method.update(headless)
|
draft.method.update(headless)
|
||||||
@@ -184,30 +170,8 @@ export const OpenAIPlugin = define({
|
|||||||
model.enabled = false
|
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(
|
yield* ctx.aisdk.sdk(
|
||||||
Effect.fn(function* (evt) {
|
Effect.fn(function* (evt) {
|
||||||
if (evt.package !== "@ai-sdk/openai") return
|
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)
|
const variantID = ModelV2.VariantID.make(id)
|
||||||
let existing = model.variants.find((item) => item.id === variantID)
|
let existing = model.variants.find((item) => item.id === variantID)
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
existing = { id: variantID, settings: {}, headers: {}, body: {} }
|
existing = { id: variantID, headers: {}, body: {} }
|
||||||
model.variants.push(existing)
|
model.variants.push(existing)
|
||||||
}
|
}
|
||||||
Object.assign(existing.headers, options.headers)
|
Object.assign(existing.headers, options.headers)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export * as PluginRuntime from "./runtime"
|
export * as PluginRuntime from "./runtime"
|
||||||
|
|
||||||
import { Context, Effect, Layer } from "effect"
|
import { Context, Deferred, Effect, Layer } from "effect"
|
||||||
import { AgentV2 } from "../agent"
|
import { AgentV2 } from "../agent"
|
||||||
import { makeGlobalNode } from "../effect/app-node"
|
import { makeGlobalNode } from "../effect/app-node"
|
||||||
import { Job } from "../job"
|
import { Job } from "../job"
|
||||||
@@ -11,7 +11,7 @@ import { SessionV2 } from "../session"
|
|||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly session: Pick<
|
readonly session: Pick<
|
||||||
SessionV2.Interface,
|
SessionV2.Interface,
|
||||||
"get" | "create" | "messages" | "prompt" | "command" | "resume" | "interrupt" | "synthetic"
|
"get" | "create" | "messages" | "prompt" | "resume" | "interrupt" | "synthetic"
|
||||||
>
|
>
|
||||||
readonly job: Pick<Job.Interface, "start" | "wait" | "block" | "background" | "cancel">
|
readonly job: Pick<Job.Interface, "start" | "wait" | "block" | "background" | "cancel">
|
||||||
readonly location: {
|
readonly location: {
|
||||||
@@ -25,52 +25,44 @@ export interface Interface {
|
|||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginRuntime") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginRuntime") {}
|
||||||
|
|
||||||
export interface Cell {
|
export interface Bridge {
|
||||||
runtime?: Interface
|
runtime: Deferred.Deferred<Interface>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const makeCell = (): Cell => ({})
|
export const makeBridge = (): Bridge => ({ runtime: Deferred.makeUnsafe<Interface>() })
|
||||||
|
|
||||||
const unavailable = <A, E, R>() => Effect.die("Plugin runtime is unavailable") as Effect.Effect<A, E, R>
|
const require = <A, E, R>(bridge: Bridge, f: (runtime: Interface) => Effect.Effect<A, E, R>) =>
|
||||||
const require = <A, E, R>(cell: Cell, f: (runtime: Interface) => Effect.Effect<A, E, R>) =>
|
Effect.suspend(() => Deferred.await(bridge.runtime).pipe(Effect.flatMap(f)))
|
||||||
Effect.suspend(() => {
|
|
||||||
const runtime = cell.runtime
|
|
||||||
if (runtime === undefined) return unavailable<A, E, R>()
|
|
||||||
return f(runtime)
|
|
||||||
})
|
|
||||||
|
|
||||||
const defaultCell = makeCell()
|
export const layerWithBridge = (bridge: Bridge) =>
|
||||||
|
|
||||||
export const layerWithCell = (cell: Cell) =>
|
|
||||||
Layer.succeed(
|
Layer.succeed(
|
||||||
Service,
|
Service,
|
||||||
Service.of({
|
Service.of({
|
||||||
session: {
|
session: {
|
||||||
get: (sessionID) => require(cell, (runtime) => runtime.session.get(sessionID)),
|
get: (sessionID) => require(bridge, (runtime) => runtime.session.get(sessionID)),
|
||||||
create: (input) => require(cell, (runtime) => runtime.session.create(input)),
|
create: (input) => require(bridge, (runtime) => runtime.session.create(input)),
|
||||||
messages: (input) => require(cell, (runtime) => runtime.session.messages(input)),
|
messages: (input) => require(bridge, (runtime) => runtime.session.messages(input)),
|
||||||
prompt: (input) => require(cell, (runtime) => runtime.session.prompt(input)),
|
prompt: (input) => require(bridge, (runtime) => runtime.session.prompt(input)),
|
||||||
command: (input) => require(cell, (runtime) => runtime.session.command(input)),
|
resume: (sessionID) => require(bridge, (runtime) => runtime.session.resume(sessionID)),
|
||||||
resume: (sessionID) => require(cell, (runtime) => runtime.session.resume(sessionID)),
|
interrupt: (sessionID) => require(bridge, (runtime) => runtime.session.interrupt(sessionID)),
|
||||||
interrupt: (sessionID) => require(cell, (runtime) => runtime.session.interrupt(sessionID)),
|
synthetic: (input) => require(bridge, (runtime) => runtime.session.synthetic(input)),
|
||||||
synthetic: (input) => require(cell, (runtime) => runtime.session.synthetic(input)),
|
|
||||||
},
|
},
|
||||||
job: {
|
job: {
|
||||||
start: (input) => require(cell, (runtime) => runtime.job.start(input)),
|
start: (input) => require(bridge, (runtime) => runtime.job.start(input)),
|
||||||
wait: (input) => require(cell, (runtime) => runtime.job.wait(input)),
|
wait: (input) => require(bridge, (runtime) => runtime.job.wait(input)),
|
||||||
block: (input) => require(cell, (runtime) => runtime.job.block(input)),
|
block: (input) => require(bridge, (runtime) => runtime.job.block(input)),
|
||||||
background: (id) => require(cell, (runtime) => runtime.job.background(id)),
|
background: (id) => require(bridge, (runtime) => runtime.job.background(id)),
|
||||||
cancel: (id) => require(cell, (runtime) => runtime.job.cancel(id)),
|
cancel: (id) => require(bridge, (runtime) => runtime.job.cancel(id)),
|
||||||
},
|
},
|
||||||
location: {
|
location: {
|
||||||
agent: {
|
agent: {
|
||||||
list: (ref) => require(cell, (runtime) => runtime.location.agent.list(ref)),
|
list: (ref) => require(bridge, (runtime) => runtime.location.agent.list(ref)),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const providerLayerWithCell = (cell: Cell) =>
|
export const providerLayerWithBridge = (bridge: Bridge) =>
|
||||||
Layer.effectDiscard(
|
Layer.effectDiscard(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const sessions = yield* SessionV2.Service
|
const sessions = yield* SessionV2.Service
|
||||||
@@ -97,27 +89,34 @@ export const providerLayerWithCell = (cell: Cell) =>
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
cell.runtime = runtime
|
yield* Deferred.succeed(bridge.runtime, runtime)
|
||||||
yield* Effect.addFinalizer(() =>
|
yield* Effect.addFinalizer(() =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
if (cell.runtime === runtime) cell.runtime = undefined
|
bridge.runtime = Deferred.makeUnsafe<Interface>()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const layer = layerWithCell(defaultCell)
|
const unsafeBridge = makeBridge()
|
||||||
export const providerLayer = providerLayerWithCell(defaultCell)
|
|
||||||
|
export const layer = layerWithBridge(unsafeBridge)
|
||||||
|
export const providerLayer = providerLayerWithBridge(unsafeBridge)
|
||||||
|
|
||||||
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
|
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
|
||||||
|
|
||||||
// Raw layer replacements are compiled without dependencies, so cell-scoped
|
export const nodeWithBridge = (bridge: Bridge) =>
|
||||||
// provider replacements must go through this node to keep their deps wired.
|
makeGlobalNode({ service: Service, layer: layerWithBridge(bridge), deps: [] })
|
||||||
export const providerNodeWithCell = (cell: Cell) =>
|
|
||||||
|
export const providerNode = makeGlobalNode({
|
||||||
|
name: "plugin-runtime-provider",
|
||||||
|
layer: providerLayer,
|
||||||
|
deps: [node, SessionV2.node, Job.node, LocationServiceMap.node],
|
||||||
|
})
|
||||||
|
|
||||||
|
export const providerNodeWithBridge = (bridge: Bridge) =>
|
||||||
makeGlobalNode({
|
makeGlobalNode({
|
||||||
name: "plugin-runtime-provider",
|
name: "plugin-runtime-provider",
|
||||||
layer: providerLayerWithCell(cell),
|
layer: providerLayerWithBridge(bridge),
|
||||||
deps: [node, SessionV2.node, Job.node, LocationServiceMap.node],
|
deps: [node, SessionV2.node, Job.node, LocationServiceMap.node],
|
||||||
})
|
})
|
||||||
|
|
||||||
export const providerNode = providerNodeWithCell(defaultCell)
|
|
||||||
|
|||||||
@@ -33,8 +33,7 @@ export function generate(model: ModelV2Info): ModelV2Info["variants"] {
|
|||||||
if (!["glm-5.2", "glm-5-2", "glm-5p2"].some((name) => ids.includes(name))) return []
|
if (!["glm-5.2", "glm-5-2", "glm-5p2"].some((name) => ids.includes(name))) return []
|
||||||
return ["high", "max"].map((id) => ({
|
return ["high", "max"].map((id) => ({
|
||||||
id,
|
id,
|
||||||
settings: { reasoningEffort: id },
|
|
||||||
headers: {},
|
headers: {},
|
||||||
body: {},
|
body: { reasoning_effort: id },
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,15 +19,7 @@ export type MutableApi<T extends Api = Api> = T extends Api
|
|||||||
export const Request = Provider.Request
|
export const Request = Provider.Request
|
||||||
export type Request = Provider.Request
|
export type Request = Provider.Request
|
||||||
|
|
||||||
export const Settings = Provider.Settings
|
|
||||||
export type Settings = Provider.Settings
|
|
||||||
|
|
||||||
export const Info = Provider.Info
|
export const Info = Provider.Info
|
||||||
export type Info = Provider.Info
|
export type Info = Provider.Info
|
||||||
|
|
||||||
export type MutableRequest = Types.DeepMutable<Request>
|
export type MutableInfo = Omit<Types.DeepMutable<Info>, "api"> & { api: MutableApi }
|
||||||
|
|
||||||
export type MutableInfo = Omit<Types.DeepMutable<Info>, "api" | "request"> & {
|
|
||||||
api: MutableApi
|
|
||||||
request: MutableRequest
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -11,58 +11,20 @@ const Summary = Schema.Struct({
|
|||||||
description: Schema.String.pipe(Schema.optional),
|
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>) =>
|
const render = (references: ReadonlyArray<typeof Summary.Type>) =>
|
||||||
[
|
[
|
||||||
"Project references provide additional directories that can be accessed when relevant.",
|
"Project references provide additional directories that can be accessed when relevant.",
|
||||||
"<available_references>",
|
"<available_references>",
|
||||||
...entries(references),
|
...references.flatMap((reference) => [
|
||||||
|
" <reference>",
|
||||||
|
` <name>${reference.name}</name>`,
|
||||||
|
` <path>${reference.path}</path>`,
|
||||||
|
...(reference.description === undefined ? [] : [` <description>${reference.description}</description>`]),
|
||||||
|
" </reference>",
|
||||||
|
]),
|
||||||
"</available_references>",
|
"</available_references>",
|
||||||
].join("\n")
|
].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,
|
|
||||||
)
|
|
||||||
const items = SystemContext.diffItems(diff, (reference) => ({
|
|
||||||
key: reference.name,
|
|
||||||
description: reference.description ?? reference.path,
|
|
||||||
}))
|
|
||||||
// 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 {
|
|
||||||
text: [
|
|
||||||
"The available project references have changed. This list supersedes the previous reference list.",
|
|
||||||
render(current),
|
|
||||||
].join("\n"),
|
|
||||||
items,
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
text: [
|
|
||||||
...(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"),
|
|
||||||
items,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly load: () => Effect.Effect<SystemContext.SystemContext>
|
readonly load: () => Effect.Effect<SystemContext.SystemContext>
|
||||||
}
|
}
|
||||||
@@ -87,11 +49,14 @@ const layer = Layer.effect(
|
|||||||
if (available.length === 0) return SystemContext.empty
|
if (available.length === 0) return SystemContext.empty
|
||||||
return SystemContext.make({
|
return SystemContext.make({
|
||||||
key: SystemContext.Key.make("core/reference-guidance"),
|
key: SystemContext.Key.make("core/reference-guidance"),
|
||||||
description: "Project references",
|
|
||||||
codec: Schema.toCodecJson(Schema.Array(Summary)),
|
codec: Schema.toCodecJson(Schema.Array(Summary)),
|
||||||
load: Effect.succeed(available),
|
load: Effect.succeed(available),
|
||||||
baseline: render,
|
baseline: render,
|
||||||
update,
|
update: (_previous, current) =>
|
||||||
|
[
|
||||||
|
"The available project references have changed. This list supersedes the previous reference list.",
|
||||||
|
render(current),
|
||||||
|
].join("\n"),
|
||||||
removed: () => "Project reference guidance is no longer available. Do not use previously listed references.",
|
removed: () => "Project reference guidance is no longer available. Do not use previously listed references.",
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|||||||
+26
-101
@@ -37,10 +37,9 @@ import { SessionCompaction } from "./session/compaction"
|
|||||||
import { SessionRevert } from "./session/revert"
|
import { SessionRevert } from "./session/revert"
|
||||||
import { Revert } from "@opencode-ai/schema/revert"
|
import { Revert } from "@opencode-ai/schema/revert"
|
||||||
import { FSUtil } from "./fs-util"
|
import { FSUtil } from "./fs-util"
|
||||||
import type { EventLog } from "@opencode-ai/schema/event-log"
|
import { SessionDurable } from "@opencode-ai/schema/durable-event-manifest"
|
||||||
import { SkillV2 } from "./skill"
|
import { SkillV2 } from "./skill"
|
||||||
import { Job } from "./job"
|
import { Job } from "./job"
|
||||||
import { CommandV2 } from "./command"
|
|
||||||
|
|
||||||
export const RevertState = Revert.State
|
export const RevertState = Revert.State
|
||||||
export type RevertState = Revert.State
|
export type RevertState = Revert.State
|
||||||
@@ -109,7 +108,7 @@ export class OperationUnavailableError extends Schema.TaggedErrorClass<Operation
|
|||||||
},
|
},
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
export { MessageDecodeError } from "./session/error"
|
export { ContextSnapshotDecodeError, MessageDecodeError } from "./session/error"
|
||||||
|
|
||||||
export class PromptConflictError extends Schema.TaggedErrorClass<PromptConflictError>()("Session.PromptConflictError", {
|
export class PromptConflictError extends Schema.TaggedErrorClass<PromptConflictError>()("Session.PromptConflictError", {
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
@@ -131,16 +130,10 @@ export type Error =
|
|||||||
| PromptConflictError
|
| PromptConflictError
|
||||||
| BusyError
|
| BusyError
|
||||||
| SkillNotFoundError
|
| SkillNotFoundError
|
||||||
| CommandV2.NotFoundError
|
|
||||||
| CommandV2.EvaluationError
|
|
||||||
| MessageNotFoundError
|
| MessageNotFoundError
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly list: (input?: ListInput) => Effect.Effect<{
|
readonly list: (input?: ListInput) => Effect.Effect<SessionSchema.Info[]>
|
||||||
readonly data: SessionSchema.Info[]
|
|
||||||
/** Per-session durable log watermark, read in the same transaction as the snapshot. Sessions without events are absent. */
|
|
||||||
readonly watermarks: ReadonlyMap<string, EventV2.Seq>
|
|
||||||
}>
|
|
||||||
readonly create: (input: CreateInput) => Effect.Effect<SessionSchema.Info, NotFoundError>
|
readonly create: (input: CreateInput) => Effect.Effect<SessionSchema.Info, NotFoundError>
|
||||||
readonly fork: (input: ForkInput) => Effect.Effect<SessionSchema.Info, NotFoundError | MessageNotFoundError>
|
readonly fork: (input: ForkInput) => Effect.Effect<SessionSchema.Info, NotFoundError | MessageNotFoundError>
|
||||||
readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info, NotFoundError>
|
readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info, NotFoundError>
|
||||||
@@ -160,20 +153,15 @@ export interface Interface {
|
|||||||
readonly context: (
|
readonly context: (
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
) => Effect.Effect<SessionMessage.Message[], NotFoundError | MessageDecodeError>
|
) => Effect.Effect<SessionMessage.Message[], NotFoundError | MessageDecodeError>
|
||||||
/**
|
readonly events: (input: {
|
||||||
* Durable, ordered, gap-free session log read. Replays public durable
|
|
||||||
* session events after the exclusive `after` cursor, emits a `CaughtUp`
|
|
||||||
* marker at the replay boundary, then continues live when `follow` is set.
|
|
||||||
* The marker's seq may exceed the last emitted event because non-public
|
|
||||||
* durable events share the aggregate's sequence space.
|
|
||||||
*/
|
|
||||||
readonly log: (input: {
|
|
||||||
sessionID: SessionSchema.ID
|
sessionID: SessionSchema.ID
|
||||||
after?: number
|
after?: number
|
||||||
follow?: boolean
|
}) => Stream.Stream<SessionEvent.DurableEvent, NotFoundError>
|
||||||
}) => Stream.Stream<SessionEvent.DurableEvent | EventLog.CaughtUp, NotFoundError>
|
readonly history: (input: {
|
||||||
/** Latest durable log seq per session. Sessions without events are absent. */
|
sessionID: SessionSchema.ID
|
||||||
readonly watermarks: (sessionIDs: ReadonlyArray<SessionSchema.ID>) => Effect.Effect<ReadonlyMap<string, EventV2.Seq>>
|
after?: number
|
||||||
|
limit: number
|
||||||
|
}) => Effect.Effect<{ events: ReadonlyArray<SessionEvent.DurableEvent>; hasMore: boolean }, NotFoundError>
|
||||||
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect<void, NotFoundError>
|
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect<void, NotFoundError>
|
||||||
readonly switchModel: (input: {
|
readonly switchModel: (input: {
|
||||||
sessionID: SessionSchema.ID
|
sessionID: SessionSchema.ID
|
||||||
@@ -187,21 +175,6 @@ export interface Interface {
|
|||||||
delivery?: SessionInput.Delivery
|
delivery?: SessionInput.Delivery
|
||||||
resume?: boolean
|
resume?: boolean
|
||||||
}) => Effect.Effect<SessionInput.Admitted, NotFoundError | PromptConflictError>
|
}) => Effect.Effect<SessionInput.Admitted, NotFoundError | PromptConflictError>
|
||||||
readonly command: (input: {
|
|
||||||
id?: SessionMessage.ID
|
|
||||||
sessionID: SessionSchema.ID
|
|
||||||
command: string
|
|
||||||
arguments?: string
|
|
||||||
agent?: string
|
|
||||||
model?: ModelV2.Ref
|
|
||||||
files?: PromptInput.Prompt["files"]
|
|
||||||
agents?: PromptInput.Prompt["agents"]
|
|
||||||
delivery?: SessionInput.Delivery
|
|
||||||
resume?: boolean
|
|
||||||
}) => Effect.Effect<
|
|
||||||
SessionInput.Admitted,
|
|
||||||
NotFoundError | PromptConflictError | CommandV2.NotFoundError | CommandV2.EvaluationError
|
|
||||||
>
|
|
||||||
readonly shell: (input: {
|
readonly shell: (input: {
|
||||||
id?: EventV2.ID
|
id?: EventV2.ID
|
||||||
sessionID: SessionSchema.ID
|
sessionID: SessionSchema.ID
|
||||||
@@ -226,7 +199,6 @@ export interface Interface {
|
|||||||
sessionID: SessionSchema.ID
|
sessionID: SessionSchema.ID
|
||||||
text: string
|
text: string
|
||||||
description?: string
|
description?: string
|
||||||
metadata?: Record<string, unknown>
|
|
||||||
}) => Effect.Effect<void, NotFoundError>
|
}) => Effect.Effect<void, NotFoundError>
|
||||||
readonly revert: {
|
readonly revert: {
|
||||||
readonly stage: (input: {
|
readonly stage: (input: {
|
||||||
@@ -385,21 +357,10 @@ const layer = Layer.effect(
|
|||||||
order === "asc" ? asc(sortColumn) : desc(sortColumn),
|
order === "asc" ? asc(sortColumn) : desc(sortColumn),
|
||||||
order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id),
|
order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id),
|
||||||
)
|
)
|
||||||
// Watermarks must pair with the snapshot exactly, so both reads share a transaction:
|
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
|
||||||
// a higher watermark would let an attached tail skip events missing from the snapshot.
|
Effect.orDie,
|
||||||
const snapshot = yield* db
|
)
|
||||||
.transaction(() =>
|
return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row))
|
||||||
Effect.gen(function* () {
|
|
||||||
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
|
|
||||||
Effect.orDie,
|
|
||||||
)
|
|
||||||
const watermarks = yield* events.sequences(rows.map((row) => row.id))
|
|
||||||
return { rows, watermarks }
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
const rows = direction === "previous" ? snapshot.rows.toReversed() : snapshot.rows
|
|
||||||
return { data: rows.map((row) => fromRow(row)), watermarks: snapshot.watermarks }
|
|
||||||
}),
|
}),
|
||||||
messages: Effect.fn("V2Session.messages")(function* (input) {
|
messages: Effect.fn("V2Session.messages")(function* (input) {
|
||||||
yield* result.get(input.sessionID)
|
yield* result.get(input.sessionID)
|
||||||
@@ -443,19 +404,19 @@ const layer = Layer.effect(
|
|||||||
yield* result.get(sessionID)
|
yield* result.get(sessionID)
|
||||||
return yield* store.context(sessionID)
|
return yield* store.context(sessionID)
|
||||||
}),
|
}),
|
||||||
log: (input) =>
|
events: (input) =>
|
||||||
Stream.unwrap(
|
Stream.unwrap(
|
||||||
result
|
result
|
||||||
.get(input.sessionID)
|
.get(input.sessionID)
|
||||||
.pipe(Effect.as(events.log({ aggregateID: input.sessionID, after: input.after, follow: input.follow }))),
|
.pipe(Effect.as(events.durable({ aggregateID: input.sessionID, after: input.after }))),
|
||||||
).pipe(
|
).pipe(Stream.filter((event): event is SessionEvent.DurableEvent => isDurableSessionEvent(event))),
|
||||||
Stream.filter(
|
history: Effect.fn("V2Session.history")(function* (input) {
|
||||||
(item): item is SessionEvent.DurableEvent | EventLog.CaughtUp =>
|
yield* result.get(input.sessionID)
|
||||||
EventV2.isCaughtUp(item) || isDurableSessionEvent(item),
|
return yield* EventV2.readAggregate(db, {
|
||||||
),
|
...input,
|
||||||
),
|
aggregateID: input.sessionID,
|
||||||
watermarks: Effect.fn("V2Session.watermarks")(function* (sessionIDs) {
|
manifest: SessionDurable,
|
||||||
return yield* events.sequences(sessionIDs)
|
})
|
||||||
}),
|
}),
|
||||||
prompt: Effect.fn("V2Session.prompt")((input) =>
|
prompt: Effect.fn("V2Session.prompt")((input) =>
|
||||||
Effect.uninterruptible(
|
Effect.uninterruptible(
|
||||||
@@ -488,37 +449,6 @@ const layer = Layer.effect(
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
command: Effect.fn("V2Session.command")(function* (input) {
|
|
||||||
const session = yield* result.get(input.sessionID)
|
|
||||||
const commands = yield* CommandV2.Service.pipe(Effect.provide(locations.get(session.location)))
|
|
||||||
const command = yield* commands.get(input.command)
|
|
||||||
if (!command)
|
|
||||||
return yield* new CommandV2.NotFoundError({
|
|
||||||
command: input.command,
|
|
||||||
message: `Command not found: ${input.command}`,
|
|
||||||
})
|
|
||||||
const evaluated = yield* commands.evaluate({ name: input.command, arguments: input.arguments })
|
|
||||||
|
|
||||||
// TODO(v2 commands): decide whether command-level subtask/background execution belongs in v2 commands.
|
|
||||||
const agent = command.agent ?? input.agent
|
|
||||||
const commandAgent = yield* Effect.gen(function* () {
|
|
||||||
if (!command.agent) return undefined
|
|
||||||
const agents = yield* AgentV2.Service.pipe(Effect.provide(locations.get(session.location)))
|
|
||||||
return yield* agents.get(AgentV2.ID.make(command.agent))
|
|
||||||
})
|
|
||||||
const model = command.model ?? commandAgent?.model ?? input.model
|
|
||||||
if (agent !== undefined && session.agent !== AgentV2.ID.make(agent))
|
|
||||||
yield* result.switchAgent({ sessionID: input.sessionID, agent })
|
|
||||||
if (model !== undefined) yield* result.switchModel({ sessionID: input.sessionID, model })
|
|
||||||
|
|
||||||
return yield* result.prompt({
|
|
||||||
id: input.id,
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
prompt: { text: evaluated.text, files: input.files, agents: input.agents },
|
|
||||||
delivery: input.delivery,
|
|
||||||
resume: input.resume,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
shell: Effect.fn("V2Session.shell")(function* () {
|
shell: Effect.fn("V2Session.shell")(function* () {
|
||||||
return yield* new OperationUnavailableError({ operation: "shell" })
|
return yield* new OperationUnavailableError({ operation: "shell" })
|
||||||
}),
|
}),
|
||||||
@@ -535,9 +465,7 @@ const layer = Layer.effect(
|
|||||||
text: skill.content,
|
text: skill.content,
|
||||||
})
|
})
|
||||||
if (input.resume !== false)
|
if (input.resume !== false)
|
||||||
yield* execution
|
yield* execution.resume(input.sessionID).pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
|
||||||
.resume(input.sessionID)
|
|
||||||
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
|
|
||||||
}),
|
}),
|
||||||
switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) {
|
switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) {
|
||||||
yield* result.get(input.sessionID)
|
yield* result.get(input.sessionID)
|
||||||
@@ -619,11 +547,8 @@ const layer = Layer.effect(
|
|||||||
timestamp: yield* DateTime.now,
|
timestamp: yield* DateTime.now,
|
||||||
text: input.text,
|
text: input.text,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
metadata: input.metadata,
|
|
||||||
})
|
})
|
||||||
yield* execution
|
yield* execution.resume(input.sessionID).pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
|
||||||
.resume(input.sessionID)
|
|
||||||
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
|
|
||||||
}),
|
}),
|
||||||
interrupt: Effect.fn("V2Session.interrupt")((sessionID) =>
|
interrupt: Effect.fn("V2Session.interrupt")((sessionID) =>
|
||||||
Effect.uninterruptible(execution.interrupt(sessionID)),
|
Effect.uninterruptible(execution.interrupt(sessionID)),
|
||||||
|
|||||||
@@ -321,12 +321,12 @@ export const layer = Layer.effect(
|
|||||||
compactIfNeeded: compaction.compactIfNeeded,
|
compactIfNeeded: compaction.compactIfNeeded,
|
||||||
compactAfterOverflow: compaction.compactAfterOverflow,
|
compactAfterOverflow: compaction.compactAfterOverflow,
|
||||||
compactManual: Effect.fn("SessionCompaction.compactManual")(function* (input) {
|
compactManual: Effect.fn("SessionCompaction.compactManual")(function* (input) {
|
||||||
const resolved = yield* models.resolve(input.session).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
const model = yield* models.resolve(input.session).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||||
if (!resolved) return false
|
if (!model) return false
|
||||||
return yield* compaction.compactManual({
|
return yield* compaction.compactManual({
|
||||||
sessionID: input.session.id,
|
sessionID: input.session.id,
|
||||||
messages: input.messages,
|
messages: input.messages,
|
||||||
model: resolved.model,
|
model,
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,131 +0,0 @@
|
|||||||
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,107 +0,0 @@
|
|||||||
export * as SessionContextEntry from "./context-entry"
|
|
||||||
|
|
||||||
import { and, asc, eq } from "drizzle-orm"
|
|
||||||
import { Context, Effect, Layer, Schema } from "effect"
|
|
||||||
import { SessionContextEntry } from "@opencode-ai/schema/session-context-entry"
|
|
||||||
import { Database } from "../database/database"
|
|
||||||
import { makeLocationNode } from "../effect/app-node"
|
|
||||||
import { SystemContext } from "../system-context/index"
|
|
||||||
import { SessionSchema } from "./schema"
|
|
||||||
import { SessionContextEntryTable } from "./sql"
|
|
||||||
|
|
||||||
export const Key = SessionContextEntry.Key
|
|
||||||
export type Key = typeof Key.Type
|
|
||||||
export const Info = SessionContextEntry.Info
|
|
||||||
export type Info = typeof Info.Type
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly list: (sessionID: SessionSchema.ID) => Effect.Effect<ReadonlyArray<Info>>
|
|
||||||
readonly put: (input: {
|
|
||||||
readonly sessionID: SessionSchema.ID
|
|
||||||
readonly key: Key
|
|
||||||
readonly value: Schema.Json
|
|
||||||
}) => Effect.Effect<void>
|
|
||||||
readonly remove: (input: { readonly sessionID: SessionSchema.ID; readonly key: Key }) => Effect.Effect<void>
|
|
||||||
/** Produces one SystemContext source per stored entry, keyed `api/<key>`. */
|
|
||||||
readonly load: (sessionID: SessionSchema.ID) => Effect.Effect<SystemContext.SystemContext>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionContextEntry") {}
|
|
||||||
|
|
||||||
const renderValue = (value: Schema.Json) => (typeof value === "string" ? value : JSON.stringify(value, null, 2))
|
|
||||||
|
|
||||||
const renderBlock = (key: Key, value: Schema.Json) =>
|
|
||||||
[`<context key="${key}">`, renderValue(value), "</context>"].join("\n")
|
|
||||||
|
|
||||||
// Rendering stays mechanism-neutral: the model sees session context, not how
|
|
||||||
// it was attached. Only chronological updates and removals carry narration.
|
|
||||||
const source = (entry: Info) =>
|
|
||||||
SystemContext.make({
|
|
||||||
key: SystemContext.Key.make(`api/${entry.key}`),
|
|
||||||
description: `Session context: ${entry.key}`,
|
|
||||||
codec: Schema.toCodecJson(Schema.Json),
|
|
||||||
load: Effect.succeed(entry.value),
|
|
||||||
baseline: (value) => renderBlock(entry.key, value),
|
|
||||||
update: (_previous, value) =>
|
|
||||||
[
|
|
||||||
`The context under "${entry.key}" changed and supersedes the previous value:`,
|
|
||||||
renderBlock(entry.key, value),
|
|
||||||
].join("\n"),
|
|
||||||
removed: () => `The context under "${entry.key}" no longer applies. Disregard it.`,
|
|
||||||
})
|
|
||||||
|
|
||||||
const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const { db } = yield* Database.Service
|
|
||||||
|
|
||||||
const list = Effect.fn("SessionContextEntry.list")(function* (sessionID: SessionSchema.ID) {
|
|
||||||
const rows = yield* db
|
|
||||||
.select()
|
|
||||||
.from(SessionContextEntryTable)
|
|
||||||
.where(eq(SessionContextEntryTable.session_id, sessionID))
|
|
||||||
.orderBy(asc(SessionContextEntryTable.key))
|
|
||||||
.all()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
return rows.map((row) => ({ key: row.key, value: row.value }))
|
|
||||||
})
|
|
||||||
|
|
||||||
const put = Effect.fn("SessionContextEntry.put")(function* (input: {
|
|
||||||
readonly sessionID: SessionSchema.ID
|
|
||||||
readonly key: Key
|
|
||||||
readonly value: Schema.Json
|
|
||||||
}) {
|
|
||||||
yield* db
|
|
||||||
.insert(SessionContextEntryTable)
|
|
||||||
.values({ session_id: input.sessionID, key: input.key, value: input.value })
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: [SessionContextEntryTable.session_id, SessionContextEntryTable.key],
|
|
||||||
set: { value: input.value, time_updated: Date.now() },
|
|
||||||
})
|
|
||||||
.run()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
})
|
|
||||||
|
|
||||||
const remove = Effect.fn("SessionContextEntry.remove")(function* (input: {
|
|
||||||
readonly sessionID: SessionSchema.ID
|
|
||||||
readonly key: Key
|
|
||||||
}) {
|
|
||||||
yield* db
|
|
||||||
.delete(SessionContextEntryTable)
|
|
||||||
.where(
|
|
||||||
and(eq(SessionContextEntryTable.session_id, input.sessionID), eq(SessionContextEntryTable.key, input.key)),
|
|
||||||
)
|
|
||||||
.run()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
})
|
|
||||||
|
|
||||||
const load = Effect.fn("SessionContextEntry.load")(function* (sessionID: SessionSchema.ID) {
|
|
||||||
const entries = yield* list(sessionID)
|
|
||||||
return SystemContext.combine(entries.map(source))
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ list, put, remove, load })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const node = makeLocationNode({ service: Service, layer, deps: [Database.node] })
|
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
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,3 +10,15 @@ export class MessageDecodeError extends Schema.TaggedErrorClass<MessageDecodeErr
|
|||||||
return `Failed to decode message ${this.messageID} in session ${this.sessionID}`
|
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 { MessageDecodeError } from "./error"
|
||||||
import { SessionMessage } from "./message"
|
import { SessionMessage } from "./message"
|
||||||
import { SessionSchema } from "./schema"
|
import { SessionSchema } from "./schema"
|
||||||
import { SessionContextCheckpointTable, SessionMessageTable } from "./sql"
|
import { SessionContextEpochTable, SessionMessageTable } from "./sql"
|
||||||
|
|
||||||
type DatabaseService = Database.Interface["db"]
|
type DatabaseService = Database.Interface["db"]
|
||||||
|
|
||||||
@@ -33,9 +33,6 @@ const messageRows = Effect.fnUntraced(function* (
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(SessionMessageTable.session_id, sessionID),
|
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
|
compaction
|
||||||
? or(
|
? or(
|
||||||
gte(SessionMessageTable.seq, compaction.seq),
|
gte(SessionMessageTable.seq, compaction.seq),
|
||||||
@@ -70,9 +67,9 @@ export const load = Effect.fn("SessionHistory.load")(function* (db: DatabaseServ
|
|||||||
const [epoch, compaction] = yield* Effect.all(
|
const [epoch, compaction] = yield* Effect.all(
|
||||||
[
|
[
|
||||||
db
|
db
|
||||||
.select({ baselineSeq: SessionContextCheckpointTable.baseline_seq })
|
.select({ baselineSeq: SessionContextEpochTable.baseline_seq })
|
||||||
.from(SessionContextCheckpointTable)
|
.from(SessionContextEpochTable)
|
||||||
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
|
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||||
.get()
|
.get()
|
||||||
.pipe(Effect.orDie),
|
.pipe(Effect.orDie),
|
||||||
latestCompaction(db, sessionID),
|
latestCompaction(db, sessionID),
|
||||||
@@ -82,6 +79,14 @@ export const load = Effect.fn("SessionHistory.load")(function* (db: DatabaseServ
|
|||||||
return yield* Effect.forEach(yield* messageRows(db, sessionID, compaction, epoch?.baselineSeq), decodeMessageRow)
|
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* (
|
export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
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,7 +139,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
"session.next.prompt.admitted": () => Effect.void,
|
"session.next.prompt.admitted": () => Effect.void,
|
||||||
"session.next.execution.settled": () => Effect.void,
|
|
||||||
"session.next.context.updated": (event) =>
|
"session.next.context.updated": (event) =>
|
||||||
adapter.appendMessage(
|
adapter.appendMessage(
|
||||||
SessionMessage.System.make({
|
SessionMessage.System.make({
|
||||||
@@ -155,7 +154,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||||||
sessionID: event.data.sessionID,
|
sessionID: event.data.sessionID,
|
||||||
text: event.data.text,
|
text: event.data.text,
|
||||||
description: event.data.description,
|
description: event.data.description,
|
||||||
metadata: event.data.metadata,
|
|
||||||
id: event.data.messageID,
|
id: event.data.messageID,
|
||||||
type: "synthetic",
|
type: "synthetic",
|
||||||
time: { created: event.data.timestamp },
|
time: { created: event.data.timestamp },
|
||||||
|
|||||||
@@ -12,15 +12,8 @@ import { SessionMessage } from "./message"
|
|||||||
import { SessionMessageUpdater } from "./message-updater"
|
import { SessionMessageUpdater } from "./message-updater"
|
||||||
import { SessionInput } from "./input"
|
import { SessionInput } from "./input"
|
||||||
import { WorkspaceV2 } from "../workspace"
|
import { WorkspaceV2 } from "../workspace"
|
||||||
import { SessionContextCheckpoint } from "./context-checkpoint"
|
import { SessionContextEpoch } from "./context-epoch"
|
||||||
import {
|
import { MessageTable, PartTable, SessionInputTable, SessionMessageTable, SessionTable } from "./sql"
|
||||||
MessageTable,
|
|
||||||
PartTable,
|
|
||||||
SessionContextCheckpointTable,
|
|
||||||
SessionInputTable,
|
|
||||||
SessionMessageTable,
|
|
||||||
SessionTable,
|
|
||||||
} from "./sql"
|
|
||||||
import type { DeepMutable } from "../schema"
|
import type { DeepMutable } from "../schema"
|
||||||
import { Slug } from "../util/slug"
|
import { Slug } from "../util/slug"
|
||||||
|
|
||||||
@@ -163,16 +156,12 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
|||||||
.select({ seq: SessionMessageTable.seq })
|
.select({ seq: SessionMessageTable.seq })
|
||||||
.from(SessionMessageTable)
|
.from(SessionMessageTable)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(eq(SessionMessageTable.session_id, event.data.parentID), eq(SessionMessageTable.id, event.data.messageID)),
|
||||||
eq(SessionMessageTable.session_id, event.data.parentID),
|
|
||||||
eq(SessionMessageTable.id, event.data.messageID),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.get()
|
.get()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
: undefined
|
: undefined
|
||||||
if (event.data.messageID && !boundary)
|
if (event.data.messageID && !boundary) return yield* Effect.die(`Fork boundary message not found: ${event.data.messageID}`)
|
||||||
return yield* Effect.die(`Fork boundary message not found: ${event.data.messageID}`)
|
|
||||||
const copied = yield* db
|
const copied = yield* db
|
||||||
.select({ seq: SessionMessageTable.seq })
|
.select({ seq: SessionMessageTable.seq })
|
||||||
.from(SessionMessageTable)
|
.from(SessionMessageTable)
|
||||||
@@ -217,23 +206,6 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
|||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
if (!stored) return yield* Effect.die(new SessionAlreadyProjected())
|
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()
|
const usage = emptyUsage()
|
||||||
let cursor = -1
|
let cursor = -1
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -480,7 +452,7 @@ const layer = Layer.effectDiscard(
|
|||||||
.where(eq(SessionTable.id, event.data.sessionID))
|
.where(eq(SessionTable.id, event.data.sessionID))
|
||||||
.run()
|
.run()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
yield* SessionContextCheckpoint.reset(db, event.data.sessionID)
|
yield* SessionContextEpoch.reset(db, event.data.sessionID)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
yield* events.project(SessionV1.Event.Deleted, (event) =>
|
yield* events.project(SessionV1.Event.Deleted, (event) =>
|
||||||
@@ -694,7 +666,7 @@ const layer = Layer.effectDiscard(
|
|||||||
.where(eq(SessionTable.id, event.data.sessionID))
|
.where(eq(SessionTable.id, event.data.sessionID))
|
||||||
.run()
|
.run()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
yield* SessionContextCheckpoint.reset(db, event.data.sessionID)
|
yield* SessionContextEpoch.reset(db, event.data.sessionID)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ export * as SessionRunner from "./index"
|
|||||||
import type { LLMError } from "@opencode-ai/llm"
|
import type { LLMError } from "@opencode-ai/llm"
|
||||||
import { Context, Effect } from "effect"
|
import { Context, Effect } from "effect"
|
||||||
import { SessionSchema } from "../schema"
|
import { SessionSchema } from "../schema"
|
||||||
import type { MessageDecodeError } from "../error"
|
import type { ContextSnapshotDecodeError, MessageDecodeError } from "../error"
|
||||||
import { SessionRunnerModel } from "./model"
|
import { SessionRunnerModel } from "./model"
|
||||||
import type { SystemContext } from "../../system-context/index"
|
import type { SystemContext } from "../../system-context/index"
|
||||||
import type { ToolOutputStore } from "../../tool-output-store"
|
import type { ToolOutputStore } from "../../tool-output-store"
|
||||||
@@ -12,6 +12,7 @@ export type RunError =
|
|||||||
| LLMError
|
| LLMError
|
||||||
| SessionRunnerModel.Error
|
| SessionRunnerModel.Error
|
||||||
| MessageDecodeError
|
| MessageDecodeError
|
||||||
|
| ContextSnapshotDecodeError
|
||||||
| SystemContext.InitializationBlocked
|
| SystemContext.InitializationBlocked
|
||||||
| ToolOutputStore.Error
|
| ToolOutputStore.Error
|
||||||
|
|
||||||
|
|||||||
@@ -8,23 +8,23 @@ import {
|
|||||||
isContextOverflowFailure,
|
isContextOverflowFailure,
|
||||||
type ProviderErrorEvent,
|
type ProviderErrorEvent,
|
||||||
} from "@opencode-ai/llm"
|
} from "@opencode-ai/llm"
|
||||||
import { Cause, DateTime, Effect, Exit, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
|
import { Cause, DateTime, Effect, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
|
||||||
import { AgentV2 } from "../../agent"
|
import { AgentV2 } from "../../agent"
|
||||||
import { Config } from "../../config"
|
import { Config } from "../../config"
|
||||||
import { Database } from "../../database/database"
|
import { Database } from "../../database/database"
|
||||||
import { EventV2 } from "../../event"
|
import { EventV2 } from "../../event"
|
||||||
import { Location } from "../../location"
|
import { Location } from "../../location"
|
||||||
|
import { ModelV2 } from "../../model"
|
||||||
|
import { ProviderV2 } from "../../provider"
|
||||||
import { QuestionV2 } from "../../question"
|
import { QuestionV2 } from "../../question"
|
||||||
import { SystemContext } from "../../system-context/index"
|
import { SystemContext } from "../../system-context/index"
|
||||||
import { SystemContextBuiltIns } from "../../system-context/builtins"
|
import { SystemContextRegistry } from "../../system-context/registry"
|
||||||
import { InstructionContext } from "../../instruction-context"
|
|
||||||
import { SkillGuidance } from "../../skill/guidance"
|
import { SkillGuidance } from "../../skill/guidance"
|
||||||
import { ReferenceGuidance } from "../../reference/guidance"
|
import { ReferenceGuidance } from "../../reference/guidance"
|
||||||
import { McpGuidance } from "../../mcp/guidance"
|
import { McpGuidance } from "../../mcp/guidance"
|
||||||
import { SessionContextEntry } from "../context-entry"
|
|
||||||
import { ToolRegistry } from "../../tool/registry"
|
import { ToolRegistry } from "../../tool/registry"
|
||||||
import { ToolOutputStore } from "../../tool-output-store"
|
import { ToolOutputStore } from "../../tool-output-store"
|
||||||
import { SessionContextCheckpoint } from "../context-checkpoint"
|
import { SessionContextEpoch } from "../context-epoch"
|
||||||
import { SessionCompaction } from "../compaction"
|
import { SessionCompaction } from "../compaction"
|
||||||
import { SessionEvent } from "../event"
|
import { SessionEvent } from "../event"
|
||||||
import { SessionHistory } from "../history"
|
import { SessionHistory } from "../history"
|
||||||
@@ -102,12 +102,10 @@ const layer = Layer.effect(
|
|||||||
const models = yield* SessionRunnerModel.Service
|
const models = yield* SessionRunnerModel.Service
|
||||||
const store = yield* SessionStore.Service
|
const store = yield* SessionStore.Service
|
||||||
const location = yield* Location.Service
|
const location = yield* Location.Service
|
||||||
const builtins = yield* SystemContextBuiltIns.Service
|
const systemContext = yield* SystemContextRegistry.Service
|
||||||
const instructions = yield* InstructionContext.Service
|
|
||||||
const skillGuidance = yield* SkillGuidance.Service
|
const skillGuidance = yield* SkillGuidance.Service
|
||||||
const referenceGuidance = yield* ReferenceGuidance.Service
|
const referenceGuidance = yield* ReferenceGuidance.Service
|
||||||
const mcpGuidance = yield* McpGuidance.Service
|
const mcpGuidance = yield* McpGuidance.Service
|
||||||
const contextEntries = yield* SessionContextEntry.Service
|
|
||||||
const snapshots = yield* Snapshot.Service
|
const snapshots = yield* Snapshot.Service
|
||||||
const db = (yield* Database.Service).db
|
const db = (yield* Database.Service).db
|
||||||
const compaction = yield* SessionCompaction.Service
|
const compaction = yield* SessionCompaction.Service
|
||||||
@@ -171,18 +169,10 @@ const layer = Layer.effect(
|
|||||||
const continueAfterOverflowCompaction = (step: number) =>
|
const continueAfterOverflowCompaction = (step: number) =>
|
||||||
new TurnTransitionError({ _tag: "ContinueAfterOverflowCompaction", step })
|
new TurnTransitionError({ _tag: "ContinueAfterOverflowCompaction", step })
|
||||||
|
|
||||||
const loadSystemContext = (agent: AgentV2.Selection, sessionID: SessionSchema.ID) =>
|
const loadSystemContext = (agent: AgentV2.Selection) =>
|
||||||
Effect.all(
|
Effect.all([systemContext.load(), skillGuidance.load(agent), referenceGuidance.load(), mcpGuidance.load(agent)], {
|
||||||
[
|
concurrency: "unbounded",
|
||||||
builtins.load(),
|
}).pipe(Effect.map(SystemContext.combine))
|
||||||
instructions.load(),
|
|
||||||
skillGuidance.load(agent),
|
|
||||||
referenceGuidance.load(),
|
|
||||||
mcpGuidance.load(agent),
|
|
||||||
contextEntries.load(sessionID),
|
|
||||||
],
|
|
||||||
{ concurrency: "unbounded" },
|
|
||||||
).pipe(Effect.map(SystemContext.combine))
|
|
||||||
|
|
||||||
const runTurnAttempt = Effect.fn("SessionRunner.runTurn")(function* (
|
const runTurnAttempt = Effect.fn("SessionRunner.runTurn")(function* (
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
@@ -194,14 +184,7 @@ const layer = Layer.effect(
|
|||||||
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
|
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
|
||||||
return yield* Effect.interrupt
|
return yield* Effect.interrupt
|
||||||
const agent = yield* agents.select(session.agent)
|
const agent = yield* agents.select(session.agent)
|
||||||
// Establish what the model knows before admitting what the user said, so
|
const initialized = yield* SessionContextEpoch.initialize(db, loadSystemContext(agent), session.id)
|
||||||
// a blocked first turn leaves pending inputs untouched.
|
|
||||||
const checkpoint = yield* SessionContextCheckpoint.prepare(
|
|
||||||
db,
|
|
||||||
events,
|
|
||||||
loadSystemContext(agent, session.id),
|
|
||||||
session.id,
|
|
||||||
)
|
|
||||||
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
|
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
|
||||||
let needsContinuation = false
|
let needsContinuation = false
|
||||||
let currentStep = step
|
let currentStep = step
|
||||||
@@ -215,9 +198,10 @@ const layer = Layer.effect(
|
|||||||
}
|
}
|
||||||
if (promoted > 0) currentStep = 1
|
if (promoted > 0) currentStep = 1
|
||||||
}
|
}
|
||||||
const resolved = yield* models.resolve(session)
|
const system =
|
||||||
const model = resolved.model
|
initialized ?? (yield* SessionContextEpoch.prepare(db, events, loadSystemContext(agent), session.id))
|
||||||
const entries = yield* SessionHistory.entriesForRunner(db, session.id, checkpoint.baselineSeq)
|
const model = yield* models.resolve(session)
|
||||||
|
const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq)
|
||||||
const context = entries.map((entry) => entry.message)
|
const context = entries.map((entry) => entry.message)
|
||||||
const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps
|
const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps
|
||||||
const toolMaterialization = isLastStep
|
const toolMaterialization = isLastStep
|
||||||
@@ -227,10 +211,7 @@ const layer = Layer.effect(
|
|||||||
const request = LLM.request({
|
const request = LLM.request({
|
||||||
model,
|
model,
|
||||||
providerOptions: { openai: { promptCacheKey } },
|
providerOptions: { openai: { promptCacheKey } },
|
||||||
system: [
|
system: [agent.info?.system ? agent.info.system : SessionRunnerSystemPrompt.provider(model), system.baseline]
|
||||||
agent.info?.system ? agent.info.system : SessionRunnerSystemPrompt.provider(model),
|
|
||||||
checkpoint.baseline,
|
|
||||||
]
|
|
||||||
.filter((part): part is string => part !== undefined && part.length > 0)
|
.filter((part): part is string => part !== undefined && part.length > 0)
|
||||||
.map(SystemPart.make),
|
.map(SystemPart.make),
|
||||||
messages: [...toLLMMessages(context, model), ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : [])],
|
messages: [...toLLMMessages(context, model), ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : [])],
|
||||||
@@ -243,9 +224,11 @@ const layer = Layer.effect(
|
|||||||
const publisher = createLLMEventPublisher(events, {
|
const publisher = createLLMEventPublisher(events, {
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
agent: agent.id,
|
agent: agent.id,
|
||||||
// The selected catalog identity, not model.id: route-level ids are provider API
|
model: {
|
||||||
// model ids (for example gpt-5.5-fast resolves to api id gpt-5.5).
|
id: ModelV2.ID.make(model.id),
|
||||||
model: resolved.ref,
|
providerID: ProviderV2.ID.make(model.provider),
|
||||||
|
...(session.model?.variant === undefined ? {} : { variant: session.model.variant }),
|
||||||
|
},
|
||||||
snapshot: startSnapshot,
|
snapshot: startSnapshot,
|
||||||
})
|
})
|
||||||
const publication = Semaphore.makeUnsafe(1)
|
const publication = Semaphore.makeUnsafe(1)
|
||||||
@@ -404,7 +387,7 @@ const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const drain = Effect.fnUntraced(function* (input: {
|
const run = Effect.fn("SessionRunner.run")(function* (input: {
|
||||||
readonly sessionID: SessionSchema.ID
|
readonly sessionID: SessionSchema.ID
|
||||||
readonly force: boolean
|
readonly force: boolean
|
||||||
}) {
|
}) {
|
||||||
@@ -435,30 +418,6 @@ 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({
|
return Service.of({
|
||||||
run,
|
run,
|
||||||
})
|
})
|
||||||
@@ -476,12 +435,10 @@ export const node = makeLocationNode({
|
|||||||
SessionRunnerModel.node,
|
SessionRunnerModel.node,
|
||||||
SessionStore.node,
|
SessionStore.node,
|
||||||
Location.node,
|
Location.node,
|
||||||
SystemContextBuiltIns.node,
|
SystemContextRegistry.node,
|
||||||
InstructionContext.node,
|
|
||||||
SkillGuidance.node,
|
SkillGuidance.node,
|
||||||
ReferenceGuidance.node,
|
ReferenceGuidance.node,
|
||||||
McpGuidance.node,
|
McpGuidance.node,
|
||||||
SessionContextEntry.node,
|
|
||||||
SessionCompaction.node,
|
SessionCompaction.node,
|
||||||
SessionTitle.node,
|
SessionTitle.node,
|
||||||
Config.node,
|
Config.node,
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { Catalog } from "../../catalog"
|
|||||||
import { Credential } from "../../credential"
|
import { Credential } from "../../credential"
|
||||||
import { Integration } from "../../integration"
|
import { Integration } from "../../integration"
|
||||||
import { ModelV2 } from "../../model"
|
import { ModelV2 } from "../../model"
|
||||||
import { OpenAICodex } from "../../plugin/provider/openai-codex"
|
|
||||||
import { ProviderV2 } from "../../provider"
|
import { ProviderV2 } from "../../provider"
|
||||||
import { SessionSchema } from "../schema"
|
import { SessionSchema } from "../schema"
|
||||||
|
|
||||||
@@ -72,15 +71,8 @@ export type Error =
|
|||||||
| UnsupportedApiError
|
| UnsupportedApiError
|
||||||
| Integration.AuthorizationError
|
| Integration.AuthorizationError
|
||||||
|
|
||||||
export interface Resolved {
|
|
||||||
/** Route-level model for provider requests; its id is the provider API model id, which may differ from the catalog id. */
|
|
||||||
readonly model: Model
|
|
||||||
/** Selected catalog identity. Durable records and displays must use this, never the API model id. */
|
|
||||||
readonly ref: ModelV2.Ref
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly resolve: (session: SessionSchema.Info) => Effect.Effect<Resolved, Error>
|
readonly resolve: (session: SessionSchema.Info) => Effect.Effect<Model, Error>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionRunnerModel") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionRunnerModel") {}
|
||||||
@@ -88,16 +80,6 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
|||||||
/** Test or embedding seam for supplying a model resolver directly. */
|
/** Test or embedding seam for supplying a model resolver directly. */
|
||||||
export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve }))
|
export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve }))
|
||||||
|
|
||||||
/** Builds a Resolved whose catalog identity mirrors the route model. Test or embedding seam. */
|
|
||||||
export const resolved = (model: Model, variant?: ModelV2.VariantID): Resolved => ({
|
|
||||||
model,
|
|
||||||
ref: ModelV2.Ref.make({
|
|
||||||
id: ModelV2.ID.make(model.id),
|
|
||||||
providerID: ProviderV2.ID.make(model.provider),
|
|
||||||
...(variant === undefined ? {} : { variant }),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => {
|
const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => {
|
||||||
if (credential?.type === "key") return Auth.value(credential.key)
|
if (credential?.type === "key") return Auth.value(credential.key)
|
||||||
if (credential?.type === "oauth") return Auth.value(credential.access)
|
if (credential?.type === "oauth") return Auth.value(credential.access)
|
||||||
@@ -114,22 +96,11 @@ const withDefaults = (model: ModelV2.Info, route: AnyRoute) => {
|
|||||||
provider: model.providerID,
|
provider: model.providerID,
|
||||||
endpoint: model.api.url === undefined ? undefined : { baseURL: model.api.url },
|
endpoint: model.api.url === undefined ? undefined : { baseURL: model.api.url },
|
||||||
headers: model.request.headers,
|
headers: model.request.headers,
|
||||||
providerOptions: providerOptions(model),
|
|
||||||
http: { body: httpBody },
|
http: { body: httpBody },
|
||||||
limits: { context: model.limit.context, output: model.limit.output },
|
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 = (
|
export const withVariant = (
|
||||||
model: ModelV2.Info,
|
model: ModelV2.Info,
|
||||||
variantID: ModelV2.VariantID | undefined,
|
variantID: ModelV2.VariantID | undefined,
|
||||||
@@ -147,7 +118,6 @@ export const withVariant = (
|
|||||||
return Effect.succeed(
|
return Effect.succeed(
|
||||||
variant
|
variant
|
||||||
? produce(model, (draft) => {
|
? produce(model, (draft) => {
|
||||||
Object.assign(draft.request.settings, variant.settings)
|
|
||||||
Object.assign(draft.request.headers, variant.headers)
|
Object.assign(draft.request.headers, variant.headers)
|
||||||
Object.assign(draft.request.body, variant.body)
|
Object.assign(draft.request.body, variant.body)
|
||||||
})
|
})
|
||||||
@@ -170,21 +140,6 @@ export const fromCatalogModel = (
|
|||||||
})
|
})
|
||||||
const key = apiKey(resolved, credential)
|
const key = apiKey(resolved, credential)
|
||||||
if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai") {
|
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(
|
return Effect.succeed(
|
||||||
withDefaults(resolved, OpenAIResponses.route)
|
withDefaults(resolved, OpenAIResponses.route)
|
||||||
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
||||||
@@ -250,19 +205,11 @@ const layer = Layer.effect(
|
|||||||
const connection = yield* integrations.connection.active(
|
const connection = yield* integrations.connection.active(
|
||||||
provider?.integrationID ?? Integration.ID.make(selected.providerID),
|
provider?.integrationID ?? Integration.ID.make(selected.providerID),
|
||||||
)
|
)
|
||||||
const model = yield* resolve(
|
return yield* resolve(
|
||||||
session,
|
session,
|
||||||
selected,
|
selected,
|
||||||
connection ? yield* integrations.connection.resolve(connection) : undefined,
|
connection ? yield* integrations.connection.resolve(connection) : undefined,
|
||||||
)
|
)
|
||||||
return {
|
|
||||||
model,
|
|
||||||
ref: ModelV2.Ref.make({
|
|
||||||
id: selected.id,
|
|
||||||
providerID: selected.providerID,
|
|
||||||
...(session.model?.variant === undefined ? {} : { variant: session.model.variant }),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ function toLLMMessage(message: SessionMessage.Message, model: Model): Message[]
|
|||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
case "synthetic":
|
case "synthetic":
|
||||||
return [Message.make({ id: message.id, role: "user", content: message.text })]
|
return [Message.make({ id: message.id, role: "user", content: message.text, metadata: message.metadata })]
|
||||||
case "skill":
|
case "skill":
|
||||||
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, metadata: message.metadata })]
|
||||||
case "system":
|
case "system":
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { Timestamps } from "../database/schema.sql"
|
|||||||
import type { SystemContext } from "../system-context/index"
|
import type { SystemContext } from "../system-context/index"
|
||||||
import { AgentV2 } from "../agent"
|
import { AgentV2 } from "../agent"
|
||||||
import type { Revert } from "@opencode-ai/schema/revert"
|
import type { Revert } from "@opencode-ai/schema/revert"
|
||||||
import type { Schema } from "effect"
|
|
||||||
|
|
||||||
type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id">
|
type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id">
|
||||||
type V1MessageData = Omit<SessionV1.Info, "id" | "sessionID">
|
type V1MessageData = Omit<SessionV1.Info, "id" | "sessionID">
|
||||||
@@ -166,26 +165,12 @@ export const SessionInputTable = sqliteTable(
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
export const SessionContextEntryTable = sqliteTable(
|
export const SessionContextEpochTable = sqliteTable("session_context_epoch", {
|
||||||
"session_context_entry",
|
|
||||||
{
|
|
||||||
session_id: text()
|
|
||||||
.$type<SessionSchema.ID>()
|
|
||||||
.notNull()
|
|
||||||
.references(() => SessionTable.id, { onDelete: "cascade" }),
|
|
||||||
key: text().notNull(),
|
|
||||||
value: text({ mode: "json" }).notNull().$type<Schema.Json>(),
|
|
||||||
...Timestamps,
|
|
||||||
},
|
|
||||||
(table) => [primaryKey({ columns: [table.session_id, table.key] })],
|
|
||||||
)
|
|
||||||
|
|
||||||
export const SessionContextCheckpointTable = sqliteTable("session_context_epoch", {
|
|
||||||
session_id: text()
|
session_id: text()
|
||||||
.$type<SessionSchema.ID>()
|
.$type<SessionSchema.ID>()
|
||||||
.primaryKey()
|
.primaryKey()
|
||||||
.references(() => SessionTable.id, { onDelete: "cascade" }),
|
.references(() => SessionTable.id, { onDelete: "cascade" }),
|
||||||
baseline: text().notNull(),
|
baseline: text().notNull(),
|
||||||
snapshot: text({ mode: "json" }).notNull().$type<SystemContext.Applied>(),
|
snapshot: text({ mode: "json" }).notNull().$type<SystemContext.Snapshot>(),
|
||||||
baseline_seq: integer().notNull(),
|
baseline_seq: integer().notNull(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ import { fromRow } from "./info"
|
|||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info | undefined>
|
readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info | undefined>
|
||||||
readonly context: (sessionID: SessionSchema.ID) => Effect.Effect<SessionMessage.Message[], MessageDecodeError>
|
readonly context: (sessionID: SessionSchema.ID) => Effect.Effect<SessionMessage.Message[], MessageDecodeError>
|
||||||
|
readonly runnerContext: (
|
||||||
|
sessionID: SessionSchema.ID,
|
||||||
|
baselineSeq: number,
|
||||||
|
) => Effect.Effect<SessionMessage.Message[], MessageDecodeError>
|
||||||
readonly message: (
|
readonly message: (
|
||||||
messageID: SessionMessage.ID,
|
messageID: SessionMessage.ID,
|
||||||
) => Effect.Effect<{ readonly sessionID: SessionSchema.ID; readonly message: SessionMessage.Message } | undefined>
|
) => Effect.Effect<{ readonly sessionID: SessionSchema.ID; readonly message: SessionMessage.Message } | undefined>
|
||||||
@@ -35,6 +39,9 @@ const layer = Layer.effect(
|
|||||||
context: Effect.fn("SessionStore.context")(function* (sessionID) {
|
context: Effect.fn("SessionStore.context")(function* (sessionID) {
|
||||||
return yield* SessionHistory.load(db, 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) {
|
message: Effect.fn("SessionStore.message")(function* (messageID) {
|
||||||
const row = yield* db
|
const row = yield* db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -42,17 +42,17 @@ const make = (dependencies: Dependencies) => {
|
|||||||
if (!firstUser) return
|
if (!firstUser) return
|
||||||
const agent = yield* dependencies.agents.get(AgentV2.ID.make("title"))
|
const agent = yield* dependencies.agents.get(AgentV2.ID.make("title"))
|
||||||
if (!agent) return
|
if (!agent) return
|
||||||
const resolved = yield* (agent.model
|
const model = yield* (agent.model
|
||||||
? dependencies.models.resolve({ ...session, model: agent.model })
|
? dependencies.models.resolve({ ...session, model: agent.model })
|
||||||
: dependencies.models.resolve(session)
|
: dependencies.models.resolve(session)
|
||||||
).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||||
if (!resolved) return
|
if (!model) return
|
||||||
const chunks: string[] = []
|
const chunks: string[] = []
|
||||||
let failed = false
|
let failed = false
|
||||||
const streamed = yield* dependencies.llm
|
const streamed = yield* dependencies.llm
|
||||||
.stream(
|
.stream(
|
||||||
LLM.request({
|
LLM.request({
|
||||||
model: resolved.model,
|
model,
|
||||||
system: agent.system,
|
system: agent.system,
|
||||||
messages: [Message.user(firstUser.text)],
|
messages: [Message.user(firstUser.text)],
|
||||||
tools: [],
|
tools: [],
|
||||||
|
|||||||
@@ -13,54 +13,23 @@ const Summary = Schema.Struct({
|
|||||||
})
|
})
|
||||||
type Summary = typeof Summary.Type
|
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>) =>
|
const render = (skills: ReadonlyArray<Summary>) =>
|
||||||
[
|
[
|
||||||
"Skills provide specialized instructions and workflows for specific tasks.",
|
"Skills provide specialized instructions and workflows for specific tasks.",
|
||||||
"Use the skill tool to load a skill when a task matches its description.",
|
"Use the skill tool to load a skill when a task matches its description.",
|
||||||
...(skills.length === 0
|
...(skills.length === 0
|
||||||
? ["No skills are currently available."]
|
? ["No skills are currently available."]
|
||||||
: ["<available_skills>", ...entries(skills), "</available_skills>"]),
|
: [
|
||||||
].join("\n")
|
"<available_skills>",
|
||||||
|
...skills.flatMap((skill) => [
|
||||||
const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary>) => {
|
" <skill>",
|
||||||
const diff = SystemContext.diffByKey(
|
` <name>${skill.name}</name>`,
|
||||||
previous,
|
` <description>${skill.description}</description>`,
|
||||||
current,
|
" </skill>",
|
||||||
(skill) => skill.name,
|
|
||||||
(before, after) => before.description !== after.description,
|
|
||||||
)
|
|
||||||
const items = SystemContext.diffItems(diff, (skill) => ({ key: skill.name, description: skill.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 {
|
|
||||||
text: [
|
|
||||||
"The available skills have changed. This list supersedes the previous available skills list.",
|
|
||||||
render(current),
|
|
||||||
].join("\n"),
|
|
||||||
items,
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
text: [
|
|
||||||
...(diff.added.length === 0
|
|
||||||
? []
|
|
||||||
: ["New skills are available in addition to those previously listed:", ...entries(diff.added)]),
|
|
||||||
...(diff.removed.length === 0
|
|
||||||
? []
|
|
||||||
: [
|
|
||||||
`The following skills are no longer available and must not be used: ${diff.removed.map((skill) => skill.name).join(", ")}.`,
|
|
||||||
]),
|
]),
|
||||||
].join("\n"),
|
"</available_skills>",
|
||||||
items,
|
]),
|
||||||
}
|
].join("\n")
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly load: (agent: AgentV2.Selection) => Effect.Effect<SystemContext.SystemContext>
|
readonly load: (agent: AgentV2.Selection) => Effect.Effect<SystemContext.SystemContext>
|
||||||
@@ -89,11 +58,14 @@ const layer = Layer.effect(
|
|||||||
.toSorted((a, b) => a.name.localeCompare(b.name))
|
.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||||
return SystemContext.make({
|
return SystemContext.make({
|
||||||
key: SystemContext.Key.make("core/skill-guidance"),
|
key: SystemContext.Key.make("core/skill-guidance"),
|
||||||
description: "Available skills",
|
|
||||||
codec: Schema.toCodecJson(Schema.Array(Summary)),
|
codec: Schema.toCodecJson(Schema.Array(Summary)),
|
||||||
load: Effect.succeed(available),
|
load: Effect.succeed(available),
|
||||||
baseline: render,
|
baseline: render,
|
||||||
update,
|
update: (_previous, current) =>
|
||||||
|
[
|
||||||
|
"The available skills have changed. This list supersedes the previous available skills list.",
|
||||||
|
render(current),
|
||||||
|
].join("\n"),
|
||||||
removed: () => "Skill guidance is no longer available. Do not use any previously listed skill.",
|
removed: () => "Skill guidance is no longer available. Do not use any previously listed skill.",
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
export * as SystemContextBuiltIns from "./builtins"
|
export * as SystemContextBuiltIns from "./builtins"
|
||||||
|
|
||||||
import { makeLocationNode } from "../effect/app-node"
|
import { makeLocationNode } from "../effect/app-node"
|
||||||
import { Context, DateTime, Effect, Layer, Schema } from "effect"
|
import { DateTime, Effect, Layer, Schema } from "effect"
|
||||||
import { Location } from "../location"
|
import { Location } from "../location"
|
||||||
import { SystemContext } from "./index"
|
import { SystemContext } from "./index"
|
||||||
|
import { InstructionContext } from "../instruction-context"
|
||||||
|
import { SystemContextRegistry } from "./registry"
|
||||||
|
import { FSUtil } from "../fs-util"
|
||||||
|
import { Global } from "../global"
|
||||||
|
|
||||||
export interface Interface {
|
const builtIns = Layer.effectDiscard(
|
||||||
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* () {
|
Effect.gen(function* () {
|
||||||
const location = yield* Location.Service
|
const location = yield* Location.Service
|
||||||
|
const registry = yield* SystemContextRegistry.Service
|
||||||
const environment = [
|
const environment = [
|
||||||
"<env>",
|
"<env>",
|
||||||
` Working directory: ${location.directory}`,
|
` Working directory: ${location.directory}`,
|
||||||
@@ -26,7 +24,6 @@ const layer = Layer.effect(
|
|||||||
const context = SystemContext.combine([
|
const context = SystemContext.combine([
|
||||||
SystemContext.make({
|
SystemContext.make({
|
||||||
key: SystemContext.Key.make("core/environment"),
|
key: SystemContext.Key.make("core/environment"),
|
||||||
description: "Environment",
|
|
||||||
codec: Schema.toCodecJson(Schema.String),
|
codec: Schema.toCodecJson(Schema.String),
|
||||||
load: Effect.succeed(environment),
|
load: Effect.succeed(environment),
|
||||||
baseline: (environment) =>
|
baseline: (environment) =>
|
||||||
@@ -35,7 +32,6 @@ const layer = Layer.effect(
|
|||||||
}),
|
}),
|
||||||
SystemContext.make({
|
SystemContext.make({
|
||||||
key: SystemContext.Key.make("core/date"),
|
key: SystemContext.Key.make("core/date"),
|
||||||
description: "Current date",
|
|
||||||
codec: Schema.toCodecJson(Schema.String),
|
codec: Schema.toCodecJson(Schema.String),
|
||||||
load: DateTime.nowAsDate.pipe(Effect.map((date) => date.toDateString())),
|
load: DateTime.nowAsDate.pipe(Effect.map((date) => date.toDateString())),
|
||||||
baseline: (date) => `Today's date: ${date}`,
|
baseline: (date) => `Today's date: ${date}`,
|
||||||
@@ -43,8 +39,12 @@ const layer = Layer.effect(
|
|||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
return Service.of({ load: () => Effect.succeed(context) })
|
yield* registry.register({ key: SystemContext.Key.make("core/builtins"), load: Effect.succeed(context) })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const node = makeLocationNode({ service: Service, layer, deps: [Location.node] })
|
export const node = makeLocationNode({
|
||||||
|
name: "system-context-builtins",
|
||||||
|
layer: builtIns,
|
||||||
|
deps: [Location.node, SystemContextRegistry.node, InstructionContext.node, FSUtil.node, Global.node],
|
||||||
|
})
|
||||||
|
|||||||
@@ -7,18 +7,13 @@ import { Effect, Option, Schema } from "effect"
|
|||||||
*
|
*
|
||||||
* `Source<A>` describes how to observe, compare, and render one value. `make`
|
* `Source<A>` describes how to observe, compare, and render one value. `make`
|
||||||
* closes over `A`, producing an opaque `SystemContext` that composes uniformly
|
* closes over `A`, producing an opaque `SystemContext` that composes uniformly
|
||||||
* with contexts built from other value types.
|
* with contexts built from other value types. Interpreters observe the composed
|
||||||
*
|
* context once, then produce a durable structured
|
||||||
* The durable `Applied` record tracks what the model was last told, per source:
|
* `Snapshot` alongside the exact model-visible baseline or update text.
|
||||||
* 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
|
* Returning `unavailable` means observation failed temporarily. It differs from
|
||||||
* removing a source from the context: the model's prior belief stands.
|
* removing a source from the context: refresh preserves the admitted snapshot,
|
||||||
* `reconcile` retains the applied value silently, and `rebaseline` restates the
|
* and replacement waits rather than silently constructing an incomplete baseline.
|
||||||
* belief by rendering the last-applied value instead of a live observation.
|
|
||||||
*
|
*
|
||||||
* @module
|
* @module
|
||||||
*/
|
*/
|
||||||
@@ -36,34 +31,13 @@ export type Unavailable = typeof unavailable
|
|||||||
/** Defines one typed source before its value type is hidden by `make`. */
|
/** Defines one typed source before its value type is hidden by `make`. */
|
||||||
export interface Source<A> {
|
export interface Source<A> {
|
||||||
readonly key: Key
|
readonly key: Key
|
||||||
readonly description: string
|
readonly codec: Schema.Codec<A, Schema.Json, never, never>
|
||||||
readonly codec: Schema.Codec<A, Schema.Json>
|
|
||||||
readonly load: Effect.Effect<A | Unavailable>
|
readonly load: Effect.Effect<A | Unavailable>
|
||||||
readonly baseline: (current: A) => string
|
readonly baseline: (current: A) => string
|
||||||
readonly update: (previous: A, current: A) => string | StructuredUpdate
|
readonly update: (previous: A, current: A) => string
|
||||||
readonly removed?: (previous: A) => string
|
readonly removed?: (previous: A) => string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ReconcileAction = "added" | "updated" | "removed"
|
|
||||||
|
|
||||||
export interface ReconcileItemUpdate {
|
|
||||||
readonly key: string
|
|
||||||
readonly description: string
|
|
||||||
readonly action: ReconcileAction
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReconcileUpdate {
|
|
||||||
readonly key: Key
|
|
||||||
readonly description: string
|
|
||||||
readonly action: ReconcileAction
|
|
||||||
readonly items?: ReadonlyArray<ReconcileItemUpdate>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StructuredUpdate {
|
|
||||||
readonly text: string
|
|
||||||
readonly items?: ReadonlyArray<ReconcileItemUpdate>
|
|
||||||
}
|
|
||||||
|
|
||||||
const ContextTypeId: unique symbol = Symbol.for("@opencode/SystemContext")
|
const ContextTypeId: unique symbol = Symbol.for("@opencode/SystemContext")
|
||||||
|
|
||||||
/** Opaque carrier for composable system context sources. */
|
/** Opaque carrier for composable system context sources. */
|
||||||
@@ -71,32 +45,39 @@ export interface SystemContext {
|
|||||||
readonly [ContextTypeId]: ReadonlyArray<PackedSource>
|
readonly [ContextTypeId]: ReadonlyArray<PackedSource>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The value last applied to the model for one admitted source. */
|
/** Durable comparison state for one admitted source. */
|
||||||
export const AppliedSource = Schema.Struct({
|
export const SourceSnapshot = Schema.Struct({
|
||||||
value: Schema.Json,
|
value: Schema.Json,
|
||||||
description: Schema.optional(Schema.NonEmptyString),
|
|
||||||
removed: Schema.optional(Schema.NonEmptyString),
|
removed: Schema.optional(Schema.NonEmptyString),
|
||||||
})
|
})
|
||||||
export type AppliedSource = typeof AppliedSource.Type
|
export type SourceSnapshot = typeof SourceSnapshot.Type
|
||||||
|
|
||||||
/** Durable record of what the model currently believes, per source. */
|
/** Durable structured comparison state for one active context generation. */
|
||||||
export const Applied = Schema.Record(Key, AppliedSource)
|
export const Snapshot = Schema.Record(Key, SourceSnapshot)
|
||||||
export type Applied = Readonly<Record<string, AppliedSource>>
|
export type Snapshot = Readonly<Record<string, SourceSnapshot>>
|
||||||
|
|
||||||
/** A rendered baseline together with the applied values it was rendered from. */
|
export interface Generation {
|
||||||
export interface Baseline {
|
readonly baseline: string
|
||||||
readonly text: string
|
readonly snapshot: Snapshot
|
||||||
readonly applied: Applied
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Updated {
|
export interface Updated {
|
||||||
readonly _tag: "Updated"
|
readonly _tag: "Updated"
|
||||||
readonly text: string
|
readonly text: string
|
||||||
readonly updates: ReadonlyArray<ReconcileUpdate>
|
readonly snapshot: Snapshot
|
||||||
readonly applied: Applied
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ReconcileResult = { readonly _tag: "Unchanged" } | Updated
|
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 class InitializationBlocked extends Schema.TaggedErrorClass<InitializationBlocked>()(
|
export class InitializationBlocked extends Schema.TaggedErrorClass<InitializationBlocked>()(
|
||||||
"SystemContext.InitializationBlocked",
|
"SystemContext.InitializationBlocked",
|
||||||
@@ -117,25 +98,36 @@ export class DuplicateKeyError extends Schema.TaggedErrorClass<DuplicateKeyError
|
|||||||
|
|
||||||
interface PackedSource {
|
interface PackedSource {
|
||||||
readonly key: Key
|
readonly key: Key
|
||||||
readonly load: Effect.Effect<Observed | Unavailable>
|
readonly load: Effect.Effect<Loaded | Unavailable>
|
||||||
/** Restates the model's belief from a last-applied value when the source cannot be observed. */
|
|
||||||
readonly recall: (stored: AppliedSource) => string | undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Observed {
|
interface Loaded {
|
||||||
readonly description: string
|
readonly baseline: () => Rendered
|
||||||
readonly applied: AppliedSource
|
readonly compare: (previous: Schema.Json) => Compared
|
||||||
readonly baseline: () => string
|
|
||||||
/** `undefined` means unchanged. An undecodable previous value re-renders the baseline (treat-as-new). */
|
|
||||||
readonly update: (previous: AppliedSource) => StructuredUpdate | undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Entry {
|
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"
|
||||||
readonly key: Key
|
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. */
|
/** The identity context. */
|
||||||
export const empty = context([])
|
export const empty = context([])
|
||||||
|
|
||||||
@@ -144,78 +136,42 @@ export function make<A>(source: Source<A>): SystemContext {
|
|||||||
const decode = Schema.decodeUnknownOption(source.codec)
|
const decode = Schema.decodeUnknownOption(source.codec)
|
||||||
const encode = Schema.encodeSync(source.codec)
|
const encode = Schema.encodeSync(source.codec)
|
||||||
const equivalent = Schema.toEquivalence(source.codec)
|
const equivalent = Schema.toEquivalence(source.codec)
|
||||||
const description = requireText(source.key, "description", source.description)
|
|
||||||
const baseline = (value: A) => requireText(source.key, "baseline", source.baseline(value))
|
|
||||||
return context([
|
return context([
|
||||||
{
|
{
|
||||||
key: source.key,
|
key: source.key,
|
||||||
recall: (stored) =>
|
|
||||||
Option.match(decode(stored.value), {
|
|
||||||
onNone: () => undefined,
|
|
||||||
onSome: baseline,
|
|
||||||
}),
|
|
||||||
load: source.load.pipe(
|
load: source.load.pipe(
|
||||||
Effect.map((value) => {
|
Effect.map((value) => {
|
||||||
if (isUnavailable(value)) return value
|
if (isUnavailable(value)) return value
|
||||||
|
const snapshot = (): SourceSnapshot => ({
|
||||||
|
value: encode(value),
|
||||||
|
...(source.removed ? { removed: requireText(source.key, "removal", source.removed(value)) } : {}),
|
||||||
|
})
|
||||||
return {
|
return {
|
||||||
description,
|
baseline: (): Rendered => ({
|
||||||
applied: {
|
text: requireText(source.key, "baseline", source.baseline(value)),
|
||||||
value: encode(value),
|
snapshot: snapshot(),
|
||||||
...(source.removed
|
}),
|
||||||
? { description, removed: requireText(source.key, "removal", source.removed(value)) }
|
compare: (previous): Compared =>
|
||||||
: {}),
|
Option.match(decode(previous), {
|
||||||
},
|
onNone: (): Compared => ({ _tag: "Incompatible" }),
|
||||||
baseline: () => baseline(value),
|
onSome: (decoded): Compared =>
|
||||||
update: (previous) =>
|
equivalent(decoded, value)
|
||||||
Option.match(decode(previous.value), {
|
? { _tag: "Unchanged" }
|
||||||
onNone: () => ({ text: baseline(value) }),
|
: {
|
||||||
onSome: (decoded) =>
|
_tag: "Updated",
|
||||||
equivalent(decoded, value) ? undefined : normalizeUpdate(source.key, source.update(decoded, value)),
|
render: () => ({
|
||||||
|
text: requireText(source.key, "update", source.update(decoded, value)),
|
||||||
|
snapshot: snapshot(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
} 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 }]
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function diffItems<A>(
|
|
||||||
diff: ReturnType<typeof diffByKey<A>>,
|
|
||||||
item: (value: A) => { readonly key: string; readonly description: string },
|
|
||||||
): ReadonlyArray<ReconcileItemUpdate> {
|
|
||||||
return [
|
|
||||||
...diff.added.map((value) => ({ ...item(value), action: "added" as const })),
|
|
||||||
...diff.removed.map((value) => ({ ...item(value), action: "removed" as const })),
|
|
||||||
...diff.changed.map((value) => ({ ...item(value.current), action: "updated" as const })),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Combines contexts in order and rejects duplicate source keys immediately. */
|
/** Combines contexts in order and rejects duplicate source keys immediately. */
|
||||||
export function combine(values: ReadonlyArray<SystemContext>): SystemContext {
|
export function combine(values: ReadonlyArray<SystemContext>): SystemContext {
|
||||||
const sources = values.flatMap((value) => value[ContextTypeId])
|
const sources = values.flatMap((value) => value[ContextTypeId])
|
||||||
@@ -227,106 +183,111 @@ const observe = (value: SystemContext) =>
|
|||||||
Effect.forEach(
|
Effect.forEach(
|
||||||
value[ContextTypeId],
|
value[ContextTypeId],
|
||||||
(source) =>
|
(source) =>
|
||||||
source.load.pipe(Effect.map((observed): Entry => ({ key: source.key, recall: source.recall, observed }))),
|
source.load.pipe(
|
||||||
|
Effect.map(
|
||||||
|
(result): Entry =>
|
||||||
|
result === unavailable
|
||||||
|
? { _tag: "Unavailable", key: source.key }
|
||||||
|
: { _tag: "Available", key: source.key, ...result },
|
||||||
|
),
|
||||||
|
),
|
||||||
{ concurrency: "unbounded" },
|
{ concurrency: "unbounded" },
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Creates the first baseline. Blocks rather than admit a baseline missing an unobservable source. */
|
/** Creates the immutable baseline and durable snapshot for a new generation. */
|
||||||
export function initialize(value: SystemContext): Effect.Effect<Baseline, InitializationBlocked> {
|
export function initialize(value: SystemContext): Effect.Effect<Generation, InitializationBlocked> {
|
||||||
return observe(value).pipe(
|
return observe(value).pipe(
|
||||||
Effect.flatMap((entries) => {
|
Effect.flatMap((entries) => {
|
||||||
const blocked = entries.flatMap((entry) => (entry.observed === unavailable ? [entry.key] : []))
|
const unavailable = entries.flatMap((entry) => (entry._tag === "Unavailable" ? [entry.key] : []))
|
||||||
if (blocked.length > 0) return new InitializationBlocked({ keys: blocked })
|
if (unavailable.length > 0) return new InitializationBlocked({ keys: unavailable })
|
||||||
const parts: string[] = []
|
return Effect.succeed(initializeObservation(entries))
|
||||||
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 })
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Narrates drift between current source values and the model's beliefs. Never rewrites the baseline. */
|
function initializeObservation(entries: ReadonlyArray<Entry>): Generation {
|
||||||
export function reconcile(value: SystemContext, previous: Applied): Effect.Effect<ReconcileResult> {
|
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> {
|
||||||
return observe(value).pipe(
|
return observe(value).pipe(
|
||||||
Effect.map((entries): ReconcileResult => {
|
Effect.map((entries): ReconcileResult => {
|
||||||
const parts: string[] = []
|
const result = reconcileObservation(entries, previous)
|
||||||
const updates: ReconcileUpdate[] = []
|
if (result._tag === "Unchanged" || result._tag === "Updated") return result
|
||||||
const applied: Record<string, AppliedSource> = {}
|
return replaceObservation(entries, previous)
|
||||||
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) {
|
|
||||||
parts.push(entry.observed.baseline())
|
|
||||||
updates.push({ key: entry.key, description: entry.observed.description, action: "added" })
|
|
||||||
applied[entry.key] = entry.observed.applied
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const update = entry.observed.update(stored)
|
|
||||||
if (update === undefined) {
|
|
||||||
applied[entry.key] = stored
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
parts.push(update.text)
|
|
||||||
updates.push({
|
|
||||||
key: entry.key,
|
|
||||||
description: entry.observed.description,
|
|
||||||
action: "updated",
|
|
||||||
...(update.items === undefined ? {} : { items: update.items }),
|
|
||||||
})
|
|
||||||
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 {
|
|
||||||
parts.push(removed)
|
|
||||||
updates.push({
|
|
||||||
key: Key.make(key),
|
|
||||||
description: previous[key].description ?? key,
|
|
||||||
action: "removed",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (updates.length === 0) return { _tag: "Unchanged" }
|
|
||||||
return { _tag: "Updated", text: render(parts), updates, applied }
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Rebuilds the baseline, restating unobservable sources from the model's last-applied beliefs. */
|
function reconcileObservation(
|
||||||
export function rebaseline(value: SystemContext, previous: Applied): Effect.Effect<Baseline> {
|
entries: ReadonlyArray<Entry>,
|
||||||
return observe(value).pipe(
|
previous: Snapshot,
|
||||||
Effect.map((entries): Baseline => {
|
): { readonly _tag: "Unchanged" } | Updated | { readonly _tag: "Replace" } {
|
||||||
const parts: string[] = []
|
const keys = new Set(entries.map((entry) => entry.key))
|
||||||
const applied: Record<string, AppliedSource> = {}
|
const comparisons = new Map<Key, Compared>()
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (entry.observed !== unavailable) {
|
if (entry._tag === "Unavailable") continue
|
||||||
parts.push(entry.observed.baseline())
|
const stored = getSnapshot(previous, entry.key)
|
||||||
applied[entry.key] = entry.observed.applied
|
if (!stored) continue
|
||||||
continue
|
const compared = entry.compare(stored.value)
|
||||||
}
|
if (compared._tag === "Incompatible") return { _tag: "Replace" }
|
||||||
const stored = get(previous, entry.key)
|
comparisons.set(entry.key, compared)
|
||||||
if (!stored) continue
|
}
|
||||||
const text = entry.recall(stored)
|
for (const key of Object.keys(previous).sort()) {
|
||||||
// An undecodable belief cannot be restated; the source re-announces when observable again.
|
if (keys.has(Key.make(key))) continue
|
||||||
if (text === undefined) continue
|
if (previous[key].removed === undefined) return { _tag: "Replace" }
|
||||||
parts.push(text)
|
}
|
||||||
applied[entry.key] = stored
|
|
||||||
}
|
const snapshot: Record<string, SourceSnapshot> = {}
|
||||||
return { text: render(parts), applied }
|
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) }
|
||||||
}
|
}
|
||||||
|
|
||||||
function context(sources: ReadonlyArray<PackedSource>): SystemContext {
|
function context(sources: ReadonlyArray<PackedSource>): SystemContext {
|
||||||
@@ -337,8 +298,8 @@ function render(parts: ReadonlyArray<string>) {
|
|||||||
return parts.join("\n\n")
|
return parts.join("\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
function get(applied: Applied, key: Key) {
|
function getSnapshot(snapshot: Snapshot, key: Key) {
|
||||||
return Object.hasOwn(applied, key) ? applied[key] : undefined
|
return Object.hasOwn(snapshot, key) ? snapshot[key] : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function isUnavailable(value: unknown): value is Unavailable {
|
function isUnavailable(value: unknown): value is Unavailable {
|
||||||
@@ -350,11 +311,6 @@ function requireText(key: Key, kind: string, text: string) {
|
|||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeUpdate(key: Key, update: string | StructuredUpdate) {
|
|
||||||
if (typeof update === "string") return { text: requireText(key, "update", update) }
|
|
||||||
return { ...update, text: requireText(key, "update", update.text) }
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertUniqueKeys(sources: ReadonlyArray<PackedSource>) {
|
function assertUniqueKeys(sources: ReadonlyArray<PackedSource>) {
|
||||||
const keys = new Set<Key>()
|
const keys = new Set<Key>()
|
||||||
for (const source of sources) {
|
for (const source of sources) {
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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: [] })
|
||||||
@@ -4,6 +4,7 @@ import { makeLocationNode } from "../effect/app-node"
|
|||||||
import { Context, Layer } from "effect"
|
import { Context, Layer } from "effect"
|
||||||
import { ApplyPatchTool } from "./apply-patch"
|
import { ApplyPatchTool } from "./apply-patch"
|
||||||
import { EditTool } from "./edit"
|
import { EditTool } from "./edit"
|
||||||
|
import { GlobTool } from "./glob"
|
||||||
import { GrepTool } from "./grep"
|
import { GrepTool } from "./grep"
|
||||||
import { QuestionTool } from "./question"
|
import { QuestionTool } from "./question"
|
||||||
import { ReadTool } from "./read"
|
import { ReadTool } from "./read"
|
||||||
@@ -37,6 +38,7 @@ export const node = makeLocationNode({
|
|||||||
deps: [
|
deps: [
|
||||||
ApplyPatchTool.node,
|
ApplyPatchTool.node,
|
||||||
EditTool.node,
|
EditTool.node,
|
||||||
|
GlobTool.node,
|
||||||
GrepTool.node,
|
GrepTool.node,
|
||||||
QuestionTool.node,
|
QuestionTool.node,
|
||||||
ReadTool.node,
|
ReadTool.node,
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
export * as GlobTool from "./glob"
|
export * as GlobTool from "./glob"
|
||||||
|
|
||||||
import { ToolFailure } from "@opencode-ai/llm"
|
import { ToolFailure } from "@opencode-ai/llm"
|
||||||
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
import { Effect, Layer, Schema } from "effect"
|
||||||
import { Effect, Schema } from "effect"
|
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import { makeLocationNode } from "../effect/app-node"
|
||||||
import { FileSystem } from "../filesystem"
|
import { FileSystem } from "../filesystem"
|
||||||
import { Location } from "../location"
|
import { Location } from "../location"
|
||||||
import { Ripgrep } from "../ripgrep"
|
import { Ripgrep } from "../ripgrep"
|
||||||
import { RelativePath } from "../schema"
|
import { RelativePath } from "../schema"
|
||||||
import { PermissionV2 } from "../permission"
|
import { PermissionV2 } from "../permission"
|
||||||
|
import { ToolRegistry } from "./registry"
|
||||||
import { Tool } from "./tool"
|
import { Tool } from "./tool"
|
||||||
|
import { Tools } from "./tools"
|
||||||
|
|
||||||
export const name = "glob"
|
export const name = "glob"
|
||||||
|
|
||||||
@@ -33,14 +35,14 @@ export const toModelOutput = (output: ModelOutput) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Glob leaf that defaults its filesystem root to the active Location. */
|
/** Glob leaf that defaults its filesystem root to the active Location. */
|
||||||
export const Plugin = {
|
const layer = Layer.effectDiscard(
|
||||||
id: "core-glob-tool",
|
Effect.gen(function* () {
|
||||||
effect: Effect.fn("GlobTool.Plugin")(function* (ctx: PluginContext) {
|
const tools = yield* Tools.Service
|
||||||
const ripgrep = yield* Ripgrep.Service
|
const ripgrep = yield* Ripgrep.Service
|
||||||
const location = yield* Location.Service
|
const location = yield* Location.Service
|
||||||
const permission = yield* PermissionV2.Service
|
const permission = yield* PermissionV2.Service
|
||||||
|
|
||||||
yield* ctx.tool
|
yield* tools
|
||||||
.register({
|
.register({
|
||||||
[name]: Tool.make({
|
[name]: Tool.make({
|
||||||
description:
|
description:
|
||||||
@@ -94,4 +96,10 @@ export const Plugin = {
|
|||||||
})
|
})
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
}),
|
}),
|
||||||
}
|
)
|
||||||
|
|
||||||
|
export const node = makeLocationNode({
|
||||||
|
name: "tool/glob",
|
||||||
|
layer,
|
||||||
|
deps: [ToolRegistry.node, Ripgrep.node, Location.node, PermissionV2.node],
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
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,16 +1,12 @@
|
|||||||
export * as ReadTool from "./read"
|
export * as ReadTool from "./read"
|
||||||
|
|
||||||
import { dirname } from "path"
|
|
||||||
import { ToolFailure } from "@opencode-ai/llm"
|
import { ToolFailure } from "@opencode-ai/llm"
|
||||||
import { Effect, Layer, Schema } from "effect"
|
import { Effect, Layer, Schema } from "effect"
|
||||||
import { makeLocationNode } from "../effect/app-node"
|
import { makeLocationNode } from "../effect/app-node"
|
||||||
import { FileSystem } from "../filesystem"
|
import { FileSystem } from "../filesystem"
|
||||||
import { FSUtil } from "../fs-util"
|
|
||||||
import { Image } from "../image"
|
import { Image } from "../image"
|
||||||
import { Location } from "../location"
|
|
||||||
import { LocationMutation } from "../location-mutation"
|
import { LocationMutation } from "../location-mutation"
|
||||||
import { PermissionV2 } from "../permission"
|
import { PermissionV2 } from "../permission"
|
||||||
import { SessionInstructions } from "../session/instructions"
|
|
||||||
import { AbsolutePath } from "../schema"
|
import { AbsolutePath } from "../schema"
|
||||||
import { ReadToolFileSystem } from "./read-filesystem"
|
import { ReadToolFileSystem } from "./read-filesystem"
|
||||||
import { ToolRegistry } from "./registry"
|
import { ToolRegistry } from "./registry"
|
||||||
@@ -18,7 +14,6 @@ import { Tool } from "./tool"
|
|||||||
import { Tools } from "./tools"
|
import { Tools } from "./tools"
|
||||||
|
|
||||||
export const name = "read"
|
export const name = "read"
|
||||||
const FILENAME = "AGENTS.md"
|
|
||||||
const SUPPORTED_IMAGE_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"])
|
const SUPPORTED_IMAGE_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"])
|
||||||
const LocationInput = Schema.Struct({
|
const LocationInput = Schema.Struct({
|
||||||
path: Schema.String,
|
path: Schema.String,
|
||||||
@@ -39,9 +34,6 @@ const layer = Layer.effectDiscard(
|
|||||||
const mutation = yield* LocationMutation.Service
|
const mutation = yield* LocationMutation.Service
|
||||||
const image = yield* Image.Service
|
const image = yield* Image.Service
|
||||||
const permission = yield* PermissionV2.Service
|
const permission = yield* PermissionV2.Service
|
||||||
const sessionInstructions = yield* SessionInstructions.Service
|
|
||||||
const fs = yield* FSUtil.Service
|
|
||||||
const location = yield* Location.Service
|
|
||||||
|
|
||||||
yield* tools
|
yield* tools
|
||||||
.register({
|
.register({
|
||||||
@@ -85,33 +77,12 @@ const layer = Layer.effectDiscard(
|
|||||||
agent: context.agent,
|
agent: context.agent,
|
||||||
source,
|
source,
|
||||||
})
|
})
|
||||||
const content =
|
if (type === "directory")
|
||||||
type === "directory"
|
return yield* reader.list(absolute, { offset: input.offset, limit: input.limit })
|
||||||
? yield* reader.list(absolute, { offset: input.offset, limit: input.limit })
|
const content = yield* reader.read(absolute, resource, {
|
||||||
: yield* reader.read(absolute, resource, {
|
offset: input.offset,
|
||||||
offset: input.offset,
|
limit: input.limit,
|
||||||
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)) {
|
if ("encoding" in content && content.encoding === "base64" && SUPPORTED_IMAGE_MIMES.has(content.mime)) {
|
||||||
return yield* image
|
return yield* image
|
||||||
.normalize(resource, { ...content, encoding: "base64" })
|
.normalize(resource, { ...content, encoding: "base64" })
|
||||||
@@ -142,14 +113,5 @@ const layer = Layer.effectDiscard(
|
|||||||
export const node = makeLocationNode({
|
export const node = makeLocationNode({
|
||||||
name: "tool/read",
|
name: "tool/read",
|
||||||
layer,
|
layer,
|
||||||
deps: [
|
deps: [ToolRegistry.node, ReadToolFileSystem.node, LocationMutation.node, Image.node, PermissionV2.node],
|
||||||
ToolRegistry.node,
|
|
||||||
ReadToolFileSystem.node,
|
|
||||||
LocationMutation.node,
|
|
||||||
Image.node,
|
|
||||||
PermissionV2.node,
|
|
||||||
SessionInstructions.node,
|
|
||||||
FSUtil.node,
|
|
||||||
Location.node,
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { ToolOutputStore } from "../tool-output-store"
|
|||||||
import { Wildcard } from "../util/wildcard"
|
import { Wildcard } from "../util/wildcard"
|
||||||
import { definition, permission, registrationEntries, settle, type AnyTool, type RegistrationError } from "./tool"
|
import { definition, permission, registrationEntries, settle, type AnyTool, type RegistrationError } from "./tool"
|
||||||
import { Tools } from "./tools"
|
import { Tools } from "./tools"
|
||||||
import { ToolHooks } from "./hooks"
|
|
||||||
import { makeLocationNode } from "../effect/app-node"
|
import { makeLocationNode } from "../effect/app-node"
|
||||||
|
|
||||||
export type ExecuteInput = {
|
export type ExecuteInput = {
|
||||||
@@ -48,7 +47,6 @@ const registryLayer = Layer.effect(
|
|||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const resources = yield* ToolOutputStore.Service
|
const resources = yield* ToolOutputStore.Service
|
||||||
const toolHooks = yield* ToolHooks.Service
|
|
||||||
type Registration = { readonly identity: object; readonly tool: AnyTool }
|
type Registration = { readonly identity: object; readonly tool: AnyTool }
|
||||||
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
|
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
|
||||||
|
|
||||||
@@ -63,17 +61,7 @@ const registryLayer = Layer.effect(
|
|||||||
}
|
}
|
||||||
if (advertised && registration.identity !== advertised)
|
if (advertised && registration.identity !== advertised)
|
||||||
return { result: { type: "error" as const, value: `Stale tool call: ${input.call.name}` } }
|
return { result: { type: "error" as const, value: `Stale tool call: ${input.call.name}` } }
|
||||||
// Hooks fire only for hosted/local tools; provider-executed calls never reach settleWith.
|
const pending = yield* settle(registration.tool, input.call, {
|
||||||
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,
|
sessionID: input.sessionID,
|
||||||
agent: input.agent,
|
agent: input.agent,
|
||||||
assistantMessageID: input.assistantMessageID,
|
assistantMessageID: input.assistantMessageID,
|
||||||
@@ -84,38 +72,15 @@ const registryLayer = Layer.effect(
|
|||||||
Effect.succeed({ result: { type: "error" as const, value: failure.message } }),
|
Effect.succeed({ result: { type: "error" as const, value: failure.message } }),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
let settlement: Settlement
|
if ("result" in pending) return pending
|
||||||
if ("result" in pending) {
|
const output = pending.output
|
||||||
settlement = pending
|
const bounded = yield* resources.bound({ sessionID: input.sessionID, toolCallID: input.call.id, output })
|
||||||
} else {
|
const result = ToolOutput.toResultValue(bounded.output)
|
||||||
const bounded = yield* resources.bound({ sessionID: input.sessionID, toolCallID: input.call.id, output: pending.output })
|
if (result.type === "error")
|
||||||
const result = ToolOutput.toResultValue(bounded.output)
|
return bounded.outputPaths.length > 0 ? { result, outputPaths: bounded.outputPaths } : { result }
|
||||||
settlement =
|
return bounded.outputPaths.length > 0
|
||||||
result.type === "error"
|
? { result, output: bounded.output, outputPaths: bounded.outputPaths }
|
||||||
? bounded.outputPaths.length > 0
|
: { result, output: bounded.output }
|
||||||
? { 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({
|
return Service.of({
|
||||||
@@ -178,11 +143,11 @@ function whollyDisabled(action: string, rules: PermissionV2.Ruleset) {
|
|||||||
export const node = makeLocationNode({
|
export const node = makeLocationNode({
|
||||||
service: Service,
|
service: Service,
|
||||||
layer,
|
layer,
|
||||||
deps: [ToolOutputStore.node, ToolHooks.node],
|
deps: [ToolOutputStore.node],
|
||||||
})
|
})
|
||||||
|
|
||||||
export const toolsNode = makeLocationNode({
|
export const toolsNode = makeLocationNode({
|
||||||
service: Tools.Service,
|
service: Tools.Service,
|
||||||
layer,
|
layer,
|
||||||
deps: [ToolOutputStore.node, ToolHooks.node],
|
deps: [ToolOutputStore.node],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,22 +1,12 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { CommandV2 } from "@opencode-ai/core/command"
|
import { CommandV2 } from "@opencode-ai/core/command"
|
||||||
import { Config } from "@opencode-ai/core/config"
|
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { Location } from "@opencode-ai/core/location"
|
|
||||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { emptyConfigLayer, emptyMcpLayer, testLocationLayer } from "./fixture/mcp"
|
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
const it = testEffect(
|
const it = testEffect(AppNodeBuilder.build(CommandV2.node))
|
||||||
AppNodeBuilder.build(CommandV2.node, [
|
|
||||||
[MCP.node, emptyMcpLayer],
|
|
||||||
[Config.node, emptyConfigLayer],
|
|
||||||
[Location.node, testLocationLayer],
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
|
|
||||||
describe("CommandV2", () => {
|
describe("CommandV2", () => {
|
||||||
it.effect("applies command transforms and preserves later overrides", () =>
|
it.effect("applies command transforms and preserves later overrides", () =>
|
||||||
@@ -63,18 +53,4 @@ describe("CommandV2", () => {
|
|||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("evaluates command template shell blocks", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const command = yield* CommandV2.Service
|
|
||||||
yield* command.transform((editor) => {
|
|
||||||
editor.update("review", (command) => {
|
|
||||||
command.template = "Output: !`echo command-output`"
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
expect((yield* command.evaluate({ name: "review" })).text.replace(/\r?\n$/, "")).toEqual("Output: command-output")
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -173,7 +173,6 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
|||||||
model: { providerID: "anthropic", id: "claude-sonnet", variant: undefined },
|
model: { providerID: "anthropic", id: "claude-sonnet", variant: undefined },
|
||||||
})
|
})
|
||||||
expect(reviewer.request).toEqual({
|
expect(reviewer.request).toEqual({
|
||||||
settings: {},
|
|
||||||
headers: { first: "one", shared: "last", second: "two" },
|
headers: { first: "one", shared: "last", second: "two" },
|
||||||
body: { enabled: true, profile: "review", retries: 2, effort: "high" },
|
body: { enabled: true, profile: "review", retries: 2, effort: "high" },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,23 +8,14 @@ import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command"
|
|||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { Location } from "@opencode-ai/core/location"
|
|
||||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
import { emptyConfigLayer, emptyMcpLayer, testLocationLayer } from "../fixture/mcp"
|
|
||||||
import { tmpdir } from "../fixture/tmpdir"
|
import { tmpdir } from "../fixture/tmpdir"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
import { host } from "../plugin/host"
|
import { host } from "../plugin/host"
|
||||||
|
|
||||||
const it = testEffect(
|
const it = testEffect(AppNodeBuilder.build(LayerNode.group([CommandV2.node, FSUtil.node])))
|
||||||
AppNodeBuilder.build(LayerNode.group([CommandV2.node, FSUtil.node]), [
|
|
||||||
[MCP.node, emptyMcpLayer],
|
|
||||||
[Config.node, emptyConfigLayer],
|
|
||||||
[Location.node, testLocationLayer],
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
const decode = Schema.decodeUnknownSync(Config.Info)
|
const decode = Schema.decodeUnknownSync(Config.Info)
|
||||||
|
|
||||||
describe("ConfigCommandPlugin.Plugin", () => {
|
describe("ConfigCommandPlugin.Plugin", () => {
|
||||||
|
|||||||
@@ -78,12 +78,6 @@ const durableData = (sessionID: Session.ID, text: string) => ({
|
|||||||
messageID: SessionV1.MessageID.ascending(`msg_${text}`),
|
messageID: SessionV1.MessageID.ascending(`msg_${text}`),
|
||||||
})
|
})
|
||||||
|
|
||||||
/** Followed log read without markers: the old `durable` stream shape. */
|
|
||||||
const tail = (events: EventV2.Interface, input: { aggregateID: string; after?: number }) =>
|
|
||||||
events
|
|
||||||
.log({ ...input, follow: true })
|
|
||||||
.pipe(Stream.filter((item): item is EventV2.Payload => !EventV2.isCaughtUp(item)))
|
|
||||||
|
|
||||||
const it = testEffect(
|
const it = testEffect(
|
||||||
AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, Location.node]), [[Location.node, locationLayer]]),
|
AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, Location.node]), [[Location.node, locationLayer]]),
|
||||||
)
|
)
|
||||||
@@ -125,7 +119,7 @@ describe("EventV2", () => {
|
|||||||
const event = yield* events.publish(VersionedMessage, { id: "one", text: "hello" })
|
const event = yield* events.publish(VersionedMessage, { id: "one", text: "hello" })
|
||||||
|
|
||||||
expect(event.type).toBe("test.versioned")
|
expect(event.type).toBe("test.versioned")
|
||||||
expect(event.durable?.version).toBe(EventV2.Version.make(2))
|
expect(event.durable?.version).toBe(2)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -151,7 +145,7 @@ describe("EventV2", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const typed = yield* events.subscribe(Message).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
const typed = yield* events.subscribe(Message).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||||
const wildcard = yield* events.live().pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
const wildcard = yield* events.all().pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||||
yield* Effect.yieldNow
|
yield* Effect.yieldNow
|
||||||
const event = yield* events.publish(Message, { text: "hello" })
|
const event = yield* events.publish(Message, { text: "hello" })
|
||||||
|
|
||||||
@@ -232,7 +226,7 @@ describe("EventV2", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const received = new Array<string>()
|
const received = new Array<string>()
|
||||||
const fiber = yield* events.live().pipe(
|
const fiber = yield* events.all().pipe(
|
||||||
Stream.take(1),
|
Stream.take(1),
|
||||||
Stream.runForEach(() => Effect.sync(() => received.push("stream"))),
|
Stream.runForEach(() => Effect.sync(() => received.push("stream"))),
|
||||||
Effect.forkScoped,
|
Effect.forkScoped,
|
||||||
@@ -331,8 +325,8 @@ describe("EventV2", () => {
|
|||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const consuming = yield* Deferred.make<void>()
|
const consuming = yield* Deferred.make<void>()
|
||||||
const release = yield* Deferred.make<void>()
|
const release = yield* Deferred.make<void>()
|
||||||
const slowStream = yield* EventV2.liveBounded(events, 1)
|
const slowStream = yield* EventV2.allBounded(events, 1)
|
||||||
const fastStream = yield* EventV2.liveBounded(events, 8)
|
const fastStream = yield* EventV2.allBounded(events, 8)
|
||||||
const slow = yield* slowStream.pipe(
|
const slow = yield* slowStream.pipe(
|
||||||
Stream.runForEach(() => Deferred.succeed(consuming, undefined).pipe(Effect.andThen(Deferred.await(release)))),
|
Stream.runForEach(() => Deferred.succeed(consuming, undefined).pipe(Effect.andThen(Deferred.await(release)))),
|
||||||
Effect.forkScoped,
|
Effect.forkScoped,
|
||||||
@@ -431,11 +425,9 @@ describe("EventV2", () => {
|
|||||||
const aggregateID = Session.ID.create()
|
const aggregateID = Session.ID.create()
|
||||||
yield* events.publish(DurableMessage, durableData(aggregateID, "zero"))
|
yield* events.publish(DurableMessage, durableData(aggregateID, "zero"))
|
||||||
yield* events.publish(DurableMessage, durableData(aggregateID, "one"))
|
yield* events.publish(DurableMessage, durableData(aggregateID, "one"))
|
||||||
const fiber = yield* tail(events, { aggregateID, after: 0 }).pipe(
|
const fiber = yield* events
|
||||||
Stream.take(2),
|
.durable({ aggregateID, after: 0 })
|
||||||
Stream.runCollect,
|
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
|
||||||
Effect.forkScoped,
|
|
||||||
)
|
|
||||||
yield* Effect.yieldNow
|
yield* Effect.yieldNow
|
||||||
|
|
||||||
yield* events.publish(DurableMessage, durableData(aggregateID, "two"))
|
yield* events.publish(DurableMessage, durableData(aggregateID, "two"))
|
||||||
@@ -452,7 +444,7 @@ describe("EventV2", () => {
|
|||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const aggregateID = Session.ID.create()
|
const aggregateID = Session.ID.create()
|
||||||
yield* events.publish(DurableMessage, durableData(aggregateID, "zero"))
|
yield* events.publish(DurableMessage, durableData(aggregateID, "zero"))
|
||||||
const fiber = yield* tail(events, { aggregateID }).pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
|
const fiber = yield* events.durable({ aggregateID }).pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
|
||||||
|
|
||||||
yield* events.publish(DurableMessage, durableData(aggregateID, "one"))
|
yield* events.publish(DurableMessage, durableData(aggregateID, "one"))
|
||||||
|
|
||||||
@@ -478,7 +470,7 @@ describe("EventV2", () => {
|
|||||||
yield* Effect.gen(function* () {
|
yield* Effect.gen(function* () {
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const aggregateID = Session.ID.create()
|
const aggregateID = Session.ID.create()
|
||||||
const fiber = yield* tail(events, { aggregateID }).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
const fiber = yield* events.durable({ aggregateID }).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||||
yield* Deferred.await(readStarted)
|
yield* Deferred.await(readStarted)
|
||||||
|
|
||||||
pause = false
|
pause = false
|
||||||
@@ -497,7 +489,9 @@ describe("EventV2", () => {
|
|||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const aggregateID = Session.ID.create()
|
const aggregateID = Session.ID.create()
|
||||||
const count = 64
|
const count = 64
|
||||||
const fiber = yield* tail(events, { aggregateID }).pipe(Stream.take(count), Stream.runCollect, Effect.forkScoped)
|
const fiber = yield* events
|
||||||
|
.durable({ aggregateID })
|
||||||
|
.pipe(Stream.take(count), Stream.runCollect, Effect.forkScoped)
|
||||||
yield* Effect.yieldNow
|
yield* Effect.yieldNow
|
||||||
|
|
||||||
for (let index = 0; index < count; index++) {
|
for (let index = 0; index < count; index++) {
|
||||||
@@ -514,7 +508,7 @@ describe("EventV2", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const aggregateID = Session.ID.create()
|
const aggregateID = Session.ID.create()
|
||||||
const fiber = yield* tail(events, { aggregateID }).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
const fiber = yield* events.durable({ aggregateID }).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||||
yield* Effect.yieldNow
|
yield* Effect.yieldNow
|
||||||
|
|
||||||
yield* events.publish(Message, { text: "live only" })
|
yield* events.publish(Message, { text: "live only" })
|
||||||
@@ -1127,125 +1121,4 @@ describe("EventV2", () => {
|
|||||||
expect(received[0]?.data).toEqual(durableData(aggregateID, "replayed"))
|
expect(received[0]?.data).toEqual(durableData(aggregateID, "replayed"))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("log without follow replays events and completes with a caught-up marker", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const events = yield* EventV2.Service
|
|
||||||
const aggregateID = Session.ID.create()
|
|
||||||
yield* events.publish(DurableMessage, durableData(aggregateID, "zero"))
|
|
||||||
yield* events.publish(DurableMessage, durableData(aggregateID, "one"))
|
|
||||||
|
|
||||||
const items = Array.from(yield* Stream.runCollect(events.log({ aggregateID })))
|
|
||||||
|
|
||||||
expect(items.map((item) => (EventV2.isCaughtUp(item) ? item.type : item.durable?.seq))).toEqual([
|
|
||||||
EventV2.Seq.make(0),
|
|
||||||
EventV2.Seq.make(1),
|
|
||||||
"log.caught_up",
|
|
||||||
])
|
|
||||||
expect(items.at(-1)).toEqual({ type: "log.caught_up", aggregateID, seq: EventV2.Seq.make(1) })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("log caught-up marker omits seq for an empty log and keeps the cursor otherwise", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const events = yield* EventV2.Service
|
|
||||||
const aggregateID = Session.ID.create()
|
|
||||||
|
|
||||||
const empty = Array.from(yield* Stream.runCollect(events.log({ aggregateID })))
|
|
||||||
yield* events.publish(DurableMessage, durableData(aggregateID, "zero"))
|
|
||||||
const drained = Array.from(yield* Stream.runCollect(events.log({ aggregateID, after: 0 })))
|
|
||||||
|
|
||||||
expect(empty).toEqual([{ type: "log.caught_up", aggregateID }])
|
|
||||||
expect(empty[0]).not.toHaveProperty("seq")
|
|
||||||
expect(drained).toEqual([{ type: "log.caught_up", aggregateID, seq: EventV2.Seq.make(0) }])
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("log with follow emits the caught-up marker at the replay-to-live boundary", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const events = yield* EventV2.Service
|
|
||||||
const aggregateID = Session.ID.create()
|
|
||||||
yield* events.publish(DurableMessage, durableData(aggregateID, "zero"))
|
|
||||||
const fiber = yield* events
|
|
||||||
.log({ aggregateID, follow: true })
|
|
||||||
.pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped)
|
|
||||||
yield* Effect.yieldNow
|
|
||||||
|
|
||||||
yield* events.publish(DurableMessage, durableData(aggregateID, "one"))
|
|
||||||
|
|
||||||
const items = Array.from(yield* Fiber.join(fiber))
|
|
||||||
expect(items.map((item) => (EventV2.isCaughtUp(item) ? item : item.durable?.seq))).toEqual([
|
|
||||||
EventV2.Seq.make(0),
|
|
||||||
{ type: "log.caught_up", aggregateID, seq: EventV2.Seq.make(0) },
|
|
||||||
EventV2.Seq.make(1),
|
|
||||||
])
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("changes emits sweep-required on subscribe then coalesced hints per aggregate", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const events = yield* EventV2.Service
|
|
||||||
const first = Session.ID.create()
|
|
||||||
const second = Session.ID.create()
|
|
||||||
const pull = yield* Stream.toPull(events.changes())
|
|
||||||
|
|
||||||
expect(Array.from(yield* pull)).toEqual([{ type: "log.sweep_required" }])
|
|
||||||
|
|
||||||
yield* events.publish(DurableMessage, durableData(first, "zero"))
|
|
||||||
yield* events.publish(DurableMessage, durableData(first, "one"))
|
|
||||||
yield* events.publish(DurableMessage, durableData(first, "two"))
|
|
||||||
yield* events.publish(DurableMessage, durableData(second, "zero"))
|
|
||||||
|
|
||||||
expect(Array.from(yield* pull)).toEqual([
|
|
||||||
{ type: "log.hint", aggregateID: first, seq: EventV2.Seq.make(2) },
|
|
||||||
{ type: "log.hint", aggregateID: second, seq: EventV2.Seq.make(0) },
|
|
||||||
])
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("changes abandons the hint buffer for a sweep when key retention is exceeded", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const eventLayer = EventV2.layerWith({ changesKeyCapacity: 2 }).pipe(
|
|
||||||
Layer.provide(LayerNode.compile(Database.node)),
|
|
||||||
)
|
|
||||||
|
|
||||||
yield* Effect.gen(function* () {
|
|
||||||
const events = yield* EventV2.Service
|
|
||||||
const pull = yield* Stream.toPull(events.changes())
|
|
||||||
expect(Array.from(yield* pull)).toEqual([{ type: "log.sweep_required" }])
|
|
||||||
|
|
||||||
yield* events.publish(DurableMessage, durableData(Session.ID.create(), "a"))
|
|
||||||
yield* events.publish(DurableMessage, durableData(Session.ID.create(), "b"))
|
|
||||||
yield* events.publish(DurableMessage, durableData(Session.ID.create(), "c"))
|
|
||||||
|
|
||||||
expect(Array.from(yield* pull)).toEqual([{ type: "log.sweep_required" }])
|
|
||||||
|
|
||||||
const late = Session.ID.create()
|
|
||||||
yield* events.publish(DurableMessage, durableData(late, "d"))
|
|
||||||
|
|
||||||
expect(Array.from(yield* pull)).toEqual([{ type: "log.hint", aggregateID: late, seq: EventV2.Seq.make(0) }])
|
|
||||||
}).pipe(Effect.provide(Layer.merge(LayerNode.compile(Database.node), eventLayer)))
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("sequences returns the latest committed seq per aggregate and omits unknown aggregates", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const events = yield* EventV2.Service
|
|
||||||
const first = Session.ID.create()
|
|
||||||
const second = Session.ID.create()
|
|
||||||
yield* events.publish(DurableMessage, durableData(first, "zero"))
|
|
||||||
yield* events.publish(DurableMessage, durableData(first, "one"))
|
|
||||||
yield* events.publish(DurableMessage, durableData(second, "zero"))
|
|
||||||
|
|
||||||
const sequences = yield* events.sequences([first, second, Session.ID.create()])
|
|
||||||
|
|
||||||
expect(sequences).toEqual(
|
|
||||||
new Map([
|
|
||||||
[first, EventV2.Seq.make(1)],
|
|
||||||
[second, EventV2.Seq.make(0)],
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
expect(yield* events.sequences([])).toEqual(new Map())
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
import { Effect, Layer } from "effect"
|
|
||||||
import { Config } from "@opencode-ai/core/config"
|
|
||||||
import { Location } from "@opencode-ai/core/location"
|
|
||||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
|
||||||
import { location } from "./location"
|
|
||||||
|
|
||||||
export const emptyMcpLayer = Layer.succeed(
|
|
||||||
MCP.Service,
|
|
||||||
MCP.Service.of({
|
|
||||||
servers: () => Effect.succeed([]),
|
|
||||||
tools: () => Effect.succeed([]),
|
|
||||||
callTool: () => Effect.die("unused mcp.callTool"),
|
|
||||||
instructions: () => Effect.succeed([]),
|
|
||||||
prompts: () => Effect.succeed([]),
|
|
||||||
prompt: () => Effect.succeed(undefined),
|
|
||||||
resourceCatalog: () => Effect.succeed(new MCP.ResourceCatalog({ resources: [], templates: [] })),
|
|
||||||
readResource: () => Effect.succeed(undefined),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const emptyConfigLayer = Layer.succeed(
|
|
||||||
Config.Service,
|
|
||||||
Config.Service.of({ entries: () => Effect.succeed([]) }),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const testLocationLayer = Layer.succeed(
|
|
||||||
Location.Service,
|
|
||||||
Location.Service.of(location({ directory: AbsolutePath.make(process.cwd()) })),
|
|
||||||
)
|
|
||||||
@@ -10,6 +10,7 @@ import { InstructionContext } from "@opencode-ai/core/instruction-context"
|
|||||||
import { Location } from "@opencode-ai/core/location"
|
import { Location } from "@opencode-ai/core/location"
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
import { SystemContext } from "@opencode-ai/core/system-context"
|
import { SystemContext } from "@opencode-ai/core/system-context"
|
||||||
|
import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry"
|
||||||
import { location } from "./fixture/location"
|
import { location } from "./fixture/location"
|
||||||
import { tmpdir } from "./fixture/tmpdir"
|
import { tmpdir } from "./fixture/tmpdir"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
@@ -21,7 +22,7 @@ const instructionLayer = (input: {
|
|||||||
locationServiceLayer: Layer.Layer<Location.Service>
|
locationServiceLayer: Layer.Layer<Location.Service>
|
||||||
filesystemLayer?: Layer.Layer<FSUtil.Service>
|
filesystemLayer?: Layer.Layer<FSUtil.Service>
|
||||||
}) =>
|
}) =>
|
||||||
AppNodeBuilder.build(InstructionContext.node, [
|
AppNodeBuilder.build(LayerNode.group([SystemContextRegistry.node, InstructionContext.node]), [
|
||||||
[Global.node, Global.layerWith({ config: input.config })],
|
[Global.node, Global.layerWith({ config: input.config })],
|
||||||
[Location.node, input.locationServiceLayer],
|
[Location.node, input.locationServiceLayer],
|
||||||
...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []),
|
...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []),
|
||||||
@@ -51,7 +52,7 @@ describe("InstructionContext", () => {
|
|||||||
await fs.writeFile(packageFile, "package")
|
await fs.writeFile(packageFile, "package")
|
||||||
})
|
})
|
||||||
|
|
||||||
const load = InstructionContext.Service.pipe(
|
const load = SystemContextRegistry.Service.pipe(
|
||||||
Effect.flatMap((service) => service.load()),
|
Effect.flatMap((service) => service.load()),
|
||||||
Effect.provide(
|
Effect.provide(
|
||||||
instructionLayer({
|
instructionLayer({
|
||||||
@@ -70,23 +71,23 @@ describe("InstructionContext", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const initialized = yield* SystemContext.initialize(yield* load)
|
const initialized = yield* SystemContext.initialize(yield* load)
|
||||||
expect(initialized.text).toBe(
|
expect(initialized.baseline).toBe(
|
||||||
[
|
[
|
||||||
`Instructions from: ${globalFile}\nglobal`,
|
`Instructions from: ${globalFile}\nglobal`,
|
||||||
`Instructions from: ${packageFile}\npackage`,
|
`Instructions from: ${packageFile}\npackage`,
|
||||||
`Instructions from: ${projectFile}\nproject`,
|
`Instructions from: ${projectFile}\nproject`,
|
||||||
].join("\n\n"),
|
].join("\n\n"),
|
||||||
)
|
)
|
||||||
expect(initialized.text).not.toContain("outside")
|
expect(initialized.baseline).not.toContain("outside")
|
||||||
|
|
||||||
yield* Effect.promise(() => fs.writeFile(packageFile, "changed"))
|
yield* Effect.promise(() => fs.writeFile(packageFile, "changed"))
|
||||||
expect(yield* SystemContext.reconcile(yield* load, initialized.applied)).toMatchObject({
|
expect(yield* SystemContext.reconcile(yield* load, initialized.snapshot)).toMatchObject({
|
||||||
_tag: "Updated",
|
_tag: "Updated",
|
||||||
text: expect.stringContaining(`Instructions from: ${packageFile}\nchanged`),
|
text: expect.stringContaining(`Instructions from: ${packageFile}\nchanged`),
|
||||||
})
|
})
|
||||||
|
|
||||||
yield* Effect.promise(() => fs.rm(packageFile))
|
yield* Effect.promise(() => fs.rm(packageFile))
|
||||||
const partial = yield* SystemContext.reconcile(yield* load, initialized.applied)
|
const partial = yield* SystemContext.reconcile(yield* load, initialized.snapshot)
|
||||||
expect(partial).toEqual({
|
expect(partial).toEqual({
|
||||||
_tag: "Updated",
|
_tag: "Updated",
|
||||||
text: [
|
text: [
|
||||||
@@ -94,28 +95,14 @@ describe("InstructionContext", () => {
|
|||||||
`Instructions from: ${globalFile}\nglobal`,
|
`Instructions from: ${globalFile}\nglobal`,
|
||||||
`Instructions from: ${projectFile}\nproject`,
|
`Instructions from: ${projectFile}\nproject`,
|
||||||
].join("\n\n"),
|
].join("\n\n"),
|
||||||
updates: [
|
snapshot: expect.any(Object),
|
||||||
{
|
|
||||||
key: SystemContext.Key.make("core/instructions"),
|
|
||||||
description: "Ambient instructions",
|
|
||||||
action: "updated",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
applied: expect.any(Object),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
yield* Effect.promise(() => Promise.all([fs.rm(globalFile), fs.rm(projectFile)]))
|
yield* Effect.promise(() => Promise.all([fs.rm(globalFile), fs.rm(projectFile)]))
|
||||||
expect(yield* SystemContext.reconcile(yield* load, initialized.applied)).toEqual({
|
expect(yield* SystemContext.reconcile(yield* load, initialized.snapshot)).toEqual({
|
||||||
_tag: "Updated",
|
_tag: "Updated",
|
||||||
text: "Previously loaded instructions no longer apply.",
|
text: "Previously loaded instructions no longer apply.",
|
||||||
updates: [
|
snapshot: {},
|
||||||
{
|
|
||||||
key: SystemContext.Key.make("core/instructions"),
|
|
||||||
description: "Ambient instructions",
|
|
||||||
action: "removed",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
applied: {},
|
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -131,7 +118,7 @@ describe("InstructionContext", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const file = path.join(tmp.path, "AGENTS.md")
|
const file = path.join(tmp.path, "AGENTS.md")
|
||||||
yield* Effect.promise(() => fs.writeFile(file, ""))
|
yield* Effect.promise(() => fs.writeFile(file, ""))
|
||||||
const context = yield* InstructionContext.Service.pipe(
|
const context = yield* SystemContextRegistry.Service.pipe(
|
||||||
Effect.flatMap((service) => service.load()),
|
Effect.flatMap((service) => service.load()),
|
||||||
Effect.provide(
|
Effect.provide(
|
||||||
instructionLayer({
|
instructionLayer({
|
||||||
@@ -144,7 +131,7 @@ describe("InstructionContext", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
expect((yield* SystemContext.initialize(context)).text).toBe(`Instructions from: ${file}\n`)
|
expect((yield* SystemContext.initialize(context)).baseline).toBe(`Instructions from: ${file}\n`)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -160,7 +147,7 @@ describe("InstructionContext", () => {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||||
const context = yield* InstructionContext.Service.pipe(
|
const context = yield* SystemContextRegistry.Service.pipe(
|
||||||
Effect.flatMap((service) => service.load()),
|
Effect.flatMap((service) => service.load()),
|
||||||
Effect.provide(
|
Effect.provide(
|
||||||
instructionLayer({
|
instructionLayer({
|
||||||
@@ -200,7 +187,7 @@ describe("InstructionContext", () => {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||||
const context = yield* InstructionContext.Service.pipe(
|
const context = yield* SystemContextRegistry.Service.pipe(
|
||||||
Effect.flatMap((service) => service.load()),
|
Effect.flatMap((service) => service.load()),
|
||||||
Effect.provide(
|
Effect.provide(
|
||||||
instructionLayer({
|
instructionLayer({
|
||||||
@@ -244,7 +231,7 @@ describe("InstructionContext", () => {
|
|||||||
),
|
),
|
||||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||||
|
|
||||||
yield* InstructionContext.Service.pipe(
|
yield* SystemContextRegistry.Service.pipe(
|
||||||
Effect.flatMap((service) => service.load()),
|
Effect.flatMap((service) => service.load()),
|
||||||
Effect.provide(
|
Effect.provide(
|
||||||
instructionLayer({
|
instructionLayer({
|
||||||
@@ -274,7 +261,7 @@ describe("InstructionContext", () => {
|
|||||||
let scanned = false
|
let scanned = false
|
||||||
process.env.OPENCODE_DISABLE_PROJECT_CONFIG = "1"
|
process.env.OPENCODE_DISABLE_PROJECT_CONFIG = "1"
|
||||||
|
|
||||||
yield* InstructionContext.Service.pipe(
|
yield* SystemContextRegistry.Service.pipe(
|
||||||
Effect.flatMap((service) => service.load()),
|
Effect.flatMap((service) => service.load()),
|
||||||
Effect.provide(
|
Effect.provide(
|
||||||
instructionLayer({
|
instructionLayer({
|
||||||
@@ -306,7 +293,7 @@ describe("InstructionContext", () => {
|
|||||||
it.effect("does not discover project instructions outside the canonical project root", () =>
|
it.effect("does not discover project instructions outside the canonical project root", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
let scanned = false
|
let scanned = false
|
||||||
yield* InstructionContext.Service.pipe(
|
yield* SystemContextRegistry.Service.pipe(
|
||||||
Effect.flatMap((service) => service.load()),
|
Effect.flatMap((service) => service.load()),
|
||||||
Effect.provide(
|
Effect.provide(
|
||||||
instructionLayer({
|
instructionLayer({
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ import { EventV2 } from "../src/event"
|
|||||||
import { Reference } from "../src/reference"
|
import { Reference } from "../src/reference"
|
||||||
import { ToolRegistry } from "../src/tool/registry"
|
import { ToolRegistry } from "../src/tool/registry"
|
||||||
|
|
||||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, LocationServiceMap.node])))
|
const it = testEffect(
|
||||||
|
AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, LocationServiceMap.node])),
|
||||||
|
)
|
||||||
|
|
||||||
describe("LocationServiceMap", () => {
|
describe("LocationServiceMap", () => {
|
||||||
it.live("reuses cached services for constructed and decoded location refs", () =>
|
it.live("reuses cached services for constructed and decoded location refs", () =>
|
||||||
@@ -73,7 +75,6 @@ describe("LocationServiceMap", () => {
|
|||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
|
yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
|
||||||
const registry = yield* ToolRegistry.Service
|
const registry = yield* ToolRegistry.Service
|
||||||
yield* waitForTool(registry, "glob")
|
|
||||||
yield* waitForTool(registry, "shell")
|
yield* waitForTool(registry, "shell")
|
||||||
yield* waitForTool(registry, "subagent")
|
yield* waitForTool(registry, "subagent")
|
||||||
return {
|
return {
|
||||||
@@ -174,58 +175,6 @@ describe("LocationServiceMap", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("preserves the selected catalog identity when the api model id differs", () =>
|
|
||||||
Effect.acquireRelease(
|
|
||||||
Effect.promise(() => tmpdir()),
|
|
||||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
|
||||||
).pipe(
|
|
||||||
Effect.flatMap((dir) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
|
|
||||||
const resolved = yield* Effect.gen(function* () {
|
|
||||||
const catalog = yield* Catalog.Service
|
|
||||||
yield* catalog.transform((editor) => {
|
|
||||||
editor.provider.update(ProviderV2.ID.make("aliased"), (provider) => {
|
|
||||||
provider.api = { type: "aisdk", package: "@ai-sdk/openai", settings: {} }
|
|
||||||
})
|
|
||||||
editor.model.update(ProviderV2.ID.make("aliased"), ModelV2.ID.make("fast"), (model) => {
|
|
||||||
// Catalog id and provider API id intentionally differ, like gpt-5.5-fast -> gpt-5.5.
|
|
||||||
model.api = { ...model.api, id: ModelV2.ID.make("base") }
|
|
||||||
model.variants.push({ id: ModelV2.VariantID.make("high"), settings: {}, headers: {}, body: {} })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
const models = yield* SessionRunnerModel.Service
|
|
||||||
return yield* models.resolve(
|
|
||||||
SessionV2.Info.make({
|
|
||||||
id: SessionV2.ID.make("ses_aliased_model"),
|
|
||||||
projectID: ProjectV2.ID.global,
|
|
||||||
title: "test",
|
|
||||||
model: {
|
|
||||||
id: ModelV2.ID.make("fast"),
|
|
||||||
providerID: ProviderV2.ID.make("aliased"),
|
|
||||||
variant: ModelV2.VariantID.make("high"),
|
|
||||||
},
|
|
||||||
cost: 0,
|
|
||||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
||||||
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
|
||||||
location,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}).pipe(Effect.provide(LocationServiceMap.Service.get(location)))
|
|
||||||
|
|
||||||
expect(resolved.ref).toEqual(
|
|
||||||
ModelV2.Ref.make({
|
|
||||||
id: ModelV2.ID.make("fast"),
|
|
||||||
providerID: ProviderV2.ID.make("aliased"),
|
|
||||||
variant: ModelV2.VariantID.make("high"),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
expect(String(resolved.model.id)).toBe("base")
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("installs public plugins into a location", () =>
|
it.live("installs public plugins into a location", () =>
|
||||||
Effect.acquireRelease(
|
Effect.acquireRelease(
|
||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import { Effect, Exit, Fiber, Schema } from "effect"
|
|||||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
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 { Tool } from "@opencode-ai/core/tool/tool"
|
||||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
@@ -104,68 +102,4 @@ 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: [] })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ describe("CommandPlugin.Plugin", () => {
|
|||||||
expect(yield* command.get("review")).toMatchObject({
|
expect(yield* command.get("review")).toMatchObject({
|
||||||
name: "review",
|
name: "review",
|
||||||
description: "review changes [commit|branch|pr], defaults to uncommitted",
|
description: "review changes [commit|branch|pr], defaults to uncommitted",
|
||||||
|
subtask: true,
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { PluginV2 } from "@opencode-ai/core/plugin"
|
|||||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||||
import { Reference } from "@opencode-ai/core/reference"
|
import { Reference } from "@opencode-ai/core/reference"
|
||||||
import { SkillV2 } from "@opencode-ai/core/skill"
|
import { SkillV2 } from "@opencode-ai/core/skill"
|
||||||
import { ToolHooks } from "@opencode-ai/core/tool/hooks"
|
|
||||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { tempLocationLayer } from "../fixture/location"
|
import { tempLocationLayer } from "../fixture/location"
|
||||||
@@ -48,7 +47,6 @@ export const PluginTestLayer = AppNodeBuilder.build(
|
|||||||
PluginRuntime.node,
|
PluginRuntime.node,
|
||||||
Reference.node,
|
Reference.node,
|
||||||
SkillV2.node,
|
SkillV2.node,
|
||||||
ToolHooks.node,
|
|
||||||
ToolRegistry.toolsNode,
|
ToolRegistry.toolsNode,
|
||||||
]),
|
]),
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
{
|
|
||||||
"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,16 +52,11 @@ export function host(overrides: Overrides = {}): PluginContext {
|
|||||||
},
|
},
|
||||||
tool: overrides.tool ?? {
|
tool: overrides.tool ?? {
|
||||||
register: () => Effect.die("unused tool.register"),
|
register: () => Effect.die("unused tool.register"),
|
||||||
execute: {
|
|
||||||
before: () => Effect.die("unused tool.execute.before"),
|
|
||||||
after: () => Effect.die("unused tool.execute.after"),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
session: overrides.session ?? {
|
session: overrides.session ?? {
|
||||||
create: () => Effect.die("unused session.create"),
|
create: () => Effect.die("unused session.create"),
|
||||||
get: () => Effect.die("unused session.get"),
|
get: () => Effect.die("unused session.get"),
|
||||||
prompt: () => Effect.die("unused session.prompt"),
|
prompt: () => Effect.die("unused session.prompt"),
|
||||||
command: () => Effect.die("unused session.command"),
|
|
||||||
interrupt: () => Effect.die("unused session.interrupt"),
|
interrupt: () => Effect.die("unused session.interrupt"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -280,11 +275,7 @@ function agentInfo(value: AgentV2.Info) {
|
|||||||
return {
|
return {
|
||||||
...value,
|
...value,
|
||||||
model: value.model && { ...value.model },
|
model: value.model && { ...value.model },
|
||||||
request: {
|
request: { headers: { ...value.request.headers }, body: { ...value.request.body } },
|
||||||
settings: { ...value.request.settings },
|
|
||||||
headers: { ...value.request.headers },
|
|
||||||
body: { ...value.request.body },
|
|
||||||
},
|
|
||||||
permissions: value.permissions.map((permission) => ({ ...permission })),
|
permissions: value.permissions.map((permission) => ({ ...permission })),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -293,11 +284,7 @@ function providerInfo(value: ProviderV2.MutableInfo) {
|
|||||||
return {
|
return {
|
||||||
...value,
|
...value,
|
||||||
api: { ...value.api, settings: value.api.settings && { ...value.api.settings } },
|
api: { ...value.api, settings: value.api.settings && { ...value.api.settings } },
|
||||||
request: {
|
request: { headers: { ...value.request.headers }, body: { ...value.request.body } },
|
||||||
settings: { ...value.request.settings },
|
|
||||||
headers: { ...value.request.headers },
|
|
||||||
body: { ...value.request.body },
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,13 +299,11 @@ function modelInfo(value: ModelV2.Info | ModelV2.MutableInfo) {
|
|||||||
},
|
},
|
||||||
request: {
|
request: {
|
||||||
...value.request,
|
...value.request,
|
||||||
settings: { ...value.request.settings },
|
|
||||||
headers: { ...value.request.headers },
|
headers: { ...value.request.headers },
|
||||||
body: { ...value.request.body },
|
body: { ...value.request.body },
|
||||||
},
|
},
|
||||||
variants: value.variants.map((variant) => ({
|
variants: value.variants.map((variant) => ({
|
||||||
...variant,
|
...variant,
|
||||||
settings: { ...variant.settings },
|
|
||||||
headers: { ...variant.headers },
|
headers: { ...variant.headers },
|
||||||
body: { ...variant.body },
|
body: { ...variant.body },
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -168,14 +168,14 @@ describe("ModelsDevPlugin", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("converts reasoning options into settings variants", () =>
|
it.effect("derives OpenAI reasoning variants from models.dev reasoning options", () =>
|
||||||
Effect.acquireUseRelease(
|
Effect.acquireUseRelease(
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
const previous = {
|
const previous = {
|
||||||
path: Flag.OPENCODE_MODELS_PATH,
|
path: Flag.OPENCODE_MODELS_PATH,
|
||||||
disabled: Flag.OPENCODE_DISABLE_MODELS_FETCH,
|
disabled: Flag.OPENCODE_DISABLE_MODELS_FETCH,
|
||||||
}
|
}
|
||||||
Flag.OPENCODE_MODELS_PATH = path.join(import.meta.dir, "fixtures", "models-dev-reasoning.json")
|
Flag.OPENCODE_MODELS_PATH = path.join(import.meta.dir, "fixtures", "models-dev.json")
|
||||||
Flag.OPENCODE_DISABLE_MODELS_FETCH = true
|
Flag.OPENCODE_DISABLE_MODELS_FETCH = true
|
||||||
return previous
|
return previous
|
||||||
}),
|
}),
|
||||||
@@ -183,6 +183,17 @@ describe("ModelsDevPlugin", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
const integrations = yield* Integration.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(
|
yield* ModelsDevPlugin.effect(
|
||||||
host({
|
host({
|
||||||
catalog: catalogHost(catalog),
|
catalog: catalogHost(catalog),
|
||||||
@@ -190,67 +201,42 @@ describe("ModelsDevPlugin", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const model = yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-reasoning"))
|
expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("gpt-5.5")))?.variants).toEqual([
|
||||||
expect(model?.variants.map((variant) => variant.id)).toEqual([
|
{
|
||||||
ModelV2.VariantID.make("low"),
|
id: ModelV2.VariantID.make("none"),
|
||||||
ModelV2.VariantID.make("high"),
|
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" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
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))),
|
}).pipe(Effect.provide(AppNodeBuilder.build(ModelsDev.node))),
|
||||||
(previous) =>
|
(previous) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
@@ -259,4 +245,5 @@ describe("ModelsDevPlugin", () => {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ describe("AmazonBedrockPlugin", () => {
|
|||||||
})
|
})
|
||||||
catalog.provider.update(bedrock.id, (item) => {
|
catalog.provider.update(bedrock.id, (item) => {
|
||||||
item.api = bedrock.api
|
item.api = bedrock.api
|
||||||
item.request = { settings: {}, headers: {}, body: { endpoint: "https://bedrock.example" } }
|
item.request = bedrock.request
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
yield* addPlugin()
|
yield* addPlugin()
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ describe("AnthropicPlugin", () => {
|
|||||||
})
|
})
|
||||||
catalog.provider.update(item.id, (draft) => {
|
catalog.provider.update(item.id, (draft) => {
|
||||||
draft.api = item.api
|
draft.api = item.api
|
||||||
draft.request = { settings: {}, headers: { Existing: "1" }, body: {} }
|
draft.request = item.request
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
yield* addPlugin()
|
yield* addPlugin()
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ describe("AzurePlugin", () => {
|
|||||||
})
|
})
|
||||||
catalog.provider.update(azure.id, (item) => {
|
catalog.provider.update(azure.id, (item) => {
|
||||||
item.api = azure.api
|
item.api = azure.api
|
||||||
item.request = { settings: {}, headers: {}, body: { resourceName: "from-config" } }
|
item.request = azure.request
|
||||||
})
|
})
|
||||||
catalog.provider.update(ProviderV2.ID.openai, () => {})
|
catalog.provider.update(ProviderV2.ID.openai, () => {})
|
||||||
})
|
})
|
||||||
@@ -110,7 +110,7 @@ describe("AzurePlugin", () => {
|
|||||||
})
|
})
|
||||||
catalog.provider.update(azure.id, (item) => {
|
catalog.provider.update(azure.id, (item) => {
|
||||||
item.api = azure.api
|
item.api = azure.api
|
||||||
item.request = { settings: {}, headers: {}, body: { resourceName: "" } }
|
item.request = azure.request
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
yield* addPlugin()
|
yield* addPlugin()
|
||||||
@@ -131,7 +131,7 @@ describe("AzurePlugin", () => {
|
|||||||
})
|
})
|
||||||
catalog.provider.update(azure.id, (item) => {
|
catalog.provider.update(azure.id, (item) => {
|
||||||
item.api = azure.api
|
item.api = azure.api
|
||||||
item.request = { settings: {}, headers: {}, body: { resourceName: " " } }
|
item.request = azure.request
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
yield* addPlugin()
|
yield* addPlugin()
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ describe("KiloPlugin", () => {
|
|||||||
package: "@ai-sdk/openai-compatible",
|
package: "@ai-sdk/openai-compatible",
|
||||||
url: "https://api.kilo.ai/api/gateway",
|
url: "https://api.kilo.ai/api/gateway",
|
||||||
}
|
}
|
||||||
provider.request = { settings: {}, headers: { Existing: "value" }, body: {} }
|
provider.request = { headers: { Existing: "value" }, body: {} }
|
||||||
})
|
})
|
||||||
catalog.provider.update(ProviderV2.ID.openrouter, () => {})
|
catalog.provider.update(ProviderV2.ID.openrouter, () => {})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ describe("LLMGatewayPlugin", () => {
|
|||||||
package: "@ai-sdk/openai-compatible",
|
package: "@ai-sdk/openai-compatible",
|
||||||
url: "https://api.llmgateway.io/v1",
|
url: "https://api.llmgateway.io/v1",
|
||||||
}
|
}
|
||||||
provider.request = { settings: {}, headers: { Existing: "value" }, body: {} }
|
provider.request = { headers: { Existing: "value" }, body: {} }
|
||||||
})
|
})
|
||||||
catalog.provider.update(ProviderV2.ID.openrouter, () => {})
|
catalog.provider.update(ProviderV2.ID.openrouter, () => {})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ describe("NvidiaPlugin", () => {
|
|||||||
package: "@ai-sdk/openai-compatible",
|
package: "@ai-sdk/openai-compatible",
|
||||||
url: "https://integrate.api.nvidia.com/v1",
|
url: "https://integrate.api.nvidia.com/v1",
|
||||||
}
|
}
|
||||||
provider.request = { settings: {}, headers: { Existing: "value" }, body: {} }
|
provider.request = { headers: { Existing: "value" }, body: {} }
|
||||||
})
|
})
|
||||||
catalog.provider.update(ProviderV2.ID.openrouter, () => {})
|
catalog.provider.update(ProviderV2.ID.openrouter, () => {})
|
||||||
})
|
})
|
||||||
@@ -80,7 +80,6 @@ describe("NvidiaPlugin", () => {
|
|||||||
url: "https://integrate.api.nvidia.com/v1",
|
url: "https://integrate.api.nvidia.com/v1",
|
||||||
}
|
}
|
||||||
provider.request = {
|
provider.request = {
|
||||||
settings: {},
|
|
||||||
headers: { "X-BILLING-INVOKE-ORIGIN": "CustomOrigin" },
|
headers: { "X-BILLING-INVOKE-ORIGIN": "CustomOrigin" },
|
||||||
body: { baseURL: "https://integrate.api.nvidia.com/v1" },
|
body: { baseURL: "https://integrate.api.nvidia.com/v1" },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { describe, expect } from "bun:test"
|
|||||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
import { Credential } from "@opencode-ai/core/credential"
|
|
||||||
import { Integration } from "@opencode-ai/core/integration"
|
import { Integration } from "@opencode-ai/core/integration"
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
@@ -28,20 +27,6 @@ function required<T>(value: T | undefined): T {
|
|||||||
return value
|
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[]) {
|
function fakeSelectorSdk(calls: string[]) {
|
||||||
const make = (method: string) => (id: string) => {
|
const make = (method: string) => (id: string) => {
|
||||||
calls.push(`${method}:${id}`)
|
calls.push(`${method}:${id}`)
|
||||||
@@ -168,80 +153,6 @@ 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", () =>
|
it.effect("does not disable gpt-5-chat-latest for non-OpenAI providers", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
|
|||||||
@@ -142,7 +142,6 @@ describe("OpencodePlugin", () => {
|
|||||||
model.variants = [
|
model.variants = [
|
||||||
{
|
{
|
||||||
id: ModelV2.VariantID.make("custom"),
|
id: ModelV2.VariantID.make("custom"),
|
||||||
settings: {},
|
|
||||||
headers: { "x-custom": "true" },
|
headers: { "x-custom": "true" },
|
||||||
body: { custom: true },
|
body: { custom: true },
|
||||||
},
|
},
|
||||||
@@ -178,7 +177,7 @@ describe("OpencodePlugin", () => {
|
|||||||
url: `${server.url.origin}/v1`,
|
url: `${server.url.origin}/v1`,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
expect(provider.request).toEqual({ settings: {}, headers: { "x-org-id": "org" }, body: { custom: "value" } })
|
expect(provider.request).toEqual({ headers: { "x-org-id": "org" }, body: { custom: "value" } })
|
||||||
expect(yield* (yield* Integration.Service).get(Integration.ID.make("remote"))).toBeUndefined()
|
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")))
|
const model = required(yield* catalog.model.get(ProviderV2.ID.make("remote"), ModelV2.ID.make("model")))
|
||||||
@@ -193,13 +192,11 @@ describe("OpencodePlugin", () => {
|
|||||||
expect(model.variants).toEqual([
|
expect(model.variants).toEqual([
|
||||||
{
|
{
|
||||||
id: ModelV2.VariantID.make("custom"),
|
id: ModelV2.VariantID.make("custom"),
|
||||||
settings: {},
|
|
||||||
headers: { "x-custom": "true" },
|
headers: { "x-custom": "true" },
|
||||||
body: { custom: true },
|
body: { custom: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: ModelV2.VariantID.make("high"),
|
id: ModelV2.VariantID.make("high"),
|
||||||
settings: {},
|
|
||||||
headers: {},
|
headers: {},
|
||||||
body: { temperature: 0.2 },
|
body: { temperature: 0.2 },
|
||||||
},
|
},
|
||||||
@@ -362,7 +359,6 @@ describe("OpencodePlugin", () => {
|
|||||||
...ProviderV2.Info.empty(ProviderV2.ID.opencode),
|
...ProviderV2.Info.empty(ProviderV2.ID.opencode),
|
||||||
api: { type: "aisdk", package: "test-provider" },
|
api: { type: "aisdk", package: "test-provider" },
|
||||||
request: {
|
request: {
|
||||||
settings: {},
|
|
||||||
headers: {},
|
headers: {},
|
||||||
body: { apiKey: "configured" },
|
body: { apiKey: "configured" },
|
||||||
},
|
},
|
||||||
@@ -373,7 +369,7 @@ describe("OpencodePlugin", () => {
|
|||||||
cost: cost(1),
|
cost: cost(1),
|
||||||
})
|
})
|
||||||
catalog.provider.update(provider.id, (draft) => {
|
catalog.provider.update(provider.id, (draft) => {
|
||||||
draft.request = { settings: {}, headers: {}, body: { apiKey: "configured" } }
|
draft.request = provider.request
|
||||||
})
|
})
|
||||||
catalog.model.update(provider.id, model.id, (draft) => {
|
catalog.model.update(provider.id, model.id, (draft) => {
|
||||||
draft.cost = [...model.cost]
|
draft.cost = [...model.cost]
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ describe("OpenRouterPlugin", () => {
|
|||||||
yield* catalog.transform((catalog) => {
|
yield* catalog.transform((catalog) => {
|
||||||
catalog.provider.update(ProviderV2.ID.openrouter, (provider) => {
|
catalog.provider.update(ProviderV2.ID.openrouter, (provider) => {
|
||||||
provider.api = { type: "aisdk", package: "@openrouter/ai-sdk-provider" }
|
provider.api = { type: "aisdk", package: "@openrouter/ai-sdk-provider" }
|
||||||
provider.request = { settings: {}, headers: { Existing: "value" }, body: {} }
|
provider.request = { headers: { Existing: "value" }, body: {} }
|
||||||
})
|
})
|
||||||
catalog.provider.update(ProviderV2.ID.make("nvidia"), () => {})
|
catalog.provider.update(ProviderV2.ID.make("nvidia"), () => {})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ describe("VariantPlugin", () => {
|
|||||||
yield* VariantPlugin.Plugin.effect(host({ catalog: catalogHost(service) }))
|
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((yield* service.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("glm-5.2")))?.variants).toEqual([
|
||||||
expect.objectContaining({ id: "high", settings: { reasoningEffort: "high" } }),
|
expect.objectContaining({ id: "high", body: { reasoning_effort: "high" } }),
|
||||||
expect.objectContaining({ id: "max", settings: { reasoningEffort: "max" } }),
|
expect.objectContaining({ id: "max", body: { reasoning_effort: "max" } }),
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -53,14 +53,14 @@ describe("VariantPlugin", () => {
|
|||||||
type: "aisdk",
|
type: "aisdk",
|
||||||
package: "@ai-sdk/openai-compatible",
|
package: "@ai-sdk/openai-compatible",
|
||||||
}
|
}
|
||||||
model.variants = [{ id: ModelV2.VariantID.make("high"), settings: {}, headers: { custom: "true" }, body: {} }]
|
model.variants = [{ id: ModelV2.VariantID.make("high"), headers: { custom: "true" }, body: {} }]
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
yield* VariantPlugin.Plugin.effect(host({ catalog: catalogHost(service) }))
|
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((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: "high", headers: { custom: "true" } }),
|
||||||
expect.objectContaining({ id: "max", settings: { reasoningEffort: "max" } }),
|
expect.objectContaining({ id: "max", body: { reasoning_effort: "max" } }),
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ describe("ReferenceGuidance", () => {
|
|||||||
const guidance = yield* ReferenceGuidance.Service
|
const guidance = yield* ReferenceGuidance.Service
|
||||||
const generation = yield* SystemContext.initialize(yield* guidance.load())
|
const generation = yield* SystemContext.initialize(yield* guidance.load())
|
||||||
|
|
||||||
expect(generation.text).toContain("<available_references>")
|
expect(generation.baseline).toContain("<available_references>")
|
||||||
expect(generation.text).toContain("<name>docs</name>")
|
expect(generation.baseline).toContain("<name>docs</name>")
|
||||||
expect(generation.text).toContain("<path>/docs</path>")
|
expect(generation.baseline).toContain("<path>/docs</path>")
|
||||||
expect(generation.text).toContain("<description>Use for product documentation</description>")
|
expect(generation.baseline).toContain("<description>Use for product documentation</description>")
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.provide(
|
Effect.provide(
|
||||||
guidanceLayer(
|
guidanceLayer(
|
||||||
@@ -47,7 +47,7 @@ describe("ReferenceGuidance", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const guidance = yield* ReferenceGuidance.Service
|
const guidance = yield* ReferenceGuidance.Service
|
||||||
const generation = yield* SystemContext.initialize(yield* guidance.load())
|
const generation = yield* SystemContext.initialize(yield* guidance.load())
|
||||||
expect(generation.text).toBe("")
|
expect(generation.baseline).toBe("")
|
||||||
}).pipe(Effect.provide(guidanceLayer(Layer.mock(Reference.Service, { list: () => Effect.succeed([]) })))),
|
}).pipe(Effect.provide(guidanceLayer(Layer.mock(Reference.Service, { list: () => Effect.succeed([]) })))),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ describe("ReferenceGuidance", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const guidance = yield* ReferenceGuidance.Service
|
const guidance = yield* ReferenceGuidance.Service
|
||||||
const generation = yield* SystemContext.initialize(yield* guidance.load())
|
const generation = yield* SystemContext.initialize(yield* guidance.load())
|
||||||
expect(generation.text).toBe("")
|
expect(generation.baseline).toBe("")
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.provide(
|
Effect.provide(
|
||||||
guidanceLayer(
|
guidanceLayer(
|
||||||
@@ -73,41 +73,4 @@ 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) }))))
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ const client = Layer.mock(LLMClient.Service)({
|
|||||||
generate: () => Effect.die("unused"),
|
generate: () => Effect.die("unused"),
|
||||||
})
|
})
|
||||||
const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
|
const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
|
||||||
const models = SessionRunnerModel.layerWith(() => Effect.succeed(SessionRunnerModel.resolved(model)))
|
const models = SessionRunnerModel.layerWith(() => Effect.succeed(model))
|
||||||
const locations = Layer.effect(
|
const locations = Layer.effect(
|
||||||
LocationServiceMap.Service,
|
LocationServiceMap.Service,
|
||||||
LayerMap.make(
|
LayerMap.make(
|
||||||
|
|||||||
@@ -38,9 +38,7 @@ const client = Layer.mock(LLMClient.Service)({
|
|||||||
generate: () => Effect.die("unused"),
|
generate: () => Effect.die("unused"),
|
||||||
})
|
})
|
||||||
const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
|
const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
|
||||||
const models = Layer.mock(SessionRunnerModel.Service)({
|
const models = Layer.mock(SessionRunnerModel.Service)({ resolve: () => Effect.succeed(model) })
|
||||||
resolve: () => Effect.succeed(SessionRunnerModel.resolved(model)),
|
|
||||||
})
|
|
||||||
const it = testEffect(
|
const it = testEffect(
|
||||||
AppNodeBuilder.build(
|
AppNodeBuilder.build(
|
||||||
LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionCompaction.node]),
|
LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionCompaction.node]),
|
||||||
|
|||||||
@@ -49,12 +49,6 @@ const it = testEffect(
|
|||||||
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
|
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
|
||||||
const id = SessionV2.ID.create()
|
const id = SessionV2.ID.create()
|
||||||
|
|
||||||
/** Public session events from a `log` read, without caught-up markers. */
|
|
||||||
const logEvents = (session: SessionV2.Interface, sessionID: SessionV2.ID, follow?: boolean) =>
|
|
||||||
session
|
|
||||||
.log({ sessionID, follow })
|
|
||||||
.pipe(Stream.filter((item): item is SessionEvent.DurableEvent => !EventV2.isCaughtUp(item)))
|
|
||||||
|
|
||||||
const assertCreateInputTypes = (session: SessionV2.Interface) => {
|
const assertCreateInputTypes = (session: SessionV2.Interface) => {
|
||||||
// @ts-expect-error location or parentID is required.
|
// @ts-expect-error location or parentID is required.
|
||||||
session.create({})
|
session.create({})
|
||||||
@@ -72,7 +66,7 @@ describe("SessionV2.create", () => {
|
|||||||
const second = yield* session.create({ location })
|
const second = yield* session.create({ location })
|
||||||
|
|
||||||
expect(second.id).not.toBe(first.id)
|
expect(second.id).not.toBe(first.id)
|
||||||
expect((yield* session.list()).data).toHaveLength(2)
|
expect(yield* session.list()).toHaveLength(2)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -85,7 +79,7 @@ describe("SessionV2.create", () => {
|
|||||||
const retried = yield* session.create(input)
|
const retried = yield* session.create(input)
|
||||||
|
|
||||||
expect(retried).toEqual(first)
|
expect(retried).toEqual(first)
|
||||||
expect((yield* session.list()).data).toEqual([first])
|
expect(yield* session.list()).toEqual([first])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -152,7 +146,7 @@ describe("SessionV2.create", () => {
|
|||||||
const forked = yield* session.fork({ sessionID: parent.id })
|
const forked = yield* session.fork({ sessionID: parent.id })
|
||||||
const parentContext = yield* session.context(parent.id)
|
const parentContext = yield* session.context(parent.id)
|
||||||
const forkContext = yield* session.context(forked.id)
|
const forkContext = yield* session.context(forked.id)
|
||||||
const history = Array.from(yield* Stream.runCollect(logEvents(session, forked.id)))
|
const history = yield* session.history({ sessionID: forked.id, limit: 10 })
|
||||||
|
|
||||||
expect(forked).toMatchObject({ parentID: parent.id, title: "Parent (fork #1)" })
|
expect(forked).toMatchObject({ parentID: parent.id, title: "Parent (fork #1)" })
|
||||||
expect(forkContext).toMatchObject([
|
expect(forkContext).toMatchObject([
|
||||||
@@ -160,8 +154,8 @@ describe("SessionV2.create", () => {
|
|||||||
{ type: "synthetic", text: "parent note", sessionID: forked.id },
|
{ type: "synthetic", text: "parent note", sessionID: forked.id },
|
||||||
])
|
])
|
||||||
expect(forkContext.map((message) => message.id)).not.toEqual(parentContext.map((message) => message.id))
|
expect(forkContext.map((message) => message.id)).not.toEqual(parentContext.map((message) => message.id))
|
||||||
expect(history).toHaveLength(1)
|
expect(history.events).toHaveLength(1)
|
||||||
expect(history[0]).toMatchObject({
|
expect(history.events[0]).toMatchObject({
|
||||||
type: "session.next.forked",
|
type: "session.next.forked",
|
||||||
durable: { seq: 0 },
|
durable: { seq: 0 },
|
||||||
data: { sessionID: forked.id, parentID: parent.id },
|
data: { sessionID: forked.id, parentID: parent.id },
|
||||||
@@ -181,9 +175,7 @@ describe("SessionV2.create", () => {
|
|||||||
expect((yield* session.context(forked.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"])
|
expect((yield* session.context(forked.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"])
|
||||||
expect((yield* session.context(forked.id)).at(-1)).toMatchObject({ text: "Child continues" })
|
expect((yield* session.context(forked.id)).at(-1)).toMatchObject({ text: "Child continues" })
|
||||||
expect(
|
expect(
|
||||||
Array.from(yield* Stream.runCollect(logEvents(session, forked.id))).map(
|
(yield* session.history({ sessionID: forked.id, limit: 10 })).events.map((event) => event.durable?.seq),
|
||||||
(event): number | undefined => event.durable?.seq,
|
|
||||||
),
|
|
||||||
).toEqual([0, 4, 5])
|
).toEqual([0, 4, 5])
|
||||||
expect(yield* SessionInput.find(db, admitted.id)).toMatchObject({ sessionID: parent.id })
|
expect(yield* SessionInput.find(db, admitted.id)).toMatchObject({ sessionID: parent.id })
|
||||||
}),
|
}),
|
||||||
@@ -211,10 +203,10 @@ describe("SessionV2.create", () => {
|
|||||||
const forked = yield* session.fork({ sessionID: parent.id, messageID: second.id })
|
const forked = yield* session.fork({ sessionID: parent.id, messageID: second.id })
|
||||||
|
|
||||||
const context = yield* session.context(forked.id)
|
const context = yield* session.context(forked.id)
|
||||||
const history = Array.from(yield* Stream.runCollect(logEvents(session, forked.id)))
|
const history = yield* session.history({ sessionID: forked.id, limit: 10 })
|
||||||
expect(context).toMatchObject([{ text: "First" }])
|
expect(context).toMatchObject([{ text: "First" }])
|
||||||
expect(context[0]?.id).not.toBe(first.id)
|
expect(context[0]?.id).not.toBe(first.id)
|
||||||
expect(history[0]).toMatchObject({ data: { messageID: second.id } })
|
expect(history.events[0]).toMatchObject({ data: { messageID: second.id } })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -235,7 +227,7 @@ describe("SessionV2.create", () => {
|
|||||||
for (const input of changed) {
|
for (const input of changed) {
|
||||||
expect(yield* session.create(input)).toEqual(created)
|
expect(yield* session.create(input)).toEqual(created)
|
||||||
}
|
}
|
||||||
expect((yield* session.list()).data).toHaveLength(1)
|
expect(yield* session.list()).toHaveLength(1)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -247,7 +239,7 @@ describe("SessionV2.create", () => {
|
|||||||
const created = yield* Effect.all([session.create(input), session.create(input)], { concurrency: "unbounded" })
|
const created = yield* Effect.all([session.create(input), session.create(input)], { concurrency: "unbounded" })
|
||||||
|
|
||||||
expect(created[1]).toEqual(created[0])
|
expect(created[1]).toEqual(created[0])
|
||||||
expect((yield* session.list()).data).toEqual([created[0]])
|
expect(yield* session.list()).toEqual([created[0]])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -325,7 +317,7 @@ describe("SessionV2.create", () => {
|
|||||||
yield* SessionInput.promoteSteers(db, events, created.id, Number.MAX_SAFE_INTEGER)
|
yield* SessionInput.promoteSteers(db, events, created.id, Number.MAX_SAFE_INTEGER)
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(2), Stream.runCollect)),
|
Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(2), Stream.runCollect)),
|
||||||
).toMatchObject([
|
).toMatchObject([
|
||||||
{ durable: { seq: 1 }, type: "session.next.prompt.admitted", data: { prompt: { text: "Hello" } } },
|
{ durable: { seq: 1 }, type: "session.next.prompt.admitted", data: { prompt: { text: "Hello" } } },
|
||||||
{ durable: { seq: 2 }, type: "session.next.prompted" },
|
{ durable: { seq: 2 }, type: "session.next.prompted" },
|
||||||
@@ -455,7 +447,7 @@ describe("SessionV2.create", () => {
|
|||||||
|
|
||||||
expect(yield* session.get(created.id)).toMatchObject({ agent: "plan" })
|
expect(yield* session.get(created.id)).toMatchObject({ agent: "plan" })
|
||||||
expect(
|
expect(
|
||||||
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(1), Stream.runCollect)),
|
Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(1), Stream.runCollect)),
|
||||||
).toMatchObject([{ type: "session.next.agent.switched", data: { agent: "plan" } }])
|
).toMatchObject([{ type: "session.next.agent.switched", data: { agent: "plan" } }])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -488,7 +480,7 @@ describe("SessionV2.create", () => {
|
|||||||
|
|
||||||
expect(yield* session.get(created.id)).toMatchObject({ model })
|
expect(yield* session.get(created.id)).toMatchObject({ model })
|
||||||
expect(
|
expect(
|
||||||
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(1), Stream.runCollect)),
|
Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(1), Stream.runCollect)),
|
||||||
).toMatchObject([{ type: "session.next.model.switched", data: { model } }])
|
).toMatchObject([{ type: "session.next.model.switched", data: { model } }])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Effect, Layer, Schema } from "effect"
|
||||||
|
import { Database } from "@opencode-ai/core/database/database"
|
||||||
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
|
import { Job } from "@opencode-ai/core/job"
|
||||||
|
import { Location } from "@opencode-ai/core/location"
|
||||||
|
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||||
|
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||||
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
|
import { SessionV2 } from "@opencode-ai/core/session"
|
||||||
|
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||||
|
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||||
|
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||||
|
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||||
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
|
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 it = testEffect(
|
||||||
|
AppNodeBuilder.build(
|
||||||
|
LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]),
|
||||||
|
[
|
||||||
|
[ProjectV2.node, projects],
|
||||||
|
[SessionExecution.node, SessionExecution.noopLayer],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
|
||||||
|
|
||||||
|
const GapEvent = EventV2.define({
|
||||||
|
type: "test.session.history.gap",
|
||||||
|
durable: { aggregate: "sessionID", version: 1 },
|
||||||
|
schema: { sessionID: SessionV2.ID, value: Schema.String },
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("SessionV2.history", () => {
|
||||||
|
it.effect("returns an exhausted page for a migrated Session with no event sequence", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const db = (yield* Database.Service).db
|
||||||
|
const session = yield* SessionV2.Service
|
||||||
|
const sessionID = SessionV2.ID.make("ses_empty_history")
|
||||||
|
yield* db
|
||||||
|
.insert(ProjectTable)
|
||||||
|
.values({ id: ProjectV2.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||||
|
.onConflictDoNothing()
|
||||||
|
.run()
|
||||||
|
yield* db
|
||||||
|
.insert(SessionTable)
|
||||||
|
.values({
|
||||||
|
id: sessionID,
|
||||||
|
project_id: ProjectV2.ID.global,
|
||||||
|
slug: "empty-history",
|
||||||
|
directory: "/project",
|
||||||
|
title: "Empty history",
|
||||||
|
version: "test",
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
|
||||||
|
const first = yield* session.history({ sessionID, limit: 10 })
|
||||||
|
|
||||||
|
expect(first).toEqual({ events: [], hasMore: false })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("treats after as an exclusive aggregate sequence", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const session = yield* SessionV2.Service
|
||||||
|
const created = yield* session.create({ location })
|
||||||
|
yield* session.switchAgent({ sessionID: created.id, agent: "one" })
|
||||||
|
yield* session.switchAgent({ sessionID: created.id, agent: "two" })
|
||||||
|
|
||||||
|
const page = yield* session.history({ sessionID: created.id, after: 1, limit: 10 })
|
||||||
|
|
||||||
|
expect(page.events.map((event) => event.durable?.seq)).toEqual([2])
|
||||||
|
expect(page.hasMore).toBe(false)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("paginates public events in aggregate order across filtered gaps without duplicates", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const session = yield* SessionV2.Service
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
const created = yield* session.create({ location })
|
||||||
|
yield* session.switchAgent({ sessionID: created.id, agent: "one" })
|
||||||
|
yield* events.publish(GapEvent, { sessionID: created.id, value: "filtered" })
|
||||||
|
yield* session.switchAgent({ sessionID: created.id, agent: "two" })
|
||||||
|
yield* session.switchAgent({ sessionID: created.id, agent: "three" })
|
||||||
|
|
||||||
|
const first = yield* session.history({ sessionID: created.id, limit: 2 })
|
||||||
|
const after = first.events.at(-1)?.durable?.seq
|
||||||
|
const second = yield* session.history({
|
||||||
|
sessionID: created.id,
|
||||||
|
after,
|
||||||
|
limit: 2,
|
||||||
|
})
|
||||||
|
const sequence = [...first.events, ...second.events].map((event) => event.durable?.seq)
|
||||||
|
|
||||||
|
expect(first.hasMore).toBe(true)
|
||||||
|
expect(second.hasMore).toBe(false)
|
||||||
|
expect(sequence).toEqual([1, 3, 4])
|
||||||
|
expect(new Set(sequence).size).toBe(sequence.length)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("includes events committed between pages", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const session = yield* SessionV2.Service
|
||||||
|
const created = yield* session.create({ location })
|
||||||
|
yield* session.switchAgent({ sessionID: created.id, agent: "one" })
|
||||||
|
yield* session.switchAgent({ sessionID: created.id, agent: "two" })
|
||||||
|
|
||||||
|
const first = yield* session.history({ sessionID: created.id, limit: 1 })
|
||||||
|
yield* session.switchAgent({ sessionID: created.id, agent: "later" })
|
||||||
|
const second = yield* session.history({
|
||||||
|
sessionID: created.id,
|
||||||
|
after: first.events.at(-1)?.durable?.seq,
|
||||||
|
limit: 10,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(first.hasMore).toBe(true)
|
||||||
|
expect([...first.events, ...second.events].map((event) => event.durable?.seq)).toEqual([1, 2, 3])
|
||||||
|
expect(second.hasMore).toBe(false)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("reports exhaustion for exact-limit and limit-plus-one pages", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const session = yield* SessionV2.Service
|
||||||
|
const created = yield* session.create({ location })
|
||||||
|
yield* session.switchAgent({ sessionID: created.id, agent: "one" })
|
||||||
|
yield* session.switchAgent({ sessionID: created.id, agent: "two" })
|
||||||
|
|
||||||
|
const exact = yield* session.history({ sessionID: created.id, limit: 2 })
|
||||||
|
const oneMore = yield* session.history({ sessionID: created.id, limit: 1 })
|
||||||
|
const exhausted = yield* session.history({
|
||||||
|
sessionID: created.id,
|
||||||
|
after: oneMore.events.at(-1)?.durable?.seq,
|
||||||
|
limit: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(exact.events).toHaveLength(2)
|
||||||
|
expect(exact.hasMore).toBe(false)
|
||||||
|
expect(oneMore.events).toHaveLength(1)
|
||||||
|
expect(oneMore.hasMore).toBe(true)
|
||||||
|
expect(exhausted.events).toHaveLength(1)
|
||||||
|
expect(exhausted.hasMore).toBe(false)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("fails with NotFoundError for a missing Session", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const session = yield* SessionV2.Service
|
||||||
|
const error = yield* session.history({ sessionID: SessionV2.ID.make("ses_missing"), limit: 10 }).pipe(Effect.flip)
|
||||||
|
|
||||||
|
expect(error._tag).toBe("Session.NotFoundError")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -1,301 +0,0 @@
|
|||||||
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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
import { describe, expect } from "bun:test"
|
|
||||||
import { Effect, Fiber, Layer, Schema, Stream } from "effect"
|
|
||||||
import { Database } from "@opencode-ai/core/database/database"
|
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
|
||||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
|
||||||
import { Location } from "@opencode-ai/core/location"
|
|
||||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
|
||||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
|
||||||
import { SessionV2 } from "@opencode-ai/core/session"
|
|
||||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
|
||||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
|
||||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
|
||||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
|
||||||
import { testEffect } from "./lib/effect"
|
|
||||||
|
|
||||||
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 it = testEffect(
|
|
||||||
AppNodeBuilder.build(
|
|
||||||
LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]),
|
|
||||||
[
|
|
||||||
[ProjectV2.node, projects],
|
|
||||||
[SessionExecution.node, SessionExecution.noopLayer],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
|
|
||||||
|
|
||||||
describe("SessionV2.log", () => {
|
|
||||||
it.effect("replays public session events and marks caught-up at the aggregate watermark", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const session = yield* SessionV2.Service
|
|
||||||
const events = yield* EventV2.Service
|
|
||||||
const created = yield* session.create({ location })
|
|
||||||
yield* session.rename({ sessionID: created.id, title: "renamed" })
|
|
||||||
|
|
||||||
const items = Array.from(yield* Stream.runCollect(session.log({ sessionID: created.id })))
|
|
||||||
const watermark = (yield* events.sequences([created.id])).get(created.id)
|
|
||||||
|
|
||||||
// Session creation commits a non-public durable event, so the marker's
|
|
||||||
// seq covers more of the aggregate than the public events emitted.
|
|
||||||
expect(items.map((item) => item.type)).toEqual(["session.next.renamed", "log.caught_up"])
|
|
||||||
expect(items.at(-1)).toEqual({ type: "log.caught_up", aggregateID: created.id, seq: watermark })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("continues with live public events when following", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const session = yield* SessionV2.Service
|
|
||||||
const created = yield* session.create({ location })
|
|
||||||
const fiber = yield* session
|
|
||||||
.log({ sessionID: created.id, follow: true })
|
|
||||||
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
|
|
||||||
yield* Effect.yieldNow
|
|
||||||
|
|
||||||
yield* session.rename({ sessionID: created.id, title: "renamed live" })
|
|
||||||
|
|
||||||
const items = Array.from(yield* Fiber.join(fiber))
|
|
||||||
expect(items.map((item) => item.type)).toEqual(["log.caught_up", "session.next.renamed"])
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("fails with NotFound for an unknown session", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const session = yield* SessionV2.Service
|
|
||||||
const error = yield* Effect.flip(Stream.runCollect(session.log({ sessionID: SessionV2.ID.create() })))
|
|
||||||
expect(error._tag).toBe("Session.NotFoundError")
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("reads across undecodable gaps in aggregate order and marks the true log position", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const GapEvent = EventV2.define({
|
|
||||||
type: "test.session.log.gap",
|
|
||||||
durable: { aggregate: "sessionID", version: 1 },
|
|
||||||
schema: { sessionID: SessionV2.ID, value: Schema.String },
|
|
||||||
})
|
|
||||||
const session = yield* SessionV2.Service
|
|
||||||
const events = yield* EventV2.Service
|
|
||||||
const created = yield* session.create({ location })
|
|
||||||
yield* session.switchAgent({ sessionID: created.id, agent: "one" })
|
|
||||||
// Not in the durable manifest, so reads must skip it without failing.
|
|
||||||
yield* events.publish(GapEvent, { sessionID: created.id, value: "filtered" })
|
|
||||||
yield* session.switchAgent({ sessionID: created.id, agent: "two" })
|
|
||||||
yield* session.switchAgent({ sessionID: created.id, agent: "three" })
|
|
||||||
|
|
||||||
const items = Array.from(yield* Stream.runCollect(session.log({ sessionID: created.id, after: 1 })))
|
|
||||||
|
|
||||||
expect(
|
|
||||||
items.map((item): number | string | undefined => (EventV2.isCaughtUp(item) ? item.type : item.durable?.seq)),
|
|
||||||
).toEqual([3, 4, "log.caught_up"])
|
|
||||||
expect(items.at(-1)).toEqual({ type: "log.caught_up", aggregateID: created.id, seq: EventV2.Seq.make(4) })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("completes with a bare caught-up marker for a migrated Session with no event sequence", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const db = (yield* Database.Service).db
|
|
||||||
const session = yield* SessionV2.Service
|
|
||||||
const sessionID = SessionV2.ID.make("ses_empty_log")
|
|
||||||
yield* db
|
|
||||||
.insert(ProjectTable)
|
|
||||||
.values({ id: ProjectV2.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
|
||||||
.onConflictDoNothing()
|
|
||||||
.run()
|
|
||||||
yield* db
|
|
||||||
.insert(SessionTable)
|
|
||||||
.values({
|
|
||||||
id: sessionID,
|
|
||||||
project_id: ProjectV2.ID.global,
|
|
||||||
slug: "empty-log",
|
|
||||||
directory: "/project",
|
|
||||||
title: "Empty log",
|
|
||||||
version: "test",
|
|
||||||
})
|
|
||||||
.run()
|
|
||||||
|
|
||||||
const items = Array.from(yield* Stream.runCollect(session.log({ sessionID })))
|
|
||||||
|
|
||||||
expect(items).toEqual([{ type: "log.caught_up", aggregateID: sessionID }])
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("SessionV2 watermarks", () => {
|
|
||||||
it.effect("list pairs each session snapshot with its durable log watermark", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const session = yield* SessionV2.Service
|
|
||||||
const events = yield* EventV2.Service
|
|
||||||
const first = yield* session.create({ location })
|
|
||||||
const second = yield* session.create({ location })
|
|
||||||
yield* session.rename({ sessionID: first.id, title: "renamed" })
|
|
||||||
|
|
||||||
const page = yield* session.list()
|
|
||||||
const sequences = yield* events.sequences([first.id, second.id])
|
|
||||||
|
|
||||||
expect(page.data.map((info) => info.id).toSorted()).toEqual([first.id, second.id].toSorted())
|
|
||||||
expect(page.watermarks).toEqual(sequences)
|
|
||||||
expect(page.watermarks.get(first.id)).toBeGreaterThan(page.watermarks.get(second.id)!)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("watermarks omits sessions without durable events", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const session = yield* SessionV2.Service
|
|
||||||
const created = yield* session.create({ location })
|
|
||||||
|
|
||||||
const watermarks = yield* session.watermarks([created.id, SessionV2.ID.create()])
|
|
||||||
|
|
||||||
expect(Array.from(watermarks.keys())).toEqual([created.id])
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -19,12 +19,7 @@ import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater
|
|||||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||||
import { SessionInput } from "@opencode-ai/core/session/input"
|
import { SessionInput } from "@opencode-ai/core/session/input"
|
||||||
import {
|
import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||||
SessionContextCheckpointTable,
|
|
||||||
SessionInputTable,
|
|
||||||
SessionMessageTable,
|
|
||||||
SessionTable,
|
|
||||||
} from "@opencode-ai/core/session/sql"
|
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||||
|
|
||||||
@@ -72,10 +67,6 @@ describe("SessionProjector", () => {
|
|||||||
.insert(SessionMessageTable)
|
.insert(SessionMessageTable)
|
||||||
.values([assistantRow(boundary, 1), assistantRow(SessionMessage.ID.make("msg_later"), 2)])
|
.values([assistantRow(boundary, 1), assistantRow(SessionMessage.ID.make("msg_later"), 2)])
|
||||||
.run()
|
.run()
|
||||||
yield* db
|
|
||||||
.insert(SessionContextCheckpointTable)
|
|
||||||
.values({ session_id: sessionID, baseline: "baseline", snapshot: {}, baseline_seq: 0 })
|
|
||||||
.run()
|
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
yield* events.publish(SessionEvent.RevertEvent.Staged, {
|
yield* events.publish(SessionEvent.RevertEvent.Staged, {
|
||||||
sessionID,
|
sessionID,
|
||||||
@@ -102,8 +93,6 @@ describe("SessionProjector", () => {
|
|||||||
expect(
|
expect(
|
||||||
(yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all()).map((row) => row.id),
|
(yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all()).map((row) => row.id),
|
||||||
).toEqual([boundary])
|
).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()
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -259,7 +248,6 @@ describe("SessionProjector", () => {
|
|||||||
messageID: SessionMessage.ID.create(),
|
messageID: SessionMessage.ID.create(),
|
||||||
timestamp: created,
|
timestamp: created,
|
||||||
text: "synthetic context",
|
text: "synthetic context",
|
||||||
metadata: { source: "projector-test" },
|
|
||||||
})
|
})
|
||||||
yield* events.publish(SessionEvent.Shell.Started, {
|
yield* events.publish(SessionEvent.Shell.Started, {
|
||||||
sessionID,
|
sessionID,
|
||||||
@@ -330,10 +318,6 @@ describe("SessionProjector", () => {
|
|||||||
"shell",
|
"shell",
|
||||||
"compaction",
|
"compaction",
|
||||||
])
|
])
|
||||||
expect(messages.find((message) => message.type === "synthetic")).toMatchObject({
|
|
||||||
text: "synthetic context",
|
|
||||||
metadata: { source: "projector-test" },
|
|
||||||
})
|
|
||||||
expect(messages.find((message) => message.type === "shell")).toMatchObject({
|
expect(messages.find((message) => message.type === "shell")).toMatchObject({
|
||||||
output: "/project",
|
output: "/project",
|
||||||
time: { completed: DateTime.makeUnsafe(1) },
|
time: { completed: DateTime.makeUnsafe(1) },
|
||||||
|
|||||||
@@ -245,11 +245,7 @@ describe("SessionV2.prompt", () => {
|
|||||||
const session = yield* SessionV2.Service
|
const session = yield* SessionV2.Service
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
const publicEvents = (input: { sessionID: SessionV2.ID; after?: number }) =>
|
const fiber = yield* session.events({ sessionID }).pipe(Stream.take(4), Stream.runCollect, Effect.forkScoped)
|
||||||
session
|
|
||||||
.log({ ...input, follow: true })
|
|
||||||
.pipe(Stream.filter((item): item is SessionEvent.DurableEvent => !EventV2.isCaughtUp(item)))
|
|
||||||
const fiber = yield* publicEvents({ sessionID }).pipe(Stream.take(4), Stream.runCollect, Effect.forkScoped)
|
|
||||||
yield* Effect.yieldNow
|
yield* Effect.yieldNow
|
||||||
|
|
||||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
|
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
|
||||||
@@ -257,7 +253,7 @@ describe("SessionV2.prompt", () => {
|
|||||||
yield* SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER)
|
yield* SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER)
|
||||||
const streamed = Array.from(yield* Fiber.join(fiber))
|
const streamed = Array.from(yield* Fiber.join(fiber))
|
||||||
|
|
||||||
expect(streamed.map((event): [number | undefined, string] => [event.durable?.seq, event.type])).toEqual([
|
expect(streamed.map((event) => [event.durable?.seq, event.type])).toEqual([
|
||||||
[0, "session.next.prompt.admitted"],
|
[0, "session.next.prompt.admitted"],
|
||||||
[1, "session.next.prompt.admitted"],
|
[1, "session.next.prompt.admitted"],
|
||||||
[2, "session.next.prompted"],
|
[2, "session.next.prompted"],
|
||||||
@@ -265,8 +261,10 @@ describe("SessionV2.prompt", () => {
|
|||||||
])
|
])
|
||||||
expect(
|
expect(
|
||||||
Array.from(
|
Array.from(
|
||||||
yield* publicEvents({ sessionID, after: streamed[0]!.durable?.seq }).pipe(Stream.take(1), Stream.runCollect),
|
yield* session
|
||||||
).map((event): [number | undefined, string] => [event.durable?.seq, event.type]),
|
.events({ sessionID, after: streamed[0]!.durable?.seq })
|
||||||
|
.pipe(Stream.take(1), Stream.runCollect),
|
||||||
|
).map((event) => [event.durable?.seq, event.type]),
|
||||||
).toEqual([[1, "session.next.prompt.admitted"]])
|
).toEqual([[1, "session.next.prompt.admitted"]])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ const model = (api: Api, variants: ModelV2.Info["variants"] = []) =>
|
|||||||
api: { id: ModelV2.ID.make("api-test-model"), ...api },
|
api: { id: ModelV2.ID.make("api-test-model"), ...api },
|
||||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||||
request: {
|
request: {
|
||||||
settings: {},
|
|
||||||
headers: { "x-test": "header" },
|
headers: { "x-test": "header" },
|
||||||
body: { apiKey: "secret", custom_extension: { enabled: true } },
|
body: { apiKey: "secret", custom_extension: { enabled: true } },
|
||||||
},
|
},
|
||||||
@@ -84,7 +83,7 @@ describe("SessionRunnerModel", () => {
|
|||||||
url: "https://compatible.example/v1",
|
url: "https://compatible.example/v1",
|
||||||
settings: { apiKey: "settings-secret", compatibility: "strict" },
|
settings: { apiKey: "settings-secret", compatibility: "strict" },
|
||||||
}),
|
}),
|
||||||
request: { settings: {}, headers: {}, body: {} },
|
request: { headers: {}, body: {} },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const request = LLM.request({ model: resolved, prompt: "Hello" })
|
const request = LLM.request({ model: resolved, prompt: "Hello" })
|
||||||
@@ -101,17 +100,17 @@ describe("SessionRunnerModel", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("overlays selected OpenAI Session variant settings and bodies", () =>
|
it.effect("overlays selected OpenAI Session variant bodies", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const catalog = model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }, [
|
const catalog = model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }, [
|
||||||
{
|
{
|
||||||
id: ModelV2.VariantID.make("high"),
|
id: ModelV2.VariantID.make("high"),
|
||||||
settings: { reasoningEffort: "high" },
|
|
||||||
headers: { "x-variant": "high" },
|
headers: { "x-variant": "high" },
|
||||||
body: {
|
body: {
|
||||||
store: false,
|
store: false,
|
||||||
service_tier: "priority",
|
service_tier: "priority",
|
||||||
temperature: 0.2,
|
temperature: 0.2,
|
||||||
|
reasoning: { effort: "high" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
@@ -138,9 +137,7 @@ describe("SessionRunnerModel", () => {
|
|||||||
store: false,
|
store: false,
|
||||||
service_tier: "priority",
|
service_tier: "priority",
|
||||||
temperature: 0.2,
|
temperature: 0.2,
|
||||||
})
|
reasoning: { effort: "high" },
|
||||||
expect(resolved.route.defaults.providerOptions).toEqual({
|
|
||||||
openai: { store: false, reasoningEffort: "high" },
|
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -152,7 +149,6 @@ describe("SessionRunnerModel", () => {
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
id: ModelV2.VariantID.make("high"),
|
id: ModelV2.VariantID.make("high"),
|
||||||
settings: {},
|
|
||||||
headers: {},
|
headers: {},
|
||||||
body: { store: false, reasoning_effort: "high" },
|
body: { store: false, reasoning_effort: "high" },
|
||||||
},
|
},
|
||||||
@@ -209,14 +205,13 @@ describe("SessionRunnerModel", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("overlays selected Anthropic Session variant settings", () =>
|
it.effect("overlays selected Anthropic Session variant bodies", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const catalog = model({ type: "aisdk", package: "@ai-sdk/anthropic", url: "https://anthropic.example/v1" }, [
|
const catalog = model({ type: "aisdk", package: "@ai-sdk/anthropic", url: "https://anthropic.example/v1" }, [
|
||||||
{
|
{
|
||||||
id: ModelV2.VariantID.make("high"),
|
id: ModelV2.VariantID.make("high"),
|
||||||
settings: { thinking: { type: "enabled", budgetTokens: 12000 } },
|
|
||||||
headers: {},
|
headers: {},
|
||||||
body: {},
|
body: { thinking: { type: "enabled", budget_tokens: 12000 } },
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
const session = SessionV2.Info.make({
|
const session = SessionV2.Info.make({
|
||||||
@@ -234,9 +229,7 @@ describe("SessionRunnerModel", () => {
|
|||||||
|
|
||||||
expect(resolved.route.defaults.http?.body).toEqual({
|
expect(resolved.route.defaults.http?.body).toEqual({
|
||||||
custom_extension: { enabled: true },
|
custom_extension: { enabled: true },
|
||||||
})
|
thinking: { type: "enabled", budget_tokens: 12000 },
|
||||||
expect(resolved.route.defaults.providerOptions).toEqual({
|
|
||||||
anthropic: { thinking: { type: "enabled", budgetTokens: 12000 } },
|
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -259,7 +252,7 @@ describe("SessionRunnerModel", () => {
|
|||||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||||
ModelV2.Info.make({
|
ModelV2.Info.make({
|
||||||
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
|
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
|
||||||
request: { settings: {}, headers: {}, body: {} },
|
request: { headers: {}, body: {} },
|
||||||
}),
|
}),
|
||||||
Credential.Key.make({ type: "key", key: "secret" }),
|
Credential.Key.make({ type: "key", key: "secret" }),
|
||||||
)
|
)
|
||||||
@@ -282,7 +275,7 @@ describe("SessionRunnerModel", () => {
|
|||||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||||
ModelV2.Info.make({
|
ModelV2.Info.make({
|
||||||
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
|
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
|
||||||
request: { settings: {}, headers: {}, body: { apiKey: "configured-secret" } },
|
request: { headers: {}, body: { apiKey: "configured-secret" } },
|
||||||
}),
|
}),
|
||||||
credential,
|
credential,
|
||||||
)
|
)
|
||||||
@@ -304,7 +297,7 @@ describe("SessionRunnerModel", () => {
|
|||||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||||
ModelV2.Info.make({
|
ModelV2.Info.make({
|
||||||
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
|
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
|
||||||
request: { settings: {}, headers: {}, body: {} },
|
request: { headers: {}, body: {} },
|
||||||
}),
|
}),
|
||||||
Credential.OAuth.make({
|
Credential.OAuth.make({
|
||||||
type: "oauth",
|
type: "oauth",
|
||||||
@@ -320,101 +313,6 @@ 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", () =>
|
it.effect("rejects catalog APIs without a native route", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const failure = yield* SessionRunnerModel.fromCatalogModel(
|
const failure = yield* SessionRunnerModel.fromCatalogModel(
|
||||||
|
|||||||
@@ -31,8 +31,7 @@ import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
|||||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||||
import { Location } from "@opencode-ai/core/location"
|
import { Location } from "@opencode-ai/core/location"
|
||||||
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 { SystemContext } from "@opencode-ai/core/system-context"
|
import { SystemContext } from "@opencode-ai/core/system-context"
|
||||||
import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
|
import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
|
||||||
import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
|
import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
|
||||||
@@ -72,9 +71,8 @@ const model = OpenAIChat.route
|
|||||||
generation: { maxTokens: 20, temperature: 0 },
|
generation: { maxTokens: 20, temperature: 0 },
|
||||||
})
|
})
|
||||||
.model({ id: "gpt-4o-mini" })
|
.model({ id: "gpt-4o-mini" })
|
||||||
const models = SessionRunnerModel.layerWith(() => Effect.succeed(SessionRunnerModel.resolved(model)))
|
const models = SessionRunnerModel.layerWith(() => Effect.succeed(model))
|
||||||
const systemContext = Layer.mock(SystemContextBuiltIns.Service, { load: () => Effect.succeed(SystemContext.empty) })
|
const systemContext = AppNodeBuilder.build(SystemContextRegistry.node)
|
||||||
const instructionContext = Layer.mock(InstructionContext.Service, { load: () => Effect.succeed(SystemContext.empty) })
|
|
||||||
const skillGuidance = Layer.mock(SkillGuidance.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 referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
|
||||||
const mcpGuidance = Layer.mock(McpGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
|
const mcpGuidance = Layer.mock(McpGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
|
||||||
@@ -83,8 +81,7 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
|
|||||||
[Snapshot.node, Snapshot.noopLayer],
|
[Snapshot.node, Snapshot.noopLayer],
|
||||||
[LayerNodePlatform.llmClient, client],
|
[LayerNodePlatform.llmClient, client],
|
||||||
[SessionRunnerModel.node, models],
|
[SessionRunnerModel.node, models],
|
||||||
[SystemContextBuiltIns.node, systemContext],
|
[SystemContextRegistry.node, systemContext],
|
||||||
[InstructionContext.node, instructionContext],
|
|
||||||
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
|
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
|
||||||
[SkillGuidance.node, skillGuidance],
|
[SkillGuidance.node, skillGuidance],
|
||||||
[ReferenceGuidance.node, referenceGuidance],
|
[ReferenceGuidance.node, referenceGuidance],
|
||||||
@@ -119,8 +116,7 @@ const it = testEffect(
|
|||||||
AgentV2.node,
|
AgentV2.node,
|
||||||
ToolRegistry.node,
|
ToolRegistry.node,
|
||||||
SessionRunnerModel.node,
|
SessionRunnerModel.node,
|
||||||
SystemContextBuiltIns.node,
|
SystemContextRegistry.node,
|
||||||
InstructionContext.node,
|
|
||||||
SkillGuidance.node,
|
SkillGuidance.node,
|
||||||
ReferenceGuidance.node,
|
ReferenceGuidance.node,
|
||||||
Config.node,
|
Config.node,
|
||||||
@@ -133,8 +129,7 @@ const it = testEffect(
|
|||||||
[PermissionV2.node, permission],
|
[PermissionV2.node, permission],
|
||||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||||
[SessionRunnerModel.node, models],
|
[SessionRunnerModel.node, models],
|
||||||
[SystemContextBuiltIns.node, systemContext],
|
[SystemContextRegistry.node, systemContext],
|
||||||
[InstructionContext.node, instructionContext],
|
|
||||||
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
|
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
|
||||||
[SkillGuidance.node, skillGuidance],
|
[SkillGuidance.node, skillGuidance],
|
||||||
[ReferenceGuidance.node, referenceGuidance],
|
[ReferenceGuidance.node, referenceGuidance],
|
||||||
|
|||||||
@@ -27,10 +27,8 @@ const capture = () => {
|
|||||||
return event
|
return event
|
||||||
}),
|
}),
|
||||||
subscribe: () => Stream.empty,
|
subscribe: () => Stream.empty,
|
||||||
live: () => Stream.empty,
|
all: () => Stream.empty,
|
||||||
log: () => Stream.empty,
|
durable: () => Stream.empty,
|
||||||
changes: () => Stream.empty,
|
|
||||||
sequences: () => Effect.succeed(new Map()),
|
|
||||||
listen: () => Effect.succeed(Effect.void),
|
listen: () => Effect.succeed(Effect.void),
|
||||||
project: () => Effect.void,
|
project: () => Effect.void,
|
||||||
replay: () => Effect.void,
|
replay: () => Effect.void,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { QuestionV2 } from "@opencode-ai/core/question"
|
|||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
import { SessionV2 } from "@opencode-ai/core/session"
|
import { SessionV2 } from "@opencode-ai/core/session"
|
||||||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||||
|
import { ContextSnapshotDecodeError } from "@opencode-ai/core/session/error"
|
||||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||||
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
|
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
|
||||||
import { SessionTitle } from "@opencode-ai/core/session/title"
|
import { SessionTitle } from "@opencode-ai/core/session/title"
|
||||||
@@ -45,16 +46,14 @@ import { Config } from "@opencode-ai/core/config"
|
|||||||
import { ConfigCompaction } from "@opencode-ai/core/config/compaction"
|
import { ConfigCompaction } from "@opencode-ai/core/config/compaction"
|
||||||
import { Tool } from "@opencode-ai/core/tool/tool"
|
import { Tool } from "@opencode-ai/core/tool/tool"
|
||||||
import {
|
import {
|
||||||
SessionContextCheckpointTable,
|
SessionContextEpochTable,
|
||||||
SessionInputTable,
|
SessionInputTable,
|
||||||
SessionMessageTable,
|
SessionMessageTable,
|
||||||
SessionTable,
|
SessionTable,
|
||||||
} from "@opencode-ai/core/session/sql"
|
} from "@opencode-ai/core/session/sql"
|
||||||
import { SessionContextEntry } from "@opencode-ai/core/session/context-entry"
|
|
||||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||||
import { SystemContext } from "@opencode-ai/core/system-context"
|
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 { SkillGuidance } from "@opencode-ai/core/skill/guidance"
|
import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
|
||||||
import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
|
import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
|
||||||
import { McpGuidance } from "@opencode-ai/core/mcp/guidance"
|
import { McpGuidance } from "@opencode-ai/core/mcp/guidance"
|
||||||
@@ -162,14 +161,7 @@ const echoNode = makeLocationNode({ name: "test/session-runner-tools", layer: ec
|
|||||||
let modelResolveHook = Effect.void
|
let modelResolveHook = Effect.void
|
||||||
let currentModel = model
|
let currentModel = model
|
||||||
const models = SessionRunnerModel.layerWith((session) =>
|
const models = SessionRunnerModel.layerWith((session) =>
|
||||||
modelResolveHook.pipe(
|
modelResolveHook.pipe(Effect.as(session.model?.id === "replacement" ? replacementModel : currentModel)),
|
||||||
Effect.as(
|
|
||||||
SessionRunnerModel.resolved(
|
|
||||||
session.model?.id === "replacement" ? replacementModel : currentModel,
|
|
||||||
session.model?.variant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
const systemContextKey = SystemContext.Key.make("test/context")
|
const systemContextKey = SystemContext.Key.make("test/context")
|
||||||
let systemBaseline = "Initial context"
|
let systemBaseline = "Initial context"
|
||||||
@@ -177,36 +169,41 @@ let systemRemoved = false
|
|||||||
let systemUnavailable = false
|
let systemUnavailable = false
|
||||||
let systemLoadHook = Effect.void
|
let systemLoadHook = Effect.void
|
||||||
const skillBaselines = new Map<AgentV2.ID, string>()
|
const skillBaselines = new Map<AgentV2.ID, string>()
|
||||||
const systemContext = Layer.mock(SystemContextBuiltIns.Service, {
|
const systemContext = Layer.effectDiscard(
|
||||||
load: () =>
|
SystemContextRegistry.Service.pipe(
|
||||||
Effect.sync(() =>
|
Effect.flatMap((registry) =>
|
||||||
SystemContext.combine(
|
registry.register({
|
||||||
systemRemoved
|
key: systemContextKey,
|
||||||
? []
|
load: Effect.sync(() =>
|
||||||
: [
|
SystemContext.combine(
|
||||||
SystemContext.make({
|
systemRemoved
|
||||||
key: systemContextKey,
|
? []
|
||||||
description: "Test context",
|
: [
|
||||||
codec: Schema.toCodecJson(Schema.String),
|
SystemContext.make({
|
||||||
load: systemLoadHook.pipe(
|
key: systemContextKey,
|
||||||
Effect.andThen(Effect.sync(() => (systemUnavailable ? SystemContext.unavailable : systemBaseline))),
|
codec: Schema.toCodecJson(Schema.String),
|
||||||
),
|
load: systemLoadHook.pipe(
|
||||||
baseline: String,
|
Effect.andThen(
|
||||||
update: (_previous, current) => current,
|
Effect.sync(() => (systemUnavailable ? SystemContext.unavailable : systemBaseline)),
|
||||||
removed: () => "System context source removed: test/context",
|
),
|
||||||
}),
|
),
|
||||||
],
|
baseline: String,
|
||||||
),
|
update: (_previous, current) => current,
|
||||||
|
removed: () => "System context source removed: test/context",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
})
|
),
|
||||||
const instructionContext = Layer.mock(InstructionContext.Service, { load: () => Effect.succeed(SystemContext.empty) })
|
).pipe(Layer.provideMerge(AppNodeBuilder.build(SystemContextRegistry.node)))
|
||||||
const skillGuidance = Layer.mock(SkillGuidance.Service, {
|
const skillGuidance = Layer.mock(SkillGuidance.Service, {
|
||||||
load: (agent) =>
|
load: (agent) =>
|
||||||
Effect.succeed(
|
Effect.succeed(
|
||||||
skillBaselines.has(agent.id)
|
skillBaselines.has(agent.id)
|
||||||
? SystemContext.make({
|
? SystemContext.make({
|
||||||
key: SystemContext.Key.make("test/skill-guidance"),
|
key: SystemContext.Key.make("test/skill-guidance"),
|
||||||
description: "Test skill guidance",
|
|
||||||
codec: Schema.toCodecJson(Schema.String),
|
codec: Schema.toCodecJson(Schema.String),
|
||||||
load: Effect.succeed(skillBaselines.get(agent.id)!),
|
load: Effect.succeed(skillBaselines.get(agent.id)!),
|
||||||
baseline: String,
|
baseline: String,
|
||||||
@@ -239,8 +236,7 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
|
|||||||
[Snapshot.node, Snapshot.noopLayer],
|
[Snapshot.node, Snapshot.noopLayer],
|
||||||
[LayerNodePlatform.llmClient, client],
|
[LayerNodePlatform.llmClient, client],
|
||||||
[SessionRunnerModel.node, models],
|
[SessionRunnerModel.node, models],
|
||||||
[SystemContextBuiltIns.node, systemContext],
|
[SystemContextRegistry.node, systemContext],
|
||||||
[InstructionContext.node, instructionContext],
|
|
||||||
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
|
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
|
||||||
[SkillGuidance.node, skillGuidance],
|
[SkillGuidance.node, skillGuidance],
|
||||||
[ReferenceGuidance.node, referenceGuidance],
|
[ReferenceGuidance.node, referenceGuidance],
|
||||||
@@ -278,9 +274,7 @@ const it = testEffect(
|
|||||||
ToolRegistry.toolsNode,
|
ToolRegistry.toolsNode,
|
||||||
echoNode,
|
echoNode,
|
||||||
SessionRunnerModel.node,
|
SessionRunnerModel.node,
|
||||||
SystemContextBuiltIns.node,
|
SystemContextRegistry.node,
|
||||||
InstructionContext.node,
|
|
||||||
SessionContextEntry.node,
|
|
||||||
SkillGuidance.node,
|
SkillGuidance.node,
|
||||||
ReferenceGuidance.node,
|
ReferenceGuidance.node,
|
||||||
Config.node,
|
Config.node,
|
||||||
@@ -293,8 +287,7 @@ const it = testEffect(
|
|||||||
[LayerNodePlatform.llmClient, client],
|
[LayerNodePlatform.llmClient, client],
|
||||||
[PermissionV2.node, permission],
|
[PermissionV2.node, permission],
|
||||||
[SessionRunnerModel.node, models],
|
[SessionRunnerModel.node, models],
|
||||||
[SystemContextBuiltIns.node, systemContext],
|
[SystemContextRegistry.node, systemContext],
|
||||||
[InstructionContext.node, instructionContext],
|
|
||||||
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
|
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
|
||||||
[SkillGuidance.node, skillGuidance],
|
[SkillGuidance.node, skillGuidance],
|
||||||
[ReferenceGuidance.node, referenceGuidance],
|
[ReferenceGuidance.node, referenceGuidance],
|
||||||
@@ -706,8 +699,8 @@ describe("SessionRunnerLLM", () => {
|
|||||||
expect(
|
expect(
|
||||||
yield* db
|
yield* db
|
||||||
.select()
|
.select()
|
||||||
.from(SessionContextCheckpointTable)
|
.from(SessionContextEpochTable)
|
||||||
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
|
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||||
.get(),
|
.get(),
|
||||||
).toBeUndefined()
|
).toBeUndefined()
|
||||||
|
|
||||||
@@ -738,8 +731,8 @@ describe("SessionRunnerLLM", () => {
|
|||||||
expect(
|
expect(
|
||||||
yield* db
|
yield* db
|
||||||
.select()
|
.select()
|
||||||
.from(SessionContextCheckpointTable)
|
.from(SessionContextEpochTable)
|
||||||
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
|
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||||
.get(),
|
.get(),
|
||||||
).toBeUndefined()
|
).toBeUndefined()
|
||||||
|
|
||||||
@@ -752,36 +745,7 @@ describe("SessionRunnerLLM", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("copies the context checkpoint to a fork", () =>
|
it.effect("fails gracefully when a stored context snapshot cannot be decoded", () =>
|
||||||
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* () {
|
Effect.gen(function* () {
|
||||||
yield* setup
|
yield* setup
|
||||||
const session = yield* SessionV2.Service
|
const session = yield* SessionV2.Service
|
||||||
@@ -790,30 +754,19 @@ describe("SessionRunnerLLM", () => {
|
|||||||
response = []
|
response = []
|
||||||
yield* session.resume(sessionID)
|
yield* session.resume(sessionID)
|
||||||
yield* db
|
yield* db
|
||||||
.update(SessionContextCheckpointTable)
|
.update(SessionContextEpochTable)
|
||||||
.set({ snapshot: { invalid: { value: "bad" } } })
|
.set({ snapshot: { invalid: { value: "bad" } } })
|
||||||
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
|
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||||
.run()
|
.run()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
|
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
|
||||||
requests.length = 0
|
requests.length = 0
|
||||||
|
|
||||||
yield* session.resume(sessionID)
|
const exit = yield* session.resume(sessionID).pipe(Effect.exit)
|
||||||
|
|
||||||
// Comparison state was lost, so every source re-announces as new.
|
expect(Exit.isFailure(exit)).toBe(true)
|
||||||
expect(requests).toHaveLength(1)
|
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(ContextSnapshotDecodeError)
|
||||||
expect(requests[0]?.system.map((part) => part.text)).toEqual([defaultSystem, "Initial context"])
|
expect(requests).toHaveLength(0)
|
||||||
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", description: "Test context", removed: expect.any(String) },
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -834,8 +787,8 @@ describe("SessionRunnerLLM", () => {
|
|||||||
[defaultSystem, "Initial context"],
|
[defaultSystem, "Initial context"],
|
||||||
[defaultSystem, "Initial context"],
|
[defaultSystem, "Initial context"],
|
||||||
])
|
])
|
||||||
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"])
|
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.at(-1)?.content).toEqual([{ type: "text", text: "Changed context" }])
|
||||||
expect(yield* session.messages({ sessionID })).toHaveLength(3)
|
expect(yield* session.messages({ sessionID })).toHaveLength(3)
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
expect(
|
expect(
|
||||||
@@ -1096,66 +1049,14 @@ describe("SessionRunnerLLM", () => {
|
|||||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
|
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
|
||||||
yield* session.resume(sessionID)
|
yield* session.resume(sessionID)
|
||||||
|
|
||||||
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"])
|
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"])
|
||||||
expect(requests[1]?.messages.at(1)?.content).toEqual([
|
expect(requests[1]?.messages.at(-1)?.content).toEqual([
|
||||||
{ type: "text", text: "System context source removed: test/context" },
|
{ type: "text", text: "System context source removed: test/context" },
|
||||||
])
|
])
|
||||||
expect(yield* session.messages({ sessionID })).toHaveLength(3)
|
expect(yield* session.messages({ sessionID })).toHaveLength(3)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("renders API context entries through the belief lifecycle", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
yield* setup
|
|
||||||
const session = yield* SessionV2.Service
|
|
||||||
const contextEntries = yield* SessionContextEntry.Service
|
|
||||||
yield* contextEntries.put({ sessionID, key: "deploy-target", value: "production" })
|
|
||||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
|
|
||||||
|
|
||||||
requests.length = 0
|
|
||||||
response = []
|
|
||||||
yield* session.resume(sessionID)
|
|
||||||
|
|
||||||
// String values render verbatim inside the tagged block at baseline.
|
|
||||||
expect(requests[0]?.system.map((part) => part.text)).toEqual([
|
|
||||||
defaultSystem,
|
|
||||||
["Initial context", "", '<context key="deploy-target">', "production", "</context>"].join("\n"),
|
|
||||||
])
|
|
||||||
|
|
||||||
// Non-string JSON pretty-prints; the change narrates as a System update.
|
|
||||||
yield* contextEntries.put({ sessionID, key: "deploy-target", value: { region: "us-east-1" } })
|
|
||||||
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", "system", "user"])
|
|
||||||
expect(requests[1]?.messages.at(1)?.content).toEqual([
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: [
|
|
||||||
'The context under "deploy-target" changed and supersedes the previous value:',
|
|
||||||
'<context key="deploy-target">',
|
|
||||||
"{",
|
|
||||||
' "region": "us-east-1"',
|
|
||||||
"}",
|
|
||||||
"</context>",
|
|
||||||
].join("\n"),
|
|
||||||
},
|
|
||||||
])
|
|
||||||
expect(yield* contextEntries.list(sessionID)).toEqual([{ key: "deploy-target", value: { region: "us-east-1" } }])
|
|
||||||
|
|
||||||
// Deleting the row announces removal through the stored removal text.
|
|
||||||
yield* contextEntries.remove({ sessionID, key: "deploy-target" })
|
|
||||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false })
|
|
||||||
yield* session.resume(sessionID)
|
|
||||||
|
|
||||||
expect(requests[2]?.messages.map((message) => message.role)).toEqual(["user", "system", "user", "system", "user"])
|
|
||||||
expect(requests[2]?.messages.at(-2)?.content).toEqual([
|
|
||||||
{ type: "text", text: 'The context under "deploy-target" no longer applies. Disregard it.' },
|
|
||||||
])
|
|
||||||
expect(yield* contextEntries.list(sessionID)).toEqual([])
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("keeps the baseline and chronological System updates after a model switch", () =>
|
it.effect("keeps the baseline and chronological System updates after a model switch", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* setup
|
yield* setup
|
||||||
@@ -1184,15 +1085,15 @@ describe("SessionRunnerLLM", () => {
|
|||||||
[defaultSystem, "Initial context"],
|
[defaultSystem, "Initial context"],
|
||||||
[defaultSystem, "Initial context"],
|
[defaultSystem, "Initial context"],
|
||||||
])
|
])
|
||||||
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"])
|
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"])
|
||||||
expect(requests[2]?.messages.filter((message) => message.role === "system")).toHaveLength(2)
|
expect(requests[2]?.messages.filter((message) => message.role === "system")).toHaveLength(2)
|
||||||
expect((yield* session.context(sessionID)).map((message) => message.type)).toEqual([
|
expect((yield* session.context(sessionID)).map((message) => message.type)).toEqual([
|
||||||
"user",
|
"user",
|
||||||
"system",
|
|
||||||
"user",
|
"user",
|
||||||
|
"system",
|
||||||
"model-switched",
|
"model-switched",
|
||||||
"system",
|
|
||||||
"user",
|
"user",
|
||||||
|
"system",
|
||||||
])
|
])
|
||||||
yield* replaySessionProjection(sessionID)
|
yield* replaySessionProjection(sessionID)
|
||||||
expect(yield* session.messages({ sessionID })).toHaveLength(6)
|
expect(yield* session.messages({ sessionID })).toHaveLength(6)
|
||||||
@@ -1460,7 +1361,7 @@ describe("SessionRunnerLLM", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("rebaselines after compaction from the last-applied belief while unobservable", () =>
|
it.effect("preserves effective System updates while compaction rebaseline is blocked", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* setup
|
yield* setup
|
||||||
const session = yield* SessionV2.Service
|
const session = yield* SessionV2.Service
|
||||||
@@ -1492,9 +1393,8 @@ describe("SessionRunnerLLM", () => {
|
|||||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false })
|
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false })
|
||||||
yield* session.resume(sessionID)
|
yield* session.resume(sessionID)
|
||||||
|
|
||||||
// The rebaseline proceeds while the source is unobservable, restating the model's belief.
|
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([defaultSystem, "Initial context"])
|
||||||
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([defaultSystem, "Changed context"])
|
expect(systemTexts(requests.at(-1)!)).toContain("Changed context")
|
||||||
expect(systemTexts(requests.at(-1)!)).not.toContain("Changed context")
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user