mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 17:08:21 -04:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b5f689b4e | |||
| 15227b57a9 | |||
| 674d08f9be | |||
| 02f012f5d1 | |||
| bea6e1499d | |||
| 50a1fe49bc | |||
| 6380919fda | |||
| d972aa9d83 | |||
| d544ab4d91 | |||
| ce228bfd7c | |||
| eabf85aea2 | |||
| 2f4a688790 | |||
| 4a90ffedfb | |||
| 95cf5039be |
@@ -0,0 +1,36 @@
|
|||||||
|
export default {
|
||||||
|
id: "Orchestrator",
|
||||||
|
setup: async (ctx) => {
|
||||||
|
await ctx.agent.transform((agents) => {
|
||||||
|
agents.update("orchestrator", (agent) => {
|
||||||
|
agent.description = "Coordinates work by delegating implementation tasks to the minion subagent."
|
||||||
|
agent.mode = "primary"
|
||||||
|
agent.system = [
|
||||||
|
"You are Orchestrator, the primary coordinating agent for this repository. You do meta work only: you coordinate, brief, and synthesize — you do not perform the work itself.",
|
||||||
|
"Delegate ALL actual work to the minion subagent — implementation, exploration, discovery, searching the codebase, reading files to understand a problem, and even trivial one-line edits. Task size is never a reason to do it yourself, and there is no 'final integration' exception.",
|
||||||
|
"You are not hard-banned from tools, but direct tool use is reserved for coordination overhead: a quick peek to phrase a better brief, a fast read-only check to verify a minion's reported result, or answering a question about coordination state. If a tool call is producing the answer or the artifact the user asked for, that call belongs to a minion, not you.",
|
||||||
|
"Exploration is work. If the user asks how something works or where something lives, delegate the investigation to a minion rather than exploring yourself.",
|
||||||
|
"Always start minion subagents in the background. Even if you have nothing else to coordinate right now, the user may assign you new work while a Minion runs, and you must stay free to receive it. Never poll; you will be notified when they finish.",
|
||||||
|
"Give each minion a clear, self-contained brief: the goal, constraints, expected output, and any files or context already known from the user or previous minion reports.",
|
||||||
|
"Synthesize minion results, decide next steps, and report back concisely.",
|
||||||
|
].join("\n")
|
||||||
|
})
|
||||||
|
|
||||||
|
agents.update("minion", (agent) => {
|
||||||
|
agent.description = "Subagent that executes focused tasks delegated by Orchestrator."
|
||||||
|
agent.mode = "subagent"
|
||||||
|
agent.model = { providerID: "opencode", id: "glm-5.2" }
|
||||||
|
agent.system = [
|
||||||
|
"You are minion, a focused execution subagent for this repository.",
|
||||||
|
"Complete the specific task delegated to you by Orchestrator using the available tools.",
|
||||||
|
"Inspect the codebase before making assumptions, make targeted changes when requested, and verify your work when feasible.",
|
||||||
|
"Follow the repository's AGENTS.md conventions: respect the style guide, run `bun typecheck` from the affected package directory after code changes, never run tests from the repo root, and do not modify packages/opencode unless the task explicitly says V1 work.",
|
||||||
|
"If the task is ambiguous or you hit a blocker, stop and report your findings instead of guessing.",
|
||||||
|
"Keep your final response concise: summarize what you did, list important files changed or findings, and call out blockers or verification gaps.",
|
||||||
|
"Do not delegate to other subagents; execute the assigned work yourself.",
|
||||||
|
].join("\n")
|
||||||
|
agent.permissions.push({ action: "subagent", resource: "*", effect: "deny" })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
export default {
|
|
||||||
id: "sample-agent-plugin",
|
|
||||||
setup: async (ctx) => {
|
|
||||||
await ctx.agent.transform((agents) => {
|
|
||||||
agents.update("sample-plugin-agent", (agent) => {
|
|
||||||
agent.description = "Example primary agent registered by .opencode/plugins/sample-agent.ts"
|
|
||||||
agent.mode = "primary"
|
|
||||||
agent.system = [
|
|
||||||
"You are the sample plugin agent for this repository.",
|
|
||||||
"Use this agent to verify that local plugin auto-discovery can add agents.",
|
|
||||||
"Keep responses concise and explain which plugin registered you when asked.",
|
|
||||||
].join("\n")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -164,23 +164,36 @@ const Endpoint4_9 = (raw: RawClient["server.session"]) => (input: Endpoint4_9Inp
|
|||||||
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_10Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
|
type Endpoint4_10Request = Parameters<RawClient["server.session"]["session.synthetic"]>[0]
|
||||||
type Endpoint4_10Input = { readonly sessionID: Endpoint4_10Request["params"]["sessionID"] }
|
type Endpoint4_10Input = {
|
||||||
|
readonly sessionID: Endpoint4_10Request["params"]["sessionID"]
|
||||||
|
readonly text: Endpoint4_10Request["payload"]["text"]
|
||||||
|
readonly description?: Endpoint4_10Request["payload"]["description"]
|
||||||
|
readonly metadata?: Endpoint4_10Request["payload"]["metadata"]
|
||||||
|
}
|
||||||
const Endpoint4_10 = (raw: RawClient["server.session"]) => (input: Endpoint4_10Input) =>
|
const Endpoint4_10 = (raw: RawClient["server.session"]) => (input: Endpoint4_10Input) =>
|
||||||
raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
raw["session.synthetic"]({
|
||||||
|
params: { sessionID: input["sessionID"] },
|
||||||
|
payload: { text: input["text"], description: input["description"], metadata: input["metadata"] },
|
||||||
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
type Endpoint4_11Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
|
type Endpoint4_11Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
|
||||||
type Endpoint4_11Input = { readonly sessionID: Endpoint4_11Request["params"]["sessionID"] }
|
type Endpoint4_11Input = { readonly sessionID: Endpoint4_11Request["params"]["sessionID"] }
|
||||||
const Endpoint4_11 = (raw: RawClient["server.session"]) => (input: Endpoint4_11Input) =>
|
const Endpoint4_11 = (raw: RawClient["server.session"]) => (input: Endpoint4_11Input) =>
|
||||||
|
raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
type Endpoint4_12Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
|
||||||
|
type Endpoint4_12Input = { readonly sessionID: Endpoint4_12Request["params"]["sessionID"] }
|
||||||
|
const Endpoint4_12 = (raw: RawClient["server.session"]) => (input: Endpoint4_12Input) =>
|
||||||
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
type Endpoint4_12Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
|
type Endpoint4_13Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
|
||||||
type Endpoint4_12Input = {
|
type Endpoint4_13Input = {
|
||||||
readonly sessionID: Endpoint4_12Request["params"]["sessionID"]
|
readonly sessionID: Endpoint4_13Request["params"]["sessionID"]
|
||||||
readonly messageID: Endpoint4_12Request["payload"]["messageID"]
|
readonly messageID: Endpoint4_13Request["payload"]["messageID"]
|
||||||
readonly files?: Endpoint4_12Request["payload"]["files"]
|
readonly files?: Endpoint4_13Request["payload"]["files"]
|
||||||
}
|
}
|
||||||
const Endpoint4_12 = (raw: RawClient["server.session"]) => (input: Endpoint4_12Input) =>
|
const Endpoint4_13 = (raw: RawClient["server.session"]) => (input: Endpoint4_13Input) =>
|
||||||
raw["session.revert.stage"]({
|
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"] },
|
||||||
@@ -189,42 +202,42 @@ const Endpoint4_12 = (raw: RawClient["server.session"]) => (input: Endpoint4_12I
|
|||||||
Effect.map((value) => value.data),
|
Effect.map((value) => value.data),
|
||||||
)
|
)
|
||||||
|
|
||||||
type Endpoint4_13Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
|
type Endpoint4_14Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
|
||||||
type Endpoint4_13Input = { readonly sessionID: Endpoint4_13Request["params"]["sessionID"] }
|
|
||||||
const Endpoint4_13 = (raw: RawClient["server.session"]) => (input: Endpoint4_13Input) =>
|
|
||||||
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
|
||||||
|
|
||||||
type Endpoint4_14Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
|
|
||||||
type Endpoint4_14Input = { readonly sessionID: Endpoint4_14Request["params"]["sessionID"] }
|
type Endpoint4_14Input = { readonly sessionID: Endpoint4_14Request["params"]["sessionID"] }
|
||||||
const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14Input) =>
|
const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14Input) =>
|
||||||
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
type Endpoint4_15Request = Parameters<RawClient["server.session"]["session.context"]>[0]
|
type Endpoint4_15Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
|
||||||
type Endpoint4_15Input = { readonly sessionID: Endpoint4_15Request["params"]["sessionID"] }
|
type Endpoint4_15Input = { readonly sessionID: Endpoint4_15Request["params"]["sessionID"] }
|
||||||
const Endpoint4_15 = (raw: RawClient["server.session"]) => (input: Endpoint4_15Input) =>
|
const Endpoint4_15 = (raw: RawClient["server.session"]) => (input: Endpoint4_15Input) =>
|
||||||
|
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
type Endpoint4_16Request = Parameters<RawClient["server.session"]["session.context"]>[0]
|
||||||
|
type Endpoint4_16Input = { readonly sessionID: Endpoint4_16Request["params"]["sessionID"] }
|
||||||
|
const Endpoint4_16 = (raw: RawClient["server.session"]) => (input: Endpoint4_16Input) =>
|
||||||
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
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_16Request = Parameters<RawClient["server.session"]["session.history"]>[0]
|
type Endpoint4_17Request = Parameters<RawClient["server.session"]["session.history"]>[0]
|
||||||
type Endpoint4_16Input = {
|
type Endpoint4_17Input = {
|
||||||
readonly sessionID: Endpoint4_16Request["params"]["sessionID"]
|
readonly sessionID: Endpoint4_17Request["params"]["sessionID"]
|
||||||
readonly limit?: Endpoint4_16Request["query"]["limit"]
|
readonly limit?: Endpoint4_17Request["query"]["limit"]
|
||||||
readonly after?: Endpoint4_16Request["query"]["after"]
|
readonly after?: Endpoint4_17Request["query"]["after"]
|
||||||
}
|
}
|
||||||
const Endpoint4_16 = (raw: RawClient["server.session"]) => (input: Endpoint4_16Input) =>
|
const Endpoint4_17 = (raw: RawClient["server.session"]) => (input: Endpoint4_17Input) =>
|
||||||
raw["session.history"]({
|
raw["session.history"]({
|
||||||
params: { sessionID: input["sessionID"] },
|
params: { sessionID: input["sessionID"] },
|
||||||
query: { limit: input["limit"], after: input["after"] },
|
query: { limit: input["limit"], after: input["after"] },
|
||||||
}).pipe(Effect.mapError(mapClientError))
|
}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
type Endpoint4_17Request = Parameters<RawClient["server.session"]["session.events"]>[0]
|
type Endpoint4_18Request = Parameters<RawClient["server.session"]["session.events"]>[0]
|
||||||
type Endpoint4_17Input = {
|
type Endpoint4_18Input = {
|
||||||
readonly sessionID: Endpoint4_17Request["params"]["sessionID"]
|
readonly sessionID: Endpoint4_18Request["params"]["sessionID"]
|
||||||
readonly after?: Endpoint4_17Request["query"]["after"]
|
readonly after?: Endpoint4_18Request["query"]["after"]
|
||||||
}
|
}
|
||||||
const Endpoint4_17 = (raw: RawClient["server.session"]) => (input: Endpoint4_17Input) =>
|
const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18Input) =>
|
||||||
Stream.unwrap(
|
Stream.unwrap(
|
||||||
raw["session.events"]({ params: { sessionID: input["sessionID"] }, query: { after: input["after"] } }).pipe(
|
raw["session.events"]({ params: { sessionID: input["sessionID"] }, query: { after: input["after"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
@@ -232,22 +245,22 @@ const Endpoint4_17 = (raw: RawClient["server.session"]) => (input: Endpoint4_17I
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
type Endpoint4_18Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
||||||
type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] }
|
|
||||||
const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18Input) =>
|
|
||||||
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
|
||||||
|
|
||||||
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.background"]>[0]
|
|
||||||
type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] }
|
type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] }
|
||||||
const Endpoint4_19 = (raw: RawClient["server.session"]) => (input: Endpoint4_19Input) =>
|
const Endpoint4_19 = (raw: RawClient["server.session"]) => (input: Endpoint4_19Input) =>
|
||||||
|
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
|
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.background"]>[0]
|
||||||
|
type Endpoint4_20Input = { readonly sessionID: Endpoint4_20Request["params"]["sessionID"] }
|
||||||
|
const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) =>
|
||||||
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
type Endpoint4_21Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||||
type Endpoint4_20Input = {
|
type Endpoint4_21Input = {
|
||||||
readonly sessionID: Endpoint4_20Request["params"]["sessionID"]
|
readonly sessionID: Endpoint4_21Request["params"]["sessionID"]
|
||||||
readonly messageID: Endpoint4_20Request["params"]["messageID"]
|
readonly messageID: Endpoint4_21Request["params"]["messageID"]
|
||||||
}
|
}
|
||||||
const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) =>
|
const Endpoint4_21 = (raw: RawClient["server.session"]) => (input: Endpoint4_21Input) =>
|
||||||
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
|
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),
|
||||||
@@ -264,17 +277,18 @@ const adaptGroup4 = (raw: RawClient["server.session"]) => ({
|
|||||||
rename: Endpoint4_7(raw),
|
rename: Endpoint4_7(raw),
|
||||||
prompt: Endpoint4_8(raw),
|
prompt: Endpoint4_8(raw),
|
||||||
skill: Endpoint4_9(raw),
|
skill: Endpoint4_9(raw),
|
||||||
compact: Endpoint4_10(raw),
|
synthetic: Endpoint4_10(raw),
|
||||||
wait: Endpoint4_11(raw),
|
compact: Endpoint4_11(raw),
|
||||||
revertStage: Endpoint4_12(raw),
|
wait: Endpoint4_12(raw),
|
||||||
revertClear: Endpoint4_13(raw),
|
revertStage: Endpoint4_13(raw),
|
||||||
revertCommit: Endpoint4_14(raw),
|
revertClear: Endpoint4_14(raw),
|
||||||
context: Endpoint4_15(raw),
|
revertCommit: Endpoint4_15(raw),
|
||||||
history: Endpoint4_16(raw),
|
context: Endpoint4_16(raw),
|
||||||
events: Endpoint4_17(raw),
|
history: Endpoint4_17(raw),
|
||||||
interrupt: Endpoint4_18(raw),
|
events: Endpoint4_18(raw),
|
||||||
background: Endpoint4_19(raw),
|
interrupt: Endpoint4_19(raw),
|
||||||
message: Endpoint4_20(raw),
|
background: Endpoint4_20(raw),
|
||||||
|
message: Endpoint4_21(raw),
|
||||||
})
|
})
|
||||||
|
|
||||||
type Endpoint5_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0]
|
type Endpoint5_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0]
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import type {
|
|||||||
SessionPromptOutput,
|
SessionPromptOutput,
|
||||||
SessionSkillInput,
|
SessionSkillInput,
|
||||||
SessionSkillOutput,
|
SessionSkillOutput,
|
||||||
|
SessionSyntheticInput,
|
||||||
|
SessionSyntheticOutput,
|
||||||
SessionCompactInput,
|
SessionCompactInput,
|
||||||
SessionCompactOutput,
|
SessionCompactOutput,
|
||||||
SessionWaitInput,
|
SessionWaitInput,
|
||||||
@@ -461,6 +463,18 @@ 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>(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -584,6 +584,27 @@ export type SessionSkillInput = {
|
|||||||
|
|
||||||
export type SessionSkillOutput = void
|
export type SessionSkillOutput = void
|
||||||
|
|
||||||
|
export type SessionSyntheticInput = {
|
||||||
|
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||||
|
readonly text: {
|
||||||
|
readonly text: string
|
||||||
|
readonly description?: string | null
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
}["text"]
|
||||||
|
readonly description?: {
|
||||||
|
readonly text: string
|
||||||
|
readonly description?: string | null
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
}["description"]
|
||||||
|
readonly metadata?: {
|
||||||
|
readonly text: string
|
||||||
|
readonly description?: string | null
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
}["metadata"]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionSyntheticOutput = void
|
||||||
|
|
||||||
export type SessionCompactInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
export type SessionCompactInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||||
|
|
||||||
export type SessionCompactOutput = void
|
export type SessionCompactOutput = void
|
||||||
@@ -934,6 +955,7 @@ export type SessionHistoryOutput = {
|
|||||||
readonly messageID: string
|
readonly messageID: string
|
||||||
readonly text: string
|
readonly text: string
|
||||||
readonly description?: string
|
readonly description?: string
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
@@ -1428,6 +1450,7 @@ export type SessionEventsOutput =
|
|||||||
readonly messageID: string
|
readonly messageID: string
|
||||||
readonly text: string
|
readonly text: string
|
||||||
readonly description?: string
|
readonly description?: string
|
||||||
|
readonly metadata?: { readonly [x: string]: unknown }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
@@ -3530,6 +3553,7 @@ export type EventSubscribeOutput =
|
|||||||
readonly messageID: string
|
readonly messageID: string
|
||||||
readonly text: string
|
readonly text: string
|
||||||
readonly description?: string
|
readonly description?: string
|
||||||
|
readonly metadata?: { readonly [x: string]: unknown }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
File to save in: ~/.local/share/opencode/worktree/012780/location-layer-tiers/packages/core/src/effect/
|
|
||||||
@@ -38,6 +38,7 @@ import { SkillGuidance } from "./skill/guidance"
|
|||||||
import { Snapshot } from "./snapshot"
|
import { Snapshot } from "./snapshot"
|
||||||
import { SystemContextBuiltIns } from "./system-context/builtins"
|
import { SystemContextBuiltIns } from "./system-context/builtins"
|
||||||
import { SystemContextRegistry } from "./system-context/registry"
|
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"
|
||||||
@@ -85,6 +86,7 @@ export const locationServices = LayerNode.group([
|
|||||||
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,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ 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
|
||||||
@@ -165,6 +166,7 @@ export const node = makeLocationNode({
|
|||||||
Reference.node,
|
Reference.node,
|
||||||
SkillV2.node,
|
SkillV2.node,
|
||||||
ToolRegistry.toolsNode,
|
ToolRegistry.toolsNode,
|
||||||
|
ToolHooks.node,
|
||||||
PluginRuntime.node,
|
PluginRuntime.node,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ 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>
|
||||||
@@ -31,6 +32,7 @@ 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({
|
||||||
@@ -247,6 +249,47 @@ 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) =>
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
export * as OpenAICodex from "./openai-codex"
|
||||||
|
|
||||||
|
// TEMPORARY SEAM (#34765): plugins have no hook into LLM route construction, so
|
||||||
|
// codex routing lives in SessionRunnerModel.fromCatalogModel and catalog filtering
|
||||||
|
// in OpenAIPlugin, sharing this module. Once the native provider packages land
|
||||||
|
// (#33689/#33925/#34462) this should collapse into the native OpenAI provider.
|
||||||
|
// The eligibility rules mirror V1's CodexAuthPlugin allowlist; models.dev has no
|
||||||
|
// plan-eligibility data for OpenAI today, but models other vendors' subscriptions
|
||||||
|
// as dedicated providers (e.g. zai-coding-plan) - a future openai-chatgpt-plan
|
||||||
|
// provider entry could replace the hardcoded rules with catalog data.
|
||||||
|
|
||||||
|
/** ChatGPT-plan requests must target the codex backend instead of the public API. */
|
||||||
|
export const baseURL = "https://chatgpt.com/backend-api/codex"
|
||||||
|
|
||||||
|
const methodIDs: readonly string[] = ["chatgpt-browser", "chatgpt-headless"]
|
||||||
|
|
||||||
|
/** Structural credential shape so both core and plugin-facing credential types fit. */
|
||||||
|
type CredentialLike = {
|
||||||
|
readonly type: string
|
||||||
|
readonly methodID?: string
|
||||||
|
readonly metadata?: Record<string, unknown> | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isChatGPT = (credential: CredentialLike | undefined) =>
|
||||||
|
credential?.type === "oauth" && credential.methodID !== undefined && methodIDs.includes(credential.methodID)
|
||||||
|
|
||||||
|
export const accountID = (credential: CredentialLike | undefined) => {
|
||||||
|
if (!isChatGPT(credential)) return undefined
|
||||||
|
const value = credential?.metadata?.accountID
|
||||||
|
return typeof value === "string" ? value : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowed = new Set(["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini"])
|
||||||
|
const disallowed = new Set(["gpt-5.5-pro"])
|
||||||
|
|
||||||
|
/** Which API model ids a ChatGPT subscription may call through the codex backend. */
|
||||||
|
export const eligible = (apiID: string) => {
|
||||||
|
if (allowed.has(apiID)) return true
|
||||||
|
if (disallowed.has(apiID)) return false
|
||||||
|
const match = apiID.match(/^gpt-(\d+\.\d+)/)
|
||||||
|
return match ? Number.parseFloat(match[1]) > 5.4 : false
|
||||||
|
}
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
import { createServer } from "node:http"
|
import { 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 } from "effect"
|
import { Deferred, Effect, Semaphore, Stream } 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"
|
||||||
@@ -154,6 +156,18 @@ 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)
|
||||||
@@ -170,8 +184,30 @@ 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
|
||||||
|
|||||||
@@ -110,8 +110,13 @@ export const providerLayer = providerLayerWithCell(defaultCell)
|
|||||||
|
|
||||||
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
|
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
|
||||||
|
|
||||||
export const providerNode = makeGlobalNode({
|
// Raw layer replacements are compiled without dependencies, so cell-scoped
|
||||||
name: "plugin-runtime-provider",
|
// provider replacements must go through this node to keep their deps wired.
|
||||||
layer: providerLayer,
|
export const providerNodeWithCell = (cell: Cell) =>
|
||||||
deps: [node, SessionV2.node, Job.node, LocationServiceMap.node],
|
makeGlobalNode({
|
||||||
})
|
name: "plugin-runtime-provider",
|
||||||
|
layer: providerLayerWithCell(cell),
|
||||||
|
deps: [node, SessionV2.node, Job.node, LocationServiceMap.node],
|
||||||
|
})
|
||||||
|
|
||||||
|
export const providerNode = providerNodeWithCell(defaultCell)
|
||||||
|
|||||||
@@ -199,6 +199,7 @@ 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: {
|
||||||
@@ -547,6 +548,7 @@ 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.resume(input.sessionID).pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
|
yield* execution.resume(input.sessionID).pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
export * as SessionInstructions from "./instructions"
|
||||||
|
|
||||||
|
import { relative } from "path"
|
||||||
|
import { Context, DateTime, Effect, Layer, Option, Ref, Schema } from "effect"
|
||||||
|
import { makeLocationNode } from "../effect/app-node"
|
||||||
|
import { EventV2 } from "../event"
|
||||||
|
import { FSUtil } from "../fs-util"
|
||||||
|
import { Location } from "../location"
|
||||||
|
import { SessionEvent } from "./event"
|
||||||
|
import { MessageDecodeError } from "./error"
|
||||||
|
import { SessionMessage } from "./message"
|
||||||
|
import { SessionSchema } from "./schema"
|
||||||
|
import { SessionStore } from "./store"
|
||||||
|
|
||||||
|
const InjectedMetadata = Schema.Struct({
|
||||||
|
instruction: Schema.Struct({ paths: Schema.Array(Schema.String) }),
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly load: (input: {
|
||||||
|
readonly sessionID: SessionSchema.ID
|
||||||
|
readonly paths: ReadonlyArray<string>
|
||||||
|
}) => Effect.Effect<void, MessageDecodeError | FSUtil.Error>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionInstructions") {}
|
||||||
|
|
||||||
|
const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
const fs = yield* FSUtil.Service
|
||||||
|
const store = yield* SessionStore.Service
|
||||||
|
const location = yield* Location.Service
|
||||||
|
// Resolved once for the Location layer; the synthetic text and dedup ledger keep
|
||||||
|
// absolute paths, but the human-facing description shows paths relative to the project
|
||||||
|
// root so opening a subdirectory still describes paths from the project root.
|
||||||
|
const root = FSUtil.resolve(location.project.directory)
|
||||||
|
// Same-turn parallel reads settle concurrently, so an in-memory claim guards each
|
||||||
|
// Session/path pair before any filesystem work. The durable history check below covers
|
||||||
|
// paths injected in earlier turns after this Location layer was reopened.
|
||||||
|
const injected = yield* Ref.make<Map<SessionSchema.ID, Set<string>>>(new Map())
|
||||||
|
|
||||||
|
const load = Effect.fn("SessionInstructions.load")(function* (input: {
|
||||||
|
readonly sessionID: SessionSchema.ID
|
||||||
|
readonly paths: ReadonlyArray<string>
|
||||||
|
}) {
|
||||||
|
const claimed = yield* Ref.modify(injected, (map) => {
|
||||||
|
const existing = map.get(input.sessionID) ?? new Set<string>()
|
||||||
|
const newlyClaimed = input.paths.filter((path) => !existing.has(path))
|
||||||
|
if (newlyClaimed.length === 0) return [newlyClaimed, map]
|
||||||
|
const next = new Map(map)
|
||||||
|
next.set(input.sessionID, new Set([...existing, ...newlyClaimed]))
|
||||||
|
return [newlyClaimed, next]
|
||||||
|
})
|
||||||
|
if (claimed.length === 0) return
|
||||||
|
const alreadyInjected = yield* previouslyInjected(store, input.sessionID)
|
||||||
|
const toInject = claimed.filter((path) => !alreadyInjected.has(path))
|
||||||
|
if (toInject.length === 0) return
|
||||||
|
const files = yield* Effect.forEach(
|
||||||
|
toInject,
|
||||||
|
(path) =>
|
||||||
|
fs.readFileStringSafe(path).pipe(
|
||||||
|
Effect.map((content) => (content === undefined ? undefined : { path, content })),
|
||||||
|
),
|
||||||
|
{ concurrency: "unbounded" },
|
||||||
|
)
|
||||||
|
const readable = files.filter((file): file is { path: string; content: string } => file !== undefined)
|
||||||
|
if (readable.length === 0) return
|
||||||
|
// Publish directly rather than through SessionV2.synthetic: a Location-scoped layer
|
||||||
|
// cannot depend on SessionV2 (it routes through LocationServiceMap, forming a type
|
||||||
|
// cycle with this node). The durable publish is what makes the synthetic visible on
|
||||||
|
// the next projected history reload. The dedup ledger lives on the synthetic message
|
||||||
|
// metadata so it survives across Location layer restarts.
|
||||||
|
yield* events.publish(SessionEvent.Synthetic, {
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
messageID: SessionMessage.ID.create(),
|
||||||
|
timestamp: yield* DateTime.now,
|
||||||
|
text: readable.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n"),
|
||||||
|
description: `Loaded ${readable.map((file) => describePath(root, file.path)).join(", ")}`,
|
||||||
|
metadata: { instruction: { paths: readable.map((file) => file.path) } },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ load })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
function previouslyInjected(store: SessionStore.Interface, sessionID: SessionSchema.ID) {
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
const history = yield* store.context(sessionID)
|
||||||
|
return new Set(
|
||||||
|
history
|
||||||
|
.filter((message): message is SessionMessage.Synthetic => message.type === "synthetic")
|
||||||
|
.flatMap(
|
||||||
|
(message) =>
|
||||||
|
Option.getOrUndefined(Schema.decodeUnknownOption(InjectedMetadata)(message.metadata))?.instruction.paths ??
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paths are normally discovered under the project root, so the description shows them
|
||||||
|
// relative to it. A directly-loaded path outside the root falls back to its absolute form
|
||||||
|
// rather than emitting `../..` chains.
|
||||||
|
function describePath(root: string, path: string) {
|
||||||
|
return FSUtil.contains(root, path) ? relative(root, path) : path
|
||||||
|
}
|
||||||
|
|
||||||
|
export const node = makeLocationNode({
|
||||||
|
name: "session-instructions",
|
||||||
|
layer,
|
||||||
|
deps: [EventV2.node, FSUtil.node, Location.node, SessionStore.node],
|
||||||
|
})
|
||||||
@@ -154,6 +154,7 @@ 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,6 +12,7 @@ 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"
|
||||||
|
|
||||||
@@ -140,6 +141,21 @@ 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) })
|
||||||
|
|||||||
@@ -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, metadata: message.metadata })]
|
return [Message.make({ id: message.id, role: "user", content: message.text })]
|
||||||
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":
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
export * as ToolHooks from "./hooks"
|
||||||
|
|
||||||
|
import { makeLocationNode } from "../effect/app-node"
|
||||||
|
import { AgentV2 } from "../agent"
|
||||||
|
import { SessionMessage } from "../session/message"
|
||||||
|
import { SessionSchema } from "../session/schema"
|
||||||
|
import { State } from "../state"
|
||||||
|
import { Context, Effect, Layer, Scope } from "effect"
|
||||||
|
import type { ToolOutput, ToolResultValue } from "@opencode-ai/llm"
|
||||||
|
|
||||||
|
export interface BeforeEvent {
|
||||||
|
readonly tool: string
|
||||||
|
readonly sessionID: SessionSchema.ID
|
||||||
|
readonly agent: AgentV2.ID
|
||||||
|
readonly assistantMessageID: SessionMessage.ID
|
||||||
|
readonly toolCallID: string
|
||||||
|
input: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AfterEvent {
|
||||||
|
readonly tool: string
|
||||||
|
readonly sessionID: SessionSchema.ID
|
||||||
|
readonly agent: AgentV2.ID
|
||||||
|
readonly assistantMessageID: SessionMessage.ID
|
||||||
|
readonly toolCallID: string
|
||||||
|
readonly input: unknown
|
||||||
|
result: ToolResultValue
|
||||||
|
output?: ToolOutput
|
||||||
|
outputPaths?: ReadonlyArray<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly hook: {
|
||||||
|
readonly before: (
|
||||||
|
callback: (event: BeforeEvent) => Effect.Effect<void> | void,
|
||||||
|
) => Effect.Effect<State.Registration, never, Scope.Scope>
|
||||||
|
readonly after: (
|
||||||
|
callback: (event: AfterEvent) => Effect.Effect<void> | void,
|
||||||
|
) => Effect.Effect<State.Registration, never, Scope.Scope>
|
||||||
|
}
|
||||||
|
readonly runBefore: (event: BeforeEvent) => Effect.Effect<BeforeEvent>
|
||||||
|
readonly runAfter: (event: AfterEvent) => Effect.Effect<AfterEvent>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolHooks") {}
|
||||||
|
|
||||||
|
const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
let beforeHooks: ((event: BeforeEvent) => Effect.Effect<void> | void)[] = []
|
||||||
|
let afterHooks: ((event: AfterEvent) => Effect.Effect<void> | void)[] = []
|
||||||
|
|
||||||
|
const register = <Event>(
|
||||||
|
hooks: () => ((event: Event) => Effect.Effect<void> | void)[],
|
||||||
|
update: (hooks: ((event: Event) => Effect.Effect<void> | void)[]) => void,
|
||||||
|
) =>
|
||||||
|
Effect.fn("ToolHooks.hook")(function* (callback: (event: Event) => Effect.Effect<void> | void) {
|
||||||
|
const scope = yield* Scope.Scope
|
||||||
|
let active = true
|
||||||
|
update([...hooks(), callback])
|
||||||
|
const dispose = Effect.sync(() => {
|
||||||
|
if (!active) return
|
||||||
|
active = false
|
||||||
|
update(hooks().filter((item) => item !== callback))
|
||||||
|
})
|
||||||
|
yield* Scope.addFinalizer(scope, dispose)
|
||||||
|
return { dispose }
|
||||||
|
})
|
||||||
|
|
||||||
|
const run = Effect.fnUntraced(function* <Event>(
|
||||||
|
hooks: readonly ((event: Event) => Effect.Effect<void> | void)[],
|
||||||
|
event: Event,
|
||||||
|
) {
|
||||||
|
for (const hook of hooks) {
|
||||||
|
const result = hook(event)
|
||||||
|
if (Effect.isEffect(result)) yield* result
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({
|
||||||
|
hook: {
|
||||||
|
before: register(() => beforeHooks, (next) => (beforeHooks = next)),
|
||||||
|
after: register(() => afterHooks, (next) => (afterHooks = next)),
|
||||||
|
},
|
||||||
|
runBefore: (event) => run(beforeHooks, event),
|
||||||
|
runAfter: (event) => run(afterHooks, event),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const node = makeLocationNode({ service: Service, layer, deps: [] })
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
export * as ReadTool from "./read"
|
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"
|
||||||
@@ -14,6 +18,7 @@ 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,
|
||||||
@@ -34,6 +39,9 @@ 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({
|
||||||
@@ -77,12 +85,33 @@ const layer = Layer.effectDiscard(
|
|||||||
agent: context.agent,
|
agent: context.agent,
|
||||||
source,
|
source,
|
||||||
})
|
})
|
||||||
if (type === "directory")
|
const content =
|
||||||
return yield* reader.list(absolute, { offset: input.offset, limit: input.limit })
|
type === "directory"
|
||||||
const content = yield* reader.read(absolute, resource, {
|
? yield* reader.list(absolute, { offset: input.offset, limit: input.limit })
|
||||||
offset: input.offset,
|
: yield* reader.read(absolute, resource, {
|
||||||
limit: input.limit,
|
offset: input.offset,
|
||||||
})
|
limit: input.limit,
|
||||||
|
})
|
||||||
|
// After a successful read, discover nearby AGENTS.md walking up to the Location
|
||||||
|
// root exclusive and inject them as durable synthetic instructions. For a
|
||||||
|
// directory listing the walk starts at the directory itself (so its own AGENTS.md
|
||||||
|
// is discovered); for a file it starts at the file's dirname. External reads are
|
||||||
|
// skipped, and discovery failures never fail the read.
|
||||||
|
yield* Effect.gen(function* () {
|
||||||
|
if (target.externalDirectory !== undefined) return
|
||||||
|
const resolved = FSUtil.resolve(target.canonical)
|
||||||
|
const root = FSUtil.resolve(location.directory)
|
||||||
|
// up() searches its stop directory, so the Location-root AGENTS.md (already
|
||||||
|
// supplied by the core/instructions baseline) is dropped by the dirname filter.
|
||||||
|
const discovered = yield* fs.up({
|
||||||
|
targets: [FILENAME],
|
||||||
|
start: type === "directory" ? resolved : dirname(resolved),
|
||||||
|
stop: root,
|
||||||
|
})
|
||||||
|
const candidates = discovered.map(FSUtil.resolve).filter((file) => dirname(file) !== root)
|
||||||
|
if (candidates.length === 0) return
|
||||||
|
yield* sessionInstructions.load({ sessionID: context.sessionID, paths: candidates })
|
||||||
|
}).pipe(Effect.catch(() => Effect.void), Effect.catchDefect(() => Effect.void))
|
||||||
if ("encoding" in content && content.encoding === "base64" && SUPPORTED_IMAGE_MIMES.has(content.mime)) {
|
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" })
|
||||||
@@ -113,5 +142,14 @@ const layer = Layer.effectDiscard(
|
|||||||
export const node = makeLocationNode({
|
export const node = makeLocationNode({
|
||||||
name: "tool/read",
|
name: "tool/read",
|
||||||
layer,
|
layer,
|
||||||
deps: [ToolRegistry.node, ReadToolFileSystem.node, LocationMutation.node, Image.node, PermissionV2.node],
|
deps: [
|
||||||
|
ToolRegistry.node,
|
||||||
|
ReadToolFileSystem.node,
|
||||||
|
LocationMutation.node,
|
||||||
|
Image.node,
|
||||||
|
PermissionV2.node,
|
||||||
|
SessionInstructions.node,
|
||||||
|
FSUtil.node,
|
||||||
|
Location.node,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { ToolOutputStore } from "../tool-output-store"
|
|||||||
import { Wildcard } from "../util/wildcard"
|
import { 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 = {
|
||||||
@@ -47,6 +48,7 @@ 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 }>>()
|
||||||
|
|
||||||
@@ -61,7 +63,17 @@ 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}` } }
|
||||||
const pending = yield* settle(registration.tool, input.call, {
|
// Hooks fire only for hosted/local tools; provider-executed calls never reach settleWith.
|
||||||
|
const beforeEvent: ToolHooks.BeforeEvent = {
|
||||||
|
tool: input.call.name,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
agent: input.agent,
|
||||||
|
assistantMessageID: input.assistantMessageID,
|
||||||
|
toolCallID: input.call.id,
|
||||||
|
input: input.call.input,
|
||||||
|
}
|
||||||
|
yield* toolHooks.runBefore(beforeEvent)
|
||||||
|
const pending = yield* settle(registration.tool, { ...input.call, input: beforeEvent.input }, {
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
agent: input.agent,
|
agent: input.agent,
|
||||||
assistantMessageID: input.assistantMessageID,
|
assistantMessageID: input.assistantMessageID,
|
||||||
@@ -72,15 +84,38 @@ const registryLayer = Layer.effect(
|
|||||||
Effect.succeed({ result: { type: "error" as const, value: failure.message } }),
|
Effect.succeed({ result: { type: "error" as const, value: failure.message } }),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if ("result" in pending) return pending
|
let settlement: Settlement
|
||||||
const output = pending.output
|
if ("result" in pending) {
|
||||||
const bounded = yield* resources.bound({ sessionID: input.sessionID, toolCallID: input.call.id, output })
|
settlement = pending
|
||||||
const result = ToolOutput.toResultValue(bounded.output)
|
} else {
|
||||||
if (result.type === "error")
|
const bounded = yield* resources.bound({ sessionID: input.sessionID, toolCallID: input.call.id, output: pending.output })
|
||||||
return bounded.outputPaths.length > 0 ? { result, outputPaths: bounded.outputPaths } : { result }
|
const result = ToolOutput.toResultValue(bounded.output)
|
||||||
return bounded.outputPaths.length > 0
|
settlement =
|
||||||
? { result, output: bounded.output, outputPaths: bounded.outputPaths }
|
result.type === "error"
|
||||||
: { result, output: bounded.output }
|
? bounded.outputPaths.length > 0
|
||||||
|
? { result, outputPaths: bounded.outputPaths }
|
||||||
|
: { result }
|
||||||
|
: bounded.outputPaths.length > 0
|
||||||
|
? { result, output: bounded.output, outputPaths: bounded.outputPaths }
|
||||||
|
: { result, output: bounded.output }
|
||||||
|
}
|
||||||
|
const afterEvent: ToolHooks.AfterEvent = {
|
||||||
|
tool: input.call.name,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
agent: input.agent,
|
||||||
|
assistantMessageID: input.assistantMessageID,
|
||||||
|
toolCallID: input.call.id,
|
||||||
|
input: beforeEvent.input,
|
||||||
|
result: settlement.result,
|
||||||
|
output: settlement.output,
|
||||||
|
outputPaths: settlement.outputPaths,
|
||||||
|
}
|
||||||
|
yield* toolHooks.runAfter(afterEvent)
|
||||||
|
return {
|
||||||
|
result: afterEvent.result,
|
||||||
|
...(afterEvent.output !== undefined ? { output: afterEvent.output } : {}),
|
||||||
|
...(afterEvent.outputPaths !== undefined ? { outputPaths: afterEvent.outputPaths } : {}),
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return Service.of({
|
return Service.of({
|
||||||
@@ -143,11 +178,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],
|
deps: [ToolOutputStore.node, ToolHooks.node],
|
||||||
})
|
})
|
||||||
|
|
||||||
export const toolsNode = makeLocationNode({
|
export const toolsNode = makeLocationNode({
|
||||||
service: Tools.Service,
|
service: Tools.Service,
|
||||||
layer,
|
layer,
|
||||||
deps: [ToolOutputStore.node],
|
deps: [ToolOutputStore.node, ToolHooks.node],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,8 +18,12 @@ export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
|
|||||||
export const MAX_TIMEOUT_MS = 10 * 60 * 1_000
|
export const MAX_TIMEOUT_MS = 10 * 60 * 1_000
|
||||||
export const MAX_CAPTURE_BYTES = 1024 * 1024
|
export const MAX_CAPTURE_BYTES = 1024 * 1024
|
||||||
|
|
||||||
const BACKGROUND_STARTED =
|
// Rendered in clients and persisted in the transcript, so it must stay accurate
|
||||||
"The command has not completed; it is now running in the background."
|
// after the command later finishes; do not claim the command is currently running.
|
||||||
|
// The model-facing behavioral instruction lives in modelOutput instead.
|
||||||
|
const BACKGROUND_STARTED = "The command was moved to the background."
|
||||||
|
const BACKGROUND_INSTRUCTION =
|
||||||
|
"You will be notified automatically when the command finishes. DO NOT sleep, poll, or proactively check on its progress."
|
||||||
|
|
||||||
export const Input = Schema.Struct({
|
export const Input = Schema.Struct({
|
||||||
command: Schema.String.annotate({ description: "Shell command string to execute" }),
|
command: Schema.String.annotate({ description: "Shell command string to execute" }),
|
||||||
@@ -54,10 +58,11 @@ const Output = Schema.Struct({
|
|||||||
type Output = typeof Output.Type
|
type Output = typeof Output.Type
|
||||||
|
|
||||||
const modelOutput = (output: Output): string | undefined => {
|
const modelOutput = (output: Output): string | undefined => {
|
||||||
if (output.status === "running") return undefined
|
|
||||||
const warnings = output.warnings?.length
|
const warnings = output.warnings?.length
|
||||||
? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}`
|
? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}`
|
||||||
: ""
|
: ""
|
||||||
|
if (output.status === "running")
|
||||||
|
return `${warnings.trimStart()}${warnings ? "\n\n" : ""}${BACKGROUND_INSTRUCTION}`
|
||||||
if (output.timeout) return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command timed out before completion.`
|
if (output.timeout) return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command timed out before completion.`
|
||||||
return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command exited with code ${output.exit}.`
|
return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command exited with code ${output.exit}.`
|
||||||
}
|
}
|
||||||
@@ -124,8 +129,11 @@ export const Plugin = {
|
|||||||
: state === "error"
|
: state === "error"
|
||||||
? (result.info!.error ?? "Command failed")
|
? (result.info!.error ?? "Command failed")
|
||||||
: "Command cancelled"
|
: "Command cancelled"
|
||||||
|
// The description makes the completion visible in clients; synthetic
|
||||||
|
// messages without one are model-facing context only.
|
||||||
return runtime.session.synthetic({
|
return runtime.session.synthetic({
|
||||||
sessionID,
|
sessionID,
|
||||||
|
description: `Background command ${state}: ${command.split("\n")[0]}`,
|
||||||
text: `<shell id="${callID}" state="${state}" command="${command}">\n${text}\n</shell>`,
|
text: `<shell id="${callID}" state="${state}" command="${command}">\n${text}\n</shell>`,
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -67,8 +67,11 @@ export const Plugin = {
|
|||||||
state: "completed" | "error" | "cancelled",
|
state: "completed" | "error" | "cancelled",
|
||||||
text: string,
|
text: string,
|
||||||
) {
|
) {
|
||||||
|
// The description makes the completion visible in clients; synthetic
|
||||||
|
// messages without one are model-facing context only.
|
||||||
yield* runtime.session.synthetic({
|
yield* runtime.session.synthetic({
|
||||||
sessionID: parentID,
|
sessionID: parentID,
|
||||||
|
description: `Subagent ${state}: ${description}`,
|
||||||
text: `<subagent id="${childID}" state="${state}" description="${description}">\n${text}\n</subagent>`,
|
text: `<subagent id="${childID}" state="${state}" description="${description}">\n${text}\n</subagent>`,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ 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"
|
||||||
@@ -102,4 +104,68 @@ describe("PluginV2", () => {
|
|||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("fires before/after tool hooks with mutable events around settlement", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const plugins = yield* PluginV2.Service
|
||||||
|
const registry = yield* ToolRegistry.Service
|
||||||
|
const executed: unknown[] = []
|
||||||
|
const seen: {
|
||||||
|
before?: unknown
|
||||||
|
after?: { input: unknown; result: unknown; output: unknown }
|
||||||
|
} = {}
|
||||||
|
|
||||||
|
const plugin = define({
|
||||||
|
id: "tool-hooks",
|
||||||
|
effect: (ctx) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* ctx.tool
|
||||||
|
.register({
|
||||||
|
echo: Tool.make({
|
||||||
|
description: "Echo",
|
||||||
|
input: Schema.Struct({ text: Schema.String }),
|
||||||
|
output: Schema.Struct({ text: Schema.String }),
|
||||||
|
execute: ({ text }) => Effect.sync(() => executed.push({ text })).pipe(Effect.as({ text })),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
|
||||||
|
yield* ctx.tool.execute
|
||||||
|
.before((event) => {
|
||||||
|
seen.before = event.input
|
||||||
|
event.input = { text: "before-mutated" }
|
||||||
|
})
|
||||||
|
.pipe(Effect.asVoid)
|
||||||
|
|
||||||
|
yield* ctx.tool.execute
|
||||||
|
.after((event) => {
|
||||||
|
seen.after = { input: event.input, result: event.result, output: event.output }
|
||||||
|
event.result = { type: "text", value: "after-mutated" }
|
||||||
|
event.output = { structured: { rewritten: true }, content: [] }
|
||||||
|
})
|
||||||
|
.pipe(Effect.asVoid)
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* plugins.add(PluginV2.ID.make(plugin.id), plugin.effect)
|
||||||
|
|
||||||
|
const materialized = yield* registry.materialize({ model: testModel })
|
||||||
|
const settlement = yield* materialized.settle({
|
||||||
|
sessionID: SessionV2.ID.make("ses_hooks"),
|
||||||
|
agent: AgentV2.ID.make("build"),
|
||||||
|
assistantMessageID: SessionMessage.ID.make("msg_hooks"),
|
||||||
|
call: { type: "tool-call", id: "call-hooks", name: "echo", input: { text: "original" } },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(seen.before).toEqual({ text: "original" })
|
||||||
|
expect(executed).toEqual([{ text: "before-mutated" }])
|
||||||
|
expect(seen.after).toEqual({
|
||||||
|
input: { text: "before-mutated" },
|
||||||
|
result: { type: "json", value: { text: "before-mutated" } },
|
||||||
|
output: { structured: { text: "before-mutated" }, content: [] },
|
||||||
|
})
|
||||||
|
expect(settlement.result).toEqual({ type: "text", value: "after-mutated" })
|
||||||
|
expect(settlement.output).toEqual({ structured: { rewritten: true }, content: [] })
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { PluginV2 } from "@opencode-ai/core/plugin"
|
|||||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
import { 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"
|
||||||
@@ -47,6 +48,7 @@ export const PluginTestLayer = AppNodeBuilder.build(
|
|||||||
PluginRuntime.node,
|
PluginRuntime.node,
|
||||||
Reference.node,
|
Reference.node,
|
||||||
SkillV2.node,
|
SkillV2.node,
|
||||||
|
ToolHooks.node,
|
||||||
ToolRegistry.toolsNode,
|
ToolRegistry.toolsNode,
|
||||||
]),
|
]),
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ 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"),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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"
|
||||||
@@ -27,6 +28,20 @@ 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}`)
|
||||||
@@ -153,6 +168,80 @@ describe("OpenAIPlugin", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("filters the OpenAI catalog to codex-eligible models under a ChatGPT connection", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const catalog = yield* Catalog.Service
|
||||||
|
const credentials = yield* Credential.Service
|
||||||
|
yield* catalog.transform((catalog) => {
|
||||||
|
const item = ProviderV2.Info.make({
|
||||||
|
...ProviderV2.Info.empty(ProviderV2.ID.openai),
|
||||||
|
api: { type: "aisdk", package: "@ai-sdk/openai" },
|
||||||
|
})
|
||||||
|
catalog.provider.update(item.id, (draft) => {
|
||||||
|
draft.api = item.api
|
||||||
|
})
|
||||||
|
catalog.model.update(item.id, ModelV2.ID.make("gpt-5.5"), (model) => {
|
||||||
|
model.cost = [{ input: 1, output: 2, cache: { read: 0.1, write: 0 } }]
|
||||||
|
})
|
||||||
|
catalog.model.update(item.id, ModelV2.ID.make("gpt-5.5-pro"), () => {})
|
||||||
|
catalog.model.update(item.id, ModelV2.ID.make("gpt-4.1"), () => {})
|
||||||
|
})
|
||||||
|
yield* credentials.create({
|
||||||
|
integrationID: Integration.ID.make("openai"),
|
||||||
|
value: Credential.OAuth.make({
|
||||||
|
type: "oauth",
|
||||||
|
methodID: Integration.MethodID.make("chatgpt-browser"),
|
||||||
|
access: "chatgpt-token",
|
||||||
|
refresh: "refresh",
|
||||||
|
expires: Date.now() + 60_000,
|
||||||
|
metadata: { accountID: "acct_123" },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
yield* addPlugin()
|
||||||
|
|
||||||
|
const eligible = required(
|
||||||
|
yield* eventually(
|
||||||
|
catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.5")),
|
||||||
|
(model) => model?.cost.length === 0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
expect(eligible.enabled).toBe(true)
|
||||||
|
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.5-pro"))).enabled).toBe(
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-4.1"))).enabled).toBe(false)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("keeps the full OpenAI catalog under an API key connection", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const catalog = yield* Catalog.Service
|
||||||
|
const credentials = yield* Credential.Service
|
||||||
|
yield* catalog.transform((catalog) => {
|
||||||
|
const item = ProviderV2.Info.make({
|
||||||
|
...ProviderV2.Info.empty(ProviderV2.ID.openai),
|
||||||
|
api: { type: "aisdk", package: "@ai-sdk/openai" },
|
||||||
|
})
|
||||||
|
catalog.provider.update(item.id, (draft) => {
|
||||||
|
draft.api = item.api
|
||||||
|
})
|
||||||
|
catalog.model.update(item.id, ModelV2.ID.make("gpt-5.5"), () => {})
|
||||||
|
catalog.model.update(item.id, ModelV2.ID.make("gpt-4.1"), () => {})
|
||||||
|
})
|
||||||
|
yield* credentials.create({
|
||||||
|
integrationID: Integration.ID.make("openai"),
|
||||||
|
value: Credential.Key.make({ type: "key", key: "sk-test" }),
|
||||||
|
})
|
||||||
|
yield* addPlugin()
|
||||||
|
// The connection refresh is asynchronous; give it time to settle before
|
||||||
|
// asserting nothing was filtered.
|
||||||
|
yield* Effect.promise(() => Bun.sleep(25))
|
||||||
|
|
||||||
|
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.5"))).enabled).toBe(true)
|
||||||
|
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-4.1"))).enabled).toBe(true)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("does not disable gpt-5-chat-latest for non-OpenAI providers", () =>
|
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
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import fs from "fs/promises"
|
||||||
|
import path from "path"
|
||||||
|
import { DateTime, Effect, Layer } from "effect"
|
||||||
|
import { Message, Model } from "@opencode-ai/llm"
|
||||||
|
import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
|
||||||
|
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||||
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
|
import { Config } from "@opencode-ai/core/config"
|
||||||
|
import { Database } from "@opencode-ai/core/database/database"
|
||||||
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
|
import { Global } from "@opencode-ai/core/global"
|
||||||
|
import { Image } from "@opencode-ai/core/image"
|
||||||
|
import { Location } from "@opencode-ai/core/location"
|
||||||
|
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||||
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
|
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||||
|
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||||
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
|
import { ReadTool } from "@opencode-ai/core/tool/read"
|
||||||
|
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
|
||||||
|
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||||
|
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||||
|
import { SessionInstructions } from "@opencode-ai/core/session/instructions"
|
||||||
|
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||||
|
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||||
|
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||||
|
import { SessionV2 } from "@opencode-ai/core/session"
|
||||||
|
import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message"
|
||||||
|
import { ToolHooks } from "@opencode-ai/core/tool/hooks"
|
||||||
|
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||||
|
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||||
|
import { tempLocationLayer } from "./fixture/location"
|
||||||
|
import { testEffect } from "./lib/effect"
|
||||||
|
import { settleTool, testModel } from "./lib/tool"
|
||||||
|
|
||||||
|
const projects = Layer.succeed(
|
||||||
|
ProjectV2.Service,
|
||||||
|
ProjectV2.Service.of({
|
||||||
|
resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
|
||||||
|
directories: () => Effect.succeed([]),
|
||||||
|
commit: () => Effect.void,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const permission = Layer.succeed(
|
||||||
|
PermissionV2.Service,
|
||||||
|
PermissionV2.Service.of({
|
||||||
|
assert: () => Effect.void,
|
||||||
|
ask: () => Effect.die("unused"),
|
||||||
|
reply: () => Effect.die("unused"),
|
||||||
|
get: () => Effect.die("unused"),
|
||||||
|
forSession: () => Effect.die("unused"),
|
||||||
|
list: () => Effect.die("unused"),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))
|
||||||
|
const imageLayer = AppNodeBuilder.build(Image.node, [[Config.node, config]])
|
||||||
|
|
||||||
|
const testLayer = AppNodeBuilder.build(
|
||||||
|
LayerNode.group([
|
||||||
|
Database.node,
|
||||||
|
EventV2.node,
|
||||||
|
SessionProjector.node,
|
||||||
|
SessionStore.node,
|
||||||
|
SessionV2.node,
|
||||||
|
Location.node,
|
||||||
|
FSUtil.node,
|
||||||
|
LocationMutation.node,
|
||||||
|
ReadToolFileSystem.node,
|
||||||
|
ReadTool.node,
|
||||||
|
ToolRegistry.node,
|
||||||
|
ToolRegistry.toolsNode,
|
||||||
|
ToolHooks.node,
|
||||||
|
SessionInstructions.node,
|
||||||
|
Global.node,
|
||||||
|
ToolOutputStore.node,
|
||||||
|
Image.node,
|
||||||
|
]),
|
||||||
|
[
|
||||||
|
[ProjectV2.node, projects],
|
||||||
|
[SessionExecution.node, SessionExecution.noopLayer],
|
||||||
|
[Location.node, tempLocationLayer],
|
||||||
|
[PermissionV2.node, permission],
|
||||||
|
[Config.node, config],
|
||||||
|
[Image.node, imageLayer],
|
||||||
|
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||||
|
],
|
||||||
|
) as unknown as Layer.Layer<unknown>
|
||||||
|
|
||||||
|
const it = testEffect(testLayer)
|
||||||
|
|
||||||
|
const identity = {
|
||||||
|
agent: AgentV2.ID.make("build"),
|
||||||
|
assistantMessageID: SessionMessage.ID.make("msg_nearby"),
|
||||||
|
}
|
||||||
|
const readCall = (sessionID: SessionV2.ID, id: string, readPath: string): ToolRegistry.ExecuteInput => ({
|
||||||
|
sessionID,
|
||||||
|
...identity,
|
||||||
|
call: { type: "tool-call", id, name: "read", input: { path: readPath } },
|
||||||
|
})
|
||||||
|
|
||||||
|
const writeAgents = (file: string, content: string) => Effect.promise(() => fs.writeFile(file, content))
|
||||||
|
const mkdir = (dir: string) => Effect.promise(() => fs.mkdir(dir, { recursive: true }))
|
||||||
|
|
||||||
|
const synthetics = (sessionID: SessionV2.ID) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const store = yield* SessionStore.Service
|
||||||
|
return (yield* store.context(sessionID)).filter((message) => message.type === "synthetic")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Seed a prior synthetic message with an instruction dedup ledger, simulating a prior turn
|
||||||
|
// after the Location layer was reopened (in-memory set empty).
|
||||||
|
const seedSynthetic = (sessionID: SessionV2.ID, paths: string[]) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
yield* events.publish(SessionEvent.Synthetic, {
|
||||||
|
sessionID,
|
||||||
|
messageID: SessionMessage.ID.create(),
|
||||||
|
timestamp: yield* DateTime.now,
|
||||||
|
text: `Instructions from: ${paths[0]}\nprior`,
|
||||||
|
description: `Loaded ${paths[0]}`,
|
||||||
|
metadata: { instruction: { paths } },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("SessionInstructions", () => {
|
||||||
|
it.effect("injects AGENTS.md files above a read, excludes the Location root, and dedups across reads", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const location = yield* Location.Service
|
||||||
|
const dir = location.directory
|
||||||
|
const rootPath = path.resolve(dir, "AGENTS.md")
|
||||||
|
const subPath = path.resolve(dir, "sub", "AGENTS.md")
|
||||||
|
const deepPath = path.resolve(dir, "sub", "deep", "AGENTS.md")
|
||||||
|
const otherPath = path.resolve(dir, "sub", "other", "AGENTS.md")
|
||||||
|
yield* mkdir(path.dirname(deepPath))
|
||||||
|
yield* mkdir(path.dirname(otherPath))
|
||||||
|
yield* writeAgents(rootPath, "root-instructions")
|
||||||
|
yield* writeAgents(subPath, "sub-instructions")
|
||||||
|
yield* writeAgents(deepPath, "deep-instructions")
|
||||||
|
yield* writeAgents(otherPath, "other-instructions")
|
||||||
|
yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "sub", "deep", "file.txt"), "file content"))
|
||||||
|
yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "sub", "other", "file2.txt"), "file content 2"))
|
||||||
|
|
||||||
|
const session = yield* SessionV2.Service
|
||||||
|
const registry = yield* ToolRegistry.Service
|
||||||
|
const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id
|
||||||
|
|
||||||
|
// A read deep under sub/ discovers deep and sub AGENTS.md, walking up to but
|
||||||
|
// excluding the Location root (already supplied by the core/instructions baseline).
|
||||||
|
yield* settleTool(registry, readCall(sessionID, "call-deep", "sub/deep/file.txt"))
|
||||||
|
|
||||||
|
const firstInjected = yield* synthetics(sessionID)
|
||||||
|
expect(firstInjected).toHaveLength(1)
|
||||||
|
expect(firstInjected[0]!.text).toBe(
|
||||||
|
`Instructions from: ${deepPath}\ndeep-instructions\n\nInstructions from: ${subPath}\nsub-instructions`,
|
||||||
|
)
|
||||||
|
expect(firstInjected[0]!.description).toBe(`Loaded ${path.relative(dir, deepPath)}, ${path.relative(dir, subPath)}`)
|
||||||
|
// The synthetic's metadata carries the durable dedup ledger.
|
||||||
|
expect(firstInjected[0]!.metadata).toEqual({ instruction: { paths: [deepPath, subPath] } })
|
||||||
|
expect(firstInjected[0]!.text).not.toContain("root-instructions")
|
||||||
|
|
||||||
|
// A sibling read under sub/other discovers only the new AGENTS.md; sub is already
|
||||||
|
// injected for this session so it is not re-emitted, and the root is still excluded.
|
||||||
|
yield* settleTool(registry, readCall(sessionID, "call-other", "sub/other/file2.txt"))
|
||||||
|
|
||||||
|
const secondInjected = yield* synthetics(sessionID)
|
||||||
|
expect(secondInjected).toHaveLength(2)
|
||||||
|
expect(secondInjected[1]!.text).toBe(`Instructions from: ${otherPath}\nother-instructions`)
|
||||||
|
expect(secondInjected[1]!.description).toBe(`Loaded ${path.relative(dir, otherPath)}`)
|
||||||
|
expect(secondInjected[1]!.metadata).toEqual({ instruction: { paths: [otherPath] } })
|
||||||
|
expect(secondInjected.some((message) => message.text.includes("root-instructions"))).toBe(false)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("does not re-inject paths already recorded in durable session history", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const location = yield* Location.Service
|
||||||
|
const dir = location.directory
|
||||||
|
const rootPath = path.resolve(dir, "AGENTS.md")
|
||||||
|
const subPath = path.resolve(dir, "sub", "AGENTS.md")
|
||||||
|
yield* mkdir(path.resolve(dir, "sub"))
|
||||||
|
yield* writeAgents(rootPath, "root-instructions")
|
||||||
|
yield* writeAgents(subPath, "sub-instructions")
|
||||||
|
yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "sub", "file.txt"), "content"))
|
||||||
|
|
||||||
|
const session = yield* SessionV2.Service
|
||||||
|
const registry = yield* ToolRegistry.Service
|
||||||
|
const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id
|
||||||
|
|
||||||
|
// Seed the durable history with a prior synthetic that already claims sub's AGENTS.md
|
||||||
|
// via the instruction metadata ledger.
|
||||||
|
yield* seedSynthetic(sessionID, [subPath])
|
||||||
|
expect((yield* synthetics(sessionID))).toHaveLength(1)
|
||||||
|
|
||||||
|
yield* settleTool(registry, readCall(sessionID, "call-sub", "sub/file.txt"))
|
||||||
|
|
||||||
|
// The durable claim on the prior synthetic prevents re-injection; no new synthetic.
|
||||||
|
expect((yield* synthetics(sessionID))).toHaveLength(1)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("discovers AGENTS.md on a directory listing, including the listed directory's own, and dedups with a later file read", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const location = yield* Location.Service
|
||||||
|
const dir = location.directory
|
||||||
|
const rootPath = path.resolve(dir, "AGENTS.md")
|
||||||
|
const pkgPath = path.resolve(dir, "packages", "foo", "AGENTS.md")
|
||||||
|
yield* mkdir(path.resolve(dir, "packages", "foo"))
|
||||||
|
yield* writeAgents(rootPath, "root-instructions")
|
||||||
|
yield* writeAgents(pkgPath, "pkg-instructions")
|
||||||
|
yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "packages", "foo", "file.txt"), "content"))
|
||||||
|
|
||||||
|
const session = yield* SessionV2.Service
|
||||||
|
const registry = yield* ToolRegistry.Service
|
||||||
|
const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id
|
||||||
|
|
||||||
|
// Listing packages/foo/ discovers its own AGENTS.md, walking up to but excluding
|
||||||
|
// the Location root (already supplied by the core/instructions baseline).
|
||||||
|
yield* settleTool(registry, readCall(sessionID, "call-list", "packages/foo"))
|
||||||
|
|
||||||
|
const firstInjected = yield* synthetics(sessionID)
|
||||||
|
expect(firstInjected).toHaveLength(1)
|
||||||
|
expect(firstInjected[0]!.text).toBe(`Instructions from: ${pkgPath}\npkg-instructions`)
|
||||||
|
expect(firstInjected[0]!.description).toBe(`Loaded ${path.relative(dir, pkgPath)}`)
|
||||||
|
expect(firstInjected[0]!.metadata).toEqual({ instruction: { paths: [pkgPath] } })
|
||||||
|
expect(firstInjected[0]!.text).not.toContain("root-instructions")
|
||||||
|
|
||||||
|
// A subsequent file read under the listed directory is a dedup: pkg's AGENTS.md is
|
||||||
|
// already injected for this session, so nothing new is emitted.
|
||||||
|
yield* settleTool(registry, readCall(sessionID, "call-file", "packages/foo/file.txt"))
|
||||||
|
|
||||||
|
expect((yield* synthetics(sessionID))).toHaveLength(1)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("listing the Location root directory injects no instructions", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const location = yield* Location.Service
|
||||||
|
const dir = location.directory
|
||||||
|
const rootPath = path.resolve(dir, "AGENTS.md")
|
||||||
|
const subPath = path.resolve(dir, "sub", "AGENTS.md")
|
||||||
|
yield* mkdir(path.resolve(dir, "sub"))
|
||||||
|
yield* writeAgents(rootPath, "root-instructions")
|
||||||
|
yield* writeAgents(subPath, "sub-instructions")
|
||||||
|
|
||||||
|
const session = yield* SessionV2.Service
|
||||||
|
const registry = yield* ToolRegistry.Service
|
||||||
|
const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id
|
||||||
|
|
||||||
|
// The walk starts and stops at the Location root: the root AGENTS.md is searched but
|
||||||
|
// dropped by the dirname filter, and up() only walks upward so nested dirs are unseen.
|
||||||
|
yield* settleTool(registry, readCall(sessionID, "call-root-list", "."))
|
||||||
|
|
||||||
|
expect((yield* synthetics(sessionID))).toHaveLength(0)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("loads instructions directly without a read", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const location = yield* Location.Service
|
||||||
|
const dir = location.directory
|
||||||
|
const subPath = path.resolve(dir, "sub", "AGENTS.md")
|
||||||
|
yield* mkdir(path.resolve(dir, "sub"))
|
||||||
|
yield* writeAgents(subPath, "sub-instructions")
|
||||||
|
|
||||||
|
const session = yield* SessionV2.Service
|
||||||
|
const sessionInstructions = yield* SessionInstructions.Service
|
||||||
|
const sessionID = (yield* session.create({ location: Location.Ref.make({ directory: dir }) })).id
|
||||||
|
|
||||||
|
yield* sessionInstructions.load({ sessionID, paths: [subPath] })
|
||||||
|
|
||||||
|
const injected = yield* synthetics(sessionID)
|
||||||
|
expect(injected).toHaveLength(1)
|
||||||
|
expect(injected[0]!.text).toBe(`Instructions from: ${subPath}\nsub-instructions`)
|
||||||
|
expect(injected[0]!.description).toBe(`Loaded ${path.relative(dir, subPath)}`)
|
||||||
|
expect(injected[0]!.metadata).toEqual({ instruction: { paths: [subPath] } })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
test("toLLMMessages does not forward synthetic metadata to the provider", () => {
|
||||||
|
const created = DateTime.makeUnsafe(0)
|
||||||
|
const model = Model.make({ id: "model", provider: "provider", route: OpenAIChat.route })
|
||||||
|
const synthetic = SessionMessage.Synthetic.make({
|
||||||
|
id: SessionMessage.ID.make("msg_synthetic"),
|
||||||
|
type: "synthetic",
|
||||||
|
sessionID: SessionV2.ID.make("ses_test"),
|
||||||
|
text: "Instructions from: /repo/sub/AGENTS.md\ncontent",
|
||||||
|
description: "Loaded /repo/sub/AGENTS.md",
|
||||||
|
metadata: { instruction: { paths: ["/repo/sub/AGENTS.md"] } },
|
||||||
|
time: { created },
|
||||||
|
})
|
||||||
|
const messages = toLLMMessages([synthetic], model)
|
||||||
|
expect(messages).toHaveLength(1)
|
||||||
|
expect(messages[0]!.role).toBe("user")
|
||||||
|
expect(messages[0]!.content).toEqual([{ type: "text", text: "Instructions from: /repo/sub/AGENTS.md\ncontent" }])
|
||||||
|
// Metadata is bookkeeping for the dedup ledger; the model must not see it.
|
||||||
|
expect(messages[0]!.metadata).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -248,6 +248,7 @@ 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,
|
||||||
@@ -318,6 +319,10 @@ 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) },
|
||||||
|
|||||||
@@ -313,6 +313,101 @@ describe("SessionRunnerModel", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("routes ChatGPT OAuth credentials to the codex backend", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||||
|
ModelV2.Info.make({
|
||||||
|
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
|
||||||
|
request: { headers: {}, body: {} },
|
||||||
|
}),
|
||||||
|
Credential.OAuth.make({
|
||||||
|
type: "oauth",
|
||||||
|
methodID: Integration.MethodID.make("chatgpt-browser"),
|
||||||
|
access: "chatgpt-token",
|
||||||
|
refresh: "refresh",
|
||||||
|
expires: Date.now() + 60_000,
|
||||||
|
metadata: { accountID: "acct_123" },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const request = LLM.request({ model: resolved, prompt: "Hello" })
|
||||||
|
const headers = yield* resolved.route.auth.apply({
|
||||||
|
request,
|
||||||
|
method: "POST",
|
||||||
|
url: "https://chatgpt.com/backend-api/codex/responses",
|
||||||
|
body: "{}",
|
||||||
|
headers: Headers.empty,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(resolved.route).toMatchObject({
|
||||||
|
id: "openai-responses",
|
||||||
|
endpoint: { baseURL: "https://chatgpt.com/backend-api/codex" },
|
||||||
|
})
|
||||||
|
expect(headers.authorization).toBe("Bearer chatgpt-token")
|
||||||
|
expect(headers["chatgpt-account-id"]).toBe("acct_123")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("routes ChatGPT OAuth credentials without an account id to the codex backend", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||||
|
ModelV2.Info.make({
|
||||||
|
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
|
||||||
|
request: { headers: {}, body: {} },
|
||||||
|
}),
|
||||||
|
Credential.OAuth.make({
|
||||||
|
type: "oauth",
|
||||||
|
methodID: Integration.MethodID.make("chatgpt-headless"),
|
||||||
|
access: "chatgpt-token",
|
||||||
|
refresh: "refresh",
|
||||||
|
expires: Date.now() + 60_000,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const request = LLM.request({ model: resolved, prompt: "Hello" })
|
||||||
|
const headers = yield* resolved.route.auth.apply({
|
||||||
|
request,
|
||||||
|
method: "POST",
|
||||||
|
url: "https://chatgpt.com/backend-api/codex/responses",
|
||||||
|
body: "{}",
|
||||||
|
headers: Headers.empty,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(resolved.route.endpoint.baseURL).toBe("https://chatgpt.com/backend-api/codex")
|
||||||
|
expect(headers.authorization).toBe("Bearer chatgpt-token")
|
||||||
|
expect(headers["chatgpt-account-id"]).toBeUndefined()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("keeps non-ChatGPT OAuth credentials on the configured endpoint", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||||
|
ModelV2.Info.make({
|
||||||
|
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
|
||||||
|
request: { headers: {}, body: {} },
|
||||||
|
}),
|
||||||
|
Credential.OAuth.make({
|
||||||
|
type: "oauth",
|
||||||
|
methodID: Integration.MethodID.make("device"),
|
||||||
|
access: "oauth-token",
|
||||||
|
refresh: "refresh",
|
||||||
|
expires: Date.now() + 60_000,
|
||||||
|
metadata: { accountID: "acct_123" },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const request = LLM.request({ model: resolved, prompt: "Hello" })
|
||||||
|
const headers = yield* resolved.route.auth.apply({
|
||||||
|
request,
|
||||||
|
method: "POST",
|
||||||
|
url: "https://openai.example/v1/responses",
|
||||||
|
body: "{}",
|
||||||
|
headers: Headers.empty,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(resolved.route.endpoint.baseURL).toBe("https://openai.example/v1")
|
||||||
|
expect(headers.authorization).toBe("Bearer oauth-token")
|
||||||
|
expect(headers["chatgpt-account-id"]).toBeUndefined()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("rejects catalog APIs without a native route", () =>
|
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(
|
||||||
|
|||||||
@@ -455,6 +455,37 @@ describe("ShellTool", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.live("notifies with a visible description when a background command completes", () =>
|
||||||
|
Effect.acquireUseRelease(
|
||||||
|
Effect.promise(() => tmpdir()),
|
||||||
|
(tmp) => {
|
||||||
|
reset()
|
||||||
|
return withSession(tmp.path, (registry) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const sessions = yield* SessionV2.Service
|
||||||
|
yield* settleTool(registry, call({ command: helloCommand, background: true }))
|
||||||
|
const awaitNotice = (remaining = 1000): Effect.Effect<SessionMessage.Message, Error> =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const notice = (yield* sessions.context(sessionID)).find((message) => message.type === "synthetic")
|
||||||
|
if (notice) return notice
|
||||||
|
if (remaining <= 0)
|
||||||
|
return yield* Effect.fail(new Error("Timed out waiting for background completion notice"))
|
||||||
|
yield* Effect.promise(() => Bun.sleep(1))
|
||||||
|
return yield* awaitNotice(remaining - 1)
|
||||||
|
})
|
||||||
|
const notice = yield* awaitNotice()
|
||||||
|
expect(notice).toMatchObject({
|
||||||
|
type: "synthetic",
|
||||||
|
description: `Background command completed: ${helloCommand}`,
|
||||||
|
text: expect.stringContaining('state="completed"'),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
it.live("backgrounds a foreground command when the session is signaled", () =>
|
it.live("backgrounds a foreground command when the session is signaled", () =>
|
||||||
Effect.acquireUseRelease(
|
Effect.acquireUseRelease(
|
||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
@@ -482,9 +513,14 @@ describe("ShellTool", () => {
|
|||||||
const structured = settled.output?.structured as Record<string, unknown> | undefined
|
const structured = settled.output?.structured as Record<string, unknown> | undefined
|
||||||
const shellID = typeof structured?.shellID === "string" ? structured.shellID : undefined
|
const shellID = typeof structured?.shellID === "string" ? structured.shellID : undefined
|
||||||
expect(settled.output?.structured).toMatchObject({ truncated: false })
|
expect(settled.output?.structured).toMatchObject({ truncated: false })
|
||||||
expect(settled.output?.content[0]).toMatchObject({
|
// content[0] is rendered by clients; the model-facing instruction stays in content[1].
|
||||||
|
expect(settled.output?.content[0]).toEqual({
|
||||||
type: "text",
|
type: "text",
|
||||||
text: expect.stringContaining("running in the background"),
|
text: "The command was moved to the background.",
|
||||||
|
})
|
||||||
|
expect(settled.output?.content[1]).toMatchObject({
|
||||||
|
type: "text",
|
||||||
|
text: expect.stringContaining("DO NOT sleep, poll"),
|
||||||
})
|
})
|
||||||
expect(shellID).toStartWith("sh_")
|
expect(shellID).toStartWith("sh_")
|
||||||
|
|
||||||
|
|||||||
@@ -279,6 +279,7 @@ describe("SubagentTool", () => {
|
|||||||
expect(synthetic).toHaveLength(1)
|
expect(synthetic).toHaveLength(1)
|
||||||
expect(synthetic[0]?.text).toContain(`<subagent id="${childID}" state="completed"`)
|
expect(synthetic[0]?.text).toContain(`<subagent id="${childID}" state="completed"`)
|
||||||
expect(synthetic[0]?.text).toContain(childText)
|
expect(synthetic[0]?.text).toContain(childText)
|
||||||
|
expect(synthetic[0]?.description).toBe("Subagent completed: background review")
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -126,74 +126,84 @@ export type Endpoint4_9Input = {
|
|||||||
export type Endpoint4_9Output = EffectValue<ReturnType<RawClient["server.session"]["session.skill"]>>
|
export type Endpoint4_9Output = EffectValue<ReturnType<RawClient["server.session"]["session.skill"]>>
|
||||||
export type SessionSkillOperation<E = never> = (input: Endpoint4_9Input) => Effect.Effect<Endpoint4_9Output, E>
|
export type SessionSkillOperation<E = never> = (input: Endpoint4_9Input) => Effect.Effect<Endpoint4_9Output, E>
|
||||||
|
|
||||||
type Endpoint4_10Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
|
type Endpoint4_10Request = Parameters<RawClient["server.session"]["session.synthetic"]>[0]
|
||||||
export type Endpoint4_10Input = { readonly sessionID: Endpoint4_10Request["params"]["sessionID"] }
|
export type Endpoint4_10Input = {
|
||||||
export type Endpoint4_10Output = EffectValue<ReturnType<RawClient["server.session"]["session.compact"]>>
|
readonly sessionID: Endpoint4_10Request["params"]["sessionID"]
|
||||||
export type SessionCompactOperation<E = never> = (input: Endpoint4_10Input) => Effect.Effect<Endpoint4_10Output, E>
|
readonly text: Endpoint4_10Request["payload"]["text"]
|
||||||
|
readonly description?: Endpoint4_10Request["payload"]["description"]
|
||||||
|
readonly metadata?: Endpoint4_10Request["payload"]["metadata"]
|
||||||
|
}
|
||||||
|
export type Endpoint4_10Output = EffectValue<ReturnType<RawClient["server.session"]["session.synthetic"]>>
|
||||||
|
export type SessionSyntheticOperation<E = never> = (input: Endpoint4_10Input) => Effect.Effect<Endpoint4_10Output, E>
|
||||||
|
|
||||||
type Endpoint4_11Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
|
type Endpoint4_11Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
|
||||||
export type Endpoint4_11Input = { readonly sessionID: Endpoint4_11Request["params"]["sessionID"] }
|
export type Endpoint4_11Input = { readonly sessionID: Endpoint4_11Request["params"]["sessionID"] }
|
||||||
export type Endpoint4_11Output = EffectValue<ReturnType<RawClient["server.session"]["session.wait"]>>
|
export type Endpoint4_11Output = EffectValue<ReturnType<RawClient["server.session"]["session.compact"]>>
|
||||||
export type SessionWaitOperation<E = never> = (input: Endpoint4_11Input) => Effect.Effect<Endpoint4_11Output, E>
|
export type SessionCompactOperation<E = never> = (input: Endpoint4_11Input) => Effect.Effect<Endpoint4_11Output, E>
|
||||||
|
|
||||||
type Endpoint4_12Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
|
type Endpoint4_12Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
|
||||||
export type Endpoint4_12Input = {
|
export type Endpoint4_12Input = { readonly sessionID: Endpoint4_12Request["params"]["sessionID"] }
|
||||||
readonly sessionID: Endpoint4_12Request["params"]["sessionID"]
|
export type Endpoint4_12Output = EffectValue<ReturnType<RawClient["server.session"]["session.wait"]>>
|
||||||
readonly messageID: Endpoint4_12Request["payload"]["messageID"]
|
export type SessionWaitOperation<E = never> = (input: Endpoint4_12Input) => Effect.Effect<Endpoint4_12Output, E>
|
||||||
readonly files?: Endpoint4_12Request["payload"]["files"]
|
|
||||||
|
type Endpoint4_13Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
|
||||||
|
export type Endpoint4_13Input = {
|
||||||
|
readonly sessionID: Endpoint4_13Request["params"]["sessionID"]
|
||||||
|
readonly messageID: Endpoint4_13Request["payload"]["messageID"]
|
||||||
|
readonly files?: Endpoint4_13Request["payload"]["files"]
|
||||||
}
|
}
|
||||||
export type Endpoint4_12Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.stage"]>>["data"]
|
export type Endpoint4_13Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.stage"]>>["data"]
|
||||||
export type SessionRevertStageOperation<E = never> = (input: Endpoint4_12Input) => Effect.Effect<Endpoint4_12Output, E>
|
export type SessionRevertStageOperation<E = never> = (input: Endpoint4_13Input) => Effect.Effect<Endpoint4_13Output, E>
|
||||||
|
|
||||||
type Endpoint4_13Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
|
type Endpoint4_14Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
|
||||||
export type Endpoint4_13Input = { readonly sessionID: Endpoint4_13Request["params"]["sessionID"] }
|
|
||||||
export type Endpoint4_13Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.clear"]>>
|
|
||||||
export type SessionRevertClearOperation<E = never> = (input: Endpoint4_13Input) => Effect.Effect<Endpoint4_13Output, E>
|
|
||||||
|
|
||||||
type Endpoint4_14Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
|
|
||||||
export type Endpoint4_14Input = { readonly sessionID: Endpoint4_14Request["params"]["sessionID"] }
|
export type Endpoint4_14Input = { readonly sessionID: Endpoint4_14Request["params"]["sessionID"] }
|
||||||
export type Endpoint4_14Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.commit"]>>
|
export type Endpoint4_14Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.clear"]>>
|
||||||
export type SessionRevertCommitOperation<E = never> = (input: Endpoint4_14Input) => Effect.Effect<Endpoint4_14Output, E>
|
export type SessionRevertClearOperation<E = never> = (input: Endpoint4_14Input) => Effect.Effect<Endpoint4_14Output, E>
|
||||||
|
|
||||||
type Endpoint4_15Request = Parameters<RawClient["server.session"]["session.context"]>[0]
|
type Endpoint4_15Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
|
||||||
export type Endpoint4_15Input = { readonly sessionID: Endpoint4_15Request["params"]["sessionID"] }
|
export type Endpoint4_15Input = { readonly sessionID: Endpoint4_15Request["params"]["sessionID"] }
|
||||||
export type Endpoint4_15Output = EffectValue<ReturnType<RawClient["server.session"]["session.context"]>>["data"]
|
export type Endpoint4_15Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.commit"]>>
|
||||||
export type SessionContextOperation<E = never> = (input: Endpoint4_15Input) => Effect.Effect<Endpoint4_15Output, E>
|
export type SessionRevertCommitOperation<E = never> = (input: Endpoint4_15Input) => Effect.Effect<Endpoint4_15Output, E>
|
||||||
|
|
||||||
type Endpoint4_16Request = Parameters<RawClient["server.session"]["session.history"]>[0]
|
type Endpoint4_16Request = Parameters<RawClient["server.session"]["session.context"]>[0]
|
||||||
export type Endpoint4_16Input = {
|
export type Endpoint4_16Input = { readonly sessionID: Endpoint4_16Request["params"]["sessionID"] }
|
||||||
readonly sessionID: Endpoint4_16Request["params"]["sessionID"]
|
export type Endpoint4_16Output = EffectValue<ReturnType<RawClient["server.session"]["session.context"]>>["data"]
|
||||||
readonly limit?: Endpoint4_16Request["query"]["limit"]
|
export type SessionContextOperation<E = never> = (input: Endpoint4_16Input) => Effect.Effect<Endpoint4_16Output, E>
|
||||||
readonly after?: Endpoint4_16Request["query"]["after"]
|
|
||||||
}
|
|
||||||
export type Endpoint4_16Output = EffectValue<ReturnType<RawClient["server.session"]["session.history"]>>
|
|
||||||
export type SessionHistoryOperation<E = never> = (input: Endpoint4_16Input) => Effect.Effect<Endpoint4_16Output, E>
|
|
||||||
|
|
||||||
type Endpoint4_17Request = Parameters<RawClient["server.session"]["session.events"]>[0]
|
type Endpoint4_17Request = Parameters<RawClient["server.session"]["session.history"]>[0]
|
||||||
export type Endpoint4_17Input = {
|
export type Endpoint4_17Input = {
|
||||||
readonly sessionID: Endpoint4_17Request["params"]["sessionID"]
|
readonly sessionID: Endpoint4_17Request["params"]["sessionID"]
|
||||||
|
readonly limit?: Endpoint4_17Request["query"]["limit"]
|
||||||
readonly after?: Endpoint4_17Request["query"]["after"]
|
readonly after?: Endpoint4_17Request["query"]["after"]
|
||||||
}
|
}
|
||||||
export type Endpoint4_17Output = StreamValue<EffectValue<ReturnType<RawClient["server.session"]["session.events"]>>>
|
export type Endpoint4_17Output = EffectValue<ReturnType<RawClient["server.session"]["session.history"]>>
|
||||||
export type SessionEventsOperation<E = never> = (input: Endpoint4_17Input) => Stream.Stream<Endpoint4_17Output, E>
|
export type SessionHistoryOperation<E = never> = (input: Endpoint4_17Input) => Effect.Effect<Endpoint4_17Output, E>
|
||||||
|
|
||||||
type Endpoint4_18Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
type Endpoint4_18Request = Parameters<RawClient["server.session"]["session.events"]>[0]
|
||||||
export type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] }
|
export type Endpoint4_18Input = {
|
||||||
export type Endpoint4_18Output = EffectValue<ReturnType<RawClient["server.session"]["session.interrupt"]>>
|
readonly sessionID: Endpoint4_18Request["params"]["sessionID"]
|
||||||
export type SessionInterruptOperation<E = never> = (input: Endpoint4_18Input) => Effect.Effect<Endpoint4_18Output, E>
|
readonly after?: Endpoint4_18Request["query"]["after"]
|
||||||
|
|
||||||
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.background"]>[0]
|
|
||||||
export type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] }
|
|
||||||
export type Endpoint4_19Output = EffectValue<ReturnType<RawClient["server.session"]["session.background"]>>
|
|
||||||
export type SessionBackgroundOperation<E = never> = (input: Endpoint4_19Input) => Effect.Effect<Endpoint4_19Output, E>
|
|
||||||
|
|
||||||
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
|
||||||
export type Endpoint4_20Input = {
|
|
||||||
readonly sessionID: Endpoint4_20Request["params"]["sessionID"]
|
|
||||||
readonly messageID: Endpoint4_20Request["params"]["messageID"]
|
|
||||||
}
|
}
|
||||||
export type Endpoint4_20Output = EffectValue<ReturnType<RawClient["server.session"]["session.message"]>>["data"]
|
export type Endpoint4_18Output = StreamValue<EffectValue<ReturnType<RawClient["server.session"]["session.events"]>>>
|
||||||
export type SessionMessageOperation<E = never> = (input: Endpoint4_20Input) => Effect.Effect<Endpoint4_20Output, E>
|
export type SessionEventsOperation<E = never> = (input: Endpoint4_18Input) => Stream.Stream<Endpoint4_18Output, E>
|
||||||
|
|
||||||
|
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
||||||
|
export type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] }
|
||||||
|
export type Endpoint4_19Output = EffectValue<ReturnType<RawClient["server.session"]["session.interrupt"]>>
|
||||||
|
export type SessionInterruptOperation<E = never> = (input: Endpoint4_19Input) => Effect.Effect<Endpoint4_19Output, E>
|
||||||
|
|
||||||
|
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.background"]>[0]
|
||||||
|
export type Endpoint4_20Input = { readonly sessionID: Endpoint4_20Request["params"]["sessionID"] }
|
||||||
|
export type Endpoint4_20Output = EffectValue<ReturnType<RawClient["server.session"]["session.background"]>>
|
||||||
|
export type SessionBackgroundOperation<E = never> = (input: Endpoint4_20Input) => Effect.Effect<Endpoint4_20Output, E>
|
||||||
|
|
||||||
|
type Endpoint4_21Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||||
|
export type Endpoint4_21Input = {
|
||||||
|
readonly sessionID: Endpoint4_21Request["params"]["sessionID"]
|
||||||
|
readonly messageID: Endpoint4_21Request["params"]["messageID"]
|
||||||
|
}
|
||||||
|
export type Endpoint4_21Output = EffectValue<ReturnType<RawClient["server.session"]["session.message"]>>["data"]
|
||||||
|
export type SessionMessageOperation<E = never> = (input: Endpoint4_21Input) => Effect.Effect<Endpoint4_21Output, E>
|
||||||
|
|
||||||
export interface SessionApi<E = never> {
|
export interface SessionApi<E = never> {
|
||||||
readonly list: SessionListOperation<E>
|
readonly list: SessionListOperation<E>
|
||||||
@@ -206,6 +216,7 @@ export interface SessionApi<E = never> {
|
|||||||
readonly rename: SessionRenameOperation<E>
|
readonly rename: SessionRenameOperation<E>
|
||||||
readonly prompt: SessionPromptOperation<E>
|
readonly prompt: SessionPromptOperation<E>
|
||||||
readonly skill: SessionSkillOperation<E>
|
readonly skill: SessionSkillOperation<E>
|
||||||
|
readonly synthetic: SessionSyntheticOperation<E>
|
||||||
readonly compact: SessionCompactOperation<E>
|
readonly compact: SessionCompactOperation<E>
|
||||||
readonly wait: SessionWaitOperation<E>
|
readonly wait: SessionWaitOperation<E>
|
||||||
readonly revertStage: SessionRevertStageOperation<E>
|
readonly revertStage: SessionRevertStageOperation<E>
|
||||||
|
|||||||
@@ -2,5 +2,5 @@ export type { PluginContext } from "./context.js"
|
|||||||
export { define } from "./plugin.js"
|
export { define } from "./plugin.js"
|
||||||
export type { Plugin } from "./plugin.js"
|
export type { Plugin } from "./plugin.js"
|
||||||
export * as Tool from "./tool.js"
|
export * as Tool from "./tool.js"
|
||||||
export type { ToolDomain } from "./tool.js"
|
export type { ToolDomain, ToolExecuteBeforeEvent, ToolExecuteAfterEvent } from "./tool.js"
|
||||||
export type { SessionDomain } from "./runtime.js"
|
export type { SessionDomain } from "./runtime.js"
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
export * as Tool from "./tool.js"
|
export * as Tool from "./tool.js"
|
||||||
|
|
||||||
import { ToolDefinition, ToolFailure, ToolOutput, type ToolCall } from "@opencode-ai/llm"
|
import { ToolDefinition, ToolFailure, ToolOutput, type ToolCall, type ToolResultValue } from "@opencode-ai/llm"
|
||||||
import { Agent } from "@opencode-ai/schema/agent"
|
import { Agent } from "@opencode-ai/schema/agent"
|
||||||
import { Session } from "@opencode-ai/schema/session"
|
import { Session } from "@opencode-ai/schema/session"
|
||||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||||
import { Effect, JsonSchema, Schema, type Scope } from "effect"
|
import { Effect, JsonSchema, Schema, type Scope } from "effect"
|
||||||
|
import type { Hooks } from "./registration.js"
|
||||||
|
|
||||||
export interface Context {
|
export interface Context {
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
@@ -213,6 +214,28 @@ function toJsonSchema(schema: Schema.Top): JsonSchema.JsonSchema {
|
|||||||
return { ...document.schema, $defs: document.definitions }
|
return { ...document.schema, $defs: document.definitions }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ToolExecuteBeforeEvent {
|
||||||
|
readonly tool: string
|
||||||
|
readonly sessionID: Session.ID
|
||||||
|
readonly agent: Agent.ID
|
||||||
|
readonly assistantMessageID: SessionMessage.ID
|
||||||
|
readonly toolCallID: string
|
||||||
|
input: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolExecuteAfterEvent {
|
||||||
|
readonly tool: string
|
||||||
|
readonly sessionID: Session.ID
|
||||||
|
readonly agent: Agent.ID
|
||||||
|
readonly assistantMessageID: SessionMessage.ID
|
||||||
|
readonly toolCallID: string
|
||||||
|
readonly input: unknown
|
||||||
|
result: ToolResultValue
|
||||||
|
output?: ToolOutput
|
||||||
|
outputPaths?: ReadonlyArray<string>
|
||||||
|
}
|
||||||
|
|
||||||
export interface ToolDomain {
|
export interface ToolDomain {
|
||||||
readonly register: (tools: Readonly<Record<string, AnyTool>>) => Effect.Effect<void, RegistrationError, Scope.Scope>
|
readonly register: (tools: Readonly<Record<string, AnyTool>>) => Effect.Effect<void, RegistrationError, Scope.Scope>
|
||||||
|
readonly execute: Hooks<{ before: ToolExecuteBeforeEvent; after: ToolExecuteAfterEvent }>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -277,6 +277,26 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
.add(
|
||||||
|
HttpApiEndpoint.post("session.synthetic", "/api/session/:sessionID/synthetic", {
|
||||||
|
params: { sessionID: Session.ID },
|
||||||
|
payload: Schema.Struct({
|
||||||
|
text: Schema.String,
|
||||||
|
description: Schema.String.pipe(Schema.optional),
|
||||||
|
metadata: SessionMessage.Synthetic.fields.metadata,
|
||||||
|
}),
|
||||||
|
success: HttpApiSchema.NoContent,
|
||||||
|
error: SessionNotFoundError,
|
||||||
|
})
|
||||||
|
.middleware(sessionLocationMiddleware)
|
||||||
|
.annotateMerge(
|
||||||
|
OpenApi.annotations({
|
||||||
|
identifier: "v2.session.synthetic",
|
||||||
|
summary: "Add synthetic message",
|
||||||
|
description: "Append a synthetic message to a session and resume execution.",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
.add(
|
.add(
|
||||||
HttpApiEndpoint.post("session.compact", "/api/session/:sessionID/compact", {
|
HttpApiEndpoint.post("session.compact", "/api/session/:sessionID/compact", {
|
||||||
params: { sessionID: Session.ID },
|
params: { sessionID: Session.ID },
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ export const Synthetic = Event.define({
|
|||||||
messageID: SessionMessage.ID,
|
messageID: SessionMessage.ID,
|
||||||
text: Schema.String,
|
text: Schema.String,
|
||||||
description: Schema.String.pipe(optional),
|
description: Schema.String.pipe(optional),
|
||||||
|
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export type Synthetic = typeof Synthetic.Type
|
export type Synthetic = typeof Synthetic.Type
|
||||||
|
|||||||
@@ -239,6 +239,27 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
|||||||
return HttpApiSchema.NoContent.make()
|
return HttpApiSchema.NoContent.make()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
.handle(
|
||||||
|
"session.synthetic",
|
||||||
|
Effect.fn(function* (ctx) {
|
||||||
|
yield* session.synthetic({
|
||||||
|
sessionID: ctx.params.sessionID,
|
||||||
|
text: ctx.payload.text,
|
||||||
|
description: ctx.payload.description,
|
||||||
|
metadata: ctx.payload.metadata,
|
||||||
|
}).pipe(
|
||||||
|
Effect.catchTag("Session.NotFoundError", (error) =>
|
||||||
|
Effect.fail(
|
||||||
|
new SessionNotFoundError({
|
||||||
|
sessionID: error.sessionID,
|
||||||
|
message: `Session not found: ${error.sessionID}`,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return HttpApiSchema.NoContent.make()
|
||||||
|
}),
|
||||||
|
)
|
||||||
.handle(
|
.handle(
|
||||||
"session.compact",
|
"session.compact",
|
||||||
Effect.fn(function* (ctx) {
|
Effect.fn(function* (ctx) {
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export function createRoutes(password?: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createEmbeddedRoutes(sdkPlugins?: SdkPlugins.Store) {
|
export function createEmbeddedRoutes(sdkPlugins?: SdkPlugins.Store) {
|
||||||
return makeRoutes(ServerAuth.Config.layer({ username: "opencode", password: Option.none() }), sdkPlugins)
|
return makeRoutes(ServerAuth.Config.configLayer({ username: "opencode", password: Option.none() }), sdkPlugins)
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeRoutes<AuthError, AuthServices>(
|
function makeRoutes<AuthError, AuthServices>(
|
||||||
@@ -63,7 +63,7 @@ function makeRoutes<AuthError, AuthServices>(
|
|||||||
[
|
[
|
||||||
[SessionExecution.node, SessionExecutionLocal.node],
|
[SessionExecution.node, SessionExecutionLocal.node],
|
||||||
[PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)],
|
[PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)],
|
||||||
[PluginRuntime.providerNode, PluginRuntime.providerLayerWithCell(pluginRuntimeCell)],
|
[PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)],
|
||||||
...(sdkPlugins ? [[SdkPlugins.node, SdkPlugins.layerWithStore(sdkPlugins)] as const] : []),
|
...(sdkPlugins ? [[SdkPlugins.node, SdkPlugins.layerWithStore(sdkPlugins)] as const] : []),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export function DialogSessionList() {
|
|||||||
category,
|
category,
|
||||||
footer,
|
footer,
|
||||||
gutter:
|
gutter:
|
||||||
data.session.status(session.id) === "running"
|
data.session.family(session.id).some((id) => data.session.status(id) === "running")
|
||||||
? () => <Spinner />
|
? () => <Spinner />
|
||||||
: slot === undefined
|
: slot === undefined
|
||||||
? undefined
|
? undefined
|
||||||
|
|||||||
@@ -160,13 +160,12 @@ export function Prompt(props: PromptProps) {
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const status = createMemo(() => data.session.status(props.sessionID ?? ""))
|
const status = createMemo(() => data.session.status(props.sessionID ?? ""))
|
||||||
const activeSubagents = createMemo(
|
const activeSubagents = createMemo(() => {
|
||||||
() =>
|
if (!props.sessionID) return 0
|
||||||
data.session
|
return data.session.family(props.sessionID).filter(
|
||||||
.list()
|
(id) => id !== props.sessionID && data.session.status(id) === "running",
|
||||||
.filter((session) => session.parentID === props.sessionID && data.session.status(session.id) === "running")
|
).length
|
||||||
.length,
|
})
|
||||||
)
|
|
||||||
const runningShells = createMemo(
|
const runningShells = createMemo(
|
||||||
() => data.shell.list(currentLocation()).filter((shell) => shell.metadata.sessionID === props.sessionID).length,
|
() => data.shell.list(currentLocation()).filter((shell) => shell.metadata.sessionID === props.sessionID).length,
|
||||||
)
|
)
|
||||||
@@ -290,18 +289,23 @@ export function Prompt(props: PromptProps) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Far-right footer cluster: live work counts lead, then context/cost usage, all dot-joined.
|
const subagentStatusLabel = createMemo(() => {
|
||||||
|
const agents = activeSubagents()
|
||||||
|
if (!agents) return undefined
|
||||||
|
return `${agents} subagent${agents === 1 ? "" : "s"}`
|
||||||
|
})
|
||||||
|
const shellStatusLabel = createMemo(() => {
|
||||||
|
const shells = runningShells()
|
||||||
|
if (!shells) return undefined
|
||||||
|
return `${shells} shell${shells === 1 ? "" : "s"}`
|
||||||
|
})
|
||||||
|
const liveWorkStatusVisible = createMemo(() => Boolean(subagentStatusLabel() || shellStatusLabel()))
|
||||||
|
|
||||||
|
// Far-right footer cluster: live work counts lead, then context/cost usage.
|
||||||
// When empty, the cluster falls back to the hotkey hints.
|
// When empty, the cluster falls back to the hotkey hints.
|
||||||
const statusItems = createMemo(() => {
|
const statusItems = createMemo(() => {
|
||||||
const agents = activeSubagents()
|
|
||||||
const shells = runningShells()
|
|
||||||
const stats = usage()
|
const stats = usage()
|
||||||
return [
|
return [stats?.context, stats?.cost].filter(Boolean)
|
||||||
agents ? `${agents} subagent${agents === 1 ? "" : "s"}` : undefined,
|
|
||||||
shells ? `${shells} shell${shells === 1 ? "" : "s"}` : undefined,
|
|
||||||
stats?.context,
|
|
||||||
stats?.cost,
|
|
||||||
].filter(Boolean)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const [store, setStore] = createStore<{
|
const [store, setStore] = createStore<{
|
||||||
@@ -1683,9 +1687,23 @@ export function Prompt(props: PromptProps) {
|
|||||||
<Switch>
|
<Switch>
|
||||||
<Match when={store.mode === "normal"}>
|
<Match when={store.mode === "normal"}>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={statusItems().length > 0}>
|
<Match when={liveWorkStatusVisible() || statusItems().length > 0}>
|
||||||
<text fg={theme.textMuted} wrapMode="none">
|
<text fg={theme.textMuted} wrapMode="none">
|
||||||
{statusItems().join(" · ")}
|
<Show when={subagentStatusLabel()}>
|
||||||
|
{(label) => <span style={{ fg: theme.textMuted }}>{label()}</span>}
|
||||||
|
</Show>
|
||||||
|
<Show when={subagentStatusLabel() && shellStatusLabel()}>
|
||||||
|
<span style={{ fg: theme.textMuted }}> · </span>
|
||||||
|
</Show>
|
||||||
|
<Show when={shellStatusLabel()}>
|
||||||
|
{(label) => <span style={{ fg: theme.textMuted }}>{label()}</span>}
|
||||||
|
</Show>
|
||||||
|
<Show when={liveWorkStatusVisible() && statusItems().length > 0}>
|
||||||
|
<span style={{ fg: theme.textMuted }}> · </span>
|
||||||
|
</Show>
|
||||||
|
<Show when={statusItems().length > 0}>
|
||||||
|
<span style={{ fg: theme.textMuted }}>{statusItems().join(" · ")}</span>
|
||||||
|
</Show>
|
||||||
</text>
|
</text>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={true}>
|
<Match when={true}>
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
import { createMemo } from "solid-js"
|
import { createMemo } from "solid-js"
|
||||||
import { useData } from "../context/data"
|
import { useData } from "../context/data"
|
||||||
import { useSync } from "../context/sync"
|
import { hasConnectedProvider } from "../util/connected-provider"
|
||||||
|
|
||||||
export function useConnected() {
|
export function useConnected() {
|
||||||
const data = useData()
|
const data = useData()
|
||||||
const sync = useSync()
|
return createMemo(() => hasConnectedProvider(data.location.integration.list() ?? []))
|
||||||
return createMemo(
|
|
||||||
() =>
|
|
||||||
(data.location.integration.list() ?? []).some((integration) => integration.connections.length > 0) ||
|
|
||||||
sync.data.console_state.consoleManagedProviders.length > 0,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ type LocationData = {
|
|||||||
type Data = {
|
type Data = {
|
||||||
session: {
|
session: {
|
||||||
info: Record<string, SessionV2Info>
|
info: Record<string, SessionV2Info>
|
||||||
|
// Family index keyed by a family's root (or furthest-known-ancestor when the
|
||||||
|
// true root is not yet loaded). The value is a flat deduplicated list of every
|
||||||
|
// session ID in that family, including the key itself once its info arrives.
|
||||||
|
family: Record<string, string[]>
|
||||||
status: Record<string, DataSessionStatus>
|
status: Record<string, DataSessionStatus>
|
||||||
message: Record<string, SessionMessage[]>
|
message: Record<string, SessionMessage[]>
|
||||||
permission: Record<string, PermissionV2Request[]>
|
permission: Record<string, PermissionV2Request[]>
|
||||||
@@ -77,6 +81,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
const [store, setStore] = createStore<Data>({
|
const [store, setStore] = createStore<Data>({
|
||||||
session: {
|
session: {
|
||||||
info: {},
|
info: {},
|
||||||
|
family: {},
|
||||||
status: {},
|
status: {},
|
||||||
message: {},
|
message: {},
|
||||||
permission: {},
|
permission: {},
|
||||||
@@ -149,6 +154,46 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
return created
|
return created
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Walk parentID upward through loaded session info to the family root. When a
|
||||||
|
// parent's info is missing, that missing ID is the furthest-known ancestor and
|
||||||
|
// is returned so orphan subtrees group under it until the parent arrives. A
|
||||||
|
// seen set guards against parent cycles, stopping at the last non-repeating
|
||||||
|
// ancestor.
|
||||||
|
function resolveRoot(sessionID: string) {
|
||||||
|
let current = sessionID
|
||||||
|
let parentID = store.session.info[sessionID]?.parentID
|
||||||
|
const seen = new Set([sessionID])
|
||||||
|
while (parentID) {
|
||||||
|
if (seen.has(parentID)) break
|
||||||
|
seen.add(parentID)
|
||||||
|
current = parentID
|
||||||
|
parentID = store.session.info[parentID]?.parentID
|
||||||
|
}
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register one session into the family index. Idempotent: refreshing an
|
||||||
|
// existing session never duplicates its ID. When a tentative family keyed by
|
||||||
|
// sessionID exists (descendants arrived while sessionID's own info was
|
||||||
|
// absent) but sessionID turns out to have a parent, fold the orphan subtree
|
||||||
|
// into the resolved root's family and drop the tentative entry.
|
||||||
|
function registerSession(sessionID: string) {
|
||||||
|
const info = store.session.info[sessionID]
|
||||||
|
if (!info) return
|
||||||
|
const rootID = resolveRoot(sessionID)
|
||||||
|
setStore("session", "family", produce((draft) => {
|
||||||
|
if (sessionID !== rootID && draft[sessionID]) {
|
||||||
|
const members = draft[rootID] ??= []
|
||||||
|
for (const id of draft[sessionID]) {
|
||||||
|
if (!members.includes(id)) members.push(id)
|
||||||
|
}
|
||||||
|
delete draft[sessionID]
|
||||||
|
}
|
||||||
|
const family = draft[rootID] ??= []
|
||||||
|
if (!family.includes(sessionID)) family.push(sessionID)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
function handleEvent(event: V2Event) {
|
function handleEvent(event: V2Event) {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "session.created":
|
case "session.created":
|
||||||
@@ -599,11 +644,18 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
get(sessionID: string) {
|
get(sessionID: string) {
|
||||||
return store.session.info[sessionID]
|
return store.session.info[sessionID]
|
||||||
},
|
},
|
||||||
|
root(sessionID: string) {
|
||||||
|
return resolveRoot(sessionID)
|
||||||
|
},
|
||||||
|
family(sessionID: string) {
|
||||||
|
return store.session.family[resolveRoot(sessionID)] ?? []
|
||||||
|
},
|
||||||
status(sessionID: string) {
|
status(sessionID: string) {
|
||||||
return store.session.status[sessionID] ?? "idle"
|
return store.session.status[sessionID] ?? "idle"
|
||||||
},
|
},
|
||||||
async refresh(sessionID: string) {
|
async refresh(sessionID: string) {
|
||||||
setStore("session", "info", sessionID, mutable(await sdk.api.session.get({ sessionID })))
|
setStore("session", "info", sessionID, mutable(await sdk.api.session.get({ sessionID })))
|
||||||
|
registerSession(sessionID)
|
||||||
},
|
},
|
||||||
message: {
|
message: {
|
||||||
ids(sessionID: string) {
|
ids(sessionID: string) {
|
||||||
@@ -795,15 +847,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
directory: defaultLocation().directory,
|
directory: defaultLocation().directory,
|
||||||
workspace: defaultLocation().workspaceID,
|
workspace: defaultLocation().workspaceID,
|
||||||
})
|
})
|
||||||
.then((response) =>
|
.then((response) => {
|
||||||
setStore(
|
setStore(
|
||||||
"session",
|
"session",
|
||||||
"info",
|
"info",
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
for (const session of response.data) draft[session.id] = mutable(session)
|
for (const session of response.data) draft[session.id] = mutable(session)
|
||||||
}),
|
}),
|
||||||
),
|
)
|
||||||
),
|
for (const session of response.data) registerSession(session.id)
|
||||||
|
}),
|
||||||
sdk.api.session
|
sdk.api.session
|
||||||
.active()
|
.active()
|
||||||
.then((active) =>
|
.then((active) =>
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import type { BuiltinTuiPlugin } from "../builtins"
|
|||||||
import { createMemo, Show } from "solid-js"
|
import { createMemo, Show } from "solid-js"
|
||||||
import { Tips } from "./tips-view"
|
import { Tips } from "./tips-view"
|
||||||
import { useBindings } from "../../keymap"
|
import { useBindings } from "../../keymap"
|
||||||
|
import { useData } from "../../context/data"
|
||||||
|
import { hasConnectedProvider } from "../../util/connected-provider"
|
||||||
|
|
||||||
const id = "internal:home-tips"
|
const id = "internal:home-tips"
|
||||||
|
|
||||||
@@ -37,13 +39,10 @@ const tui: TuiPlugin = async (api) => {
|
|||||||
order: 100,
|
order: 100,
|
||||||
slots: {
|
slots: {
|
||||||
home_bottom() {
|
home_bottom() {
|
||||||
|
const data = useData()
|
||||||
const hidden = createMemo(() => api.kv.get("tips_hidden", false))
|
const hidden = createMemo(() => api.kv.get("tips_hidden", false))
|
||||||
const first = createMemo(() => api.state.session.count() === 0)
|
const first = createMemo(() => api.state.session.count() === 0)
|
||||||
const connected = createMemo(() =>
|
const connected = createMemo(() => hasConnectedProvider(data.location.integration.list() ?? []))
|
||||||
api.state.provider.some(
|
|
||||||
(item) => item.id !== "opencode" || Object.values(item.models).some((model) => model.cost?.input !== 0),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const show = createMemo(() => (!first() || !connected()) && !hidden())
|
const show = createMemo(() => (!first() || !connected()) && !hidden())
|
||||||
return <View api={api} hidden={hidden()} show={show()} connected={connected()} />
|
return <View api={api} hidden={hidden()} show={show()} connected={connected()} />
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ export function ShellTab(props: { sessionID: string }) {
|
|||||||
const fg = selectedForeground(theme)
|
const fg = selectedForeground(theme)
|
||||||
const composer = useComposerTab()
|
const composer = useComposerTab()
|
||||||
const killHint = useCommandShortcut("composer.shell.kill")
|
const killHint = useCommandShortcut("composer.shell.kill")
|
||||||
const backgroundHint = useCommandShortcut("composer.background")
|
|
||||||
|
|
||||||
const entries = createMemo(() =>
|
const entries = createMemo(() =>
|
||||||
data.shell
|
data.shell
|
||||||
@@ -44,13 +43,7 @@ export function ShellTab(props: { sessionID: string }) {
|
|||||||
const cleanup = composer.register({
|
const cleanup = composer.register({
|
||||||
id: "shell",
|
id: "shell",
|
||||||
label: "Shell",
|
label: "Shell",
|
||||||
hints: () =>
|
hints: () => (selectedEntry() ? [{ label: "kill", shortcut: killHint() }] : []),
|
||||||
selectedEntry()
|
|
||||||
? [
|
|
||||||
{ label: "kill", shortcut: killHint() },
|
|
||||||
{ label: "background", shortcut: backgroundHint() },
|
|
||||||
]
|
|
||||||
: [],
|
|
||||||
})
|
})
|
||||||
onCleanup(cleanup)
|
onCleanup(cleanup)
|
||||||
})
|
})
|
||||||
@@ -89,18 +82,11 @@ export function ShellTab(props: { sessionID: string }) {
|
|||||||
void data.shell.remove(entry.id)
|
void data.shell.remove(entry.id)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "composer.background",
|
|
||||||
title: "Background shell command",
|
|
||||||
category: "Composer",
|
|
||||||
run() {},
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
bindings: [
|
bindings: [
|
||||||
{ key: "up", desc: "Previous shell", group: "Shell", cmd: "composer.shell.up" },
|
{ key: "up", desc: "Previous shell", group: "Shell", cmd: "composer.shell.up" },
|
||||||
{ key: "down", desc: "Next shell", group: "Shell", cmd: "composer.shell.down" },
|
{ key: "down", desc: "Next shell", group: "Shell", cmd: "composer.shell.down" },
|
||||||
{ key: "ctrl+d", desc: "Kill shell command", group: "Shell", cmd: "composer.shell.kill" },
|
{ key: "ctrl+d", desc: "Kill shell command", group: "Shell", cmd: "composer.shell.kill" },
|
||||||
{ key: "ctrl+b", desc: "Background shell command", group: "Shell", cmd: "composer.background" },
|
|
||||||
],
|
],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createStore } from "solid-js/store"
|
|||||||
import { TextAttributes, RGBA, ScrollBoxRenderable } from "@opentui/core"
|
import { TextAttributes, RGBA, ScrollBoxRenderable } from "@opentui/core"
|
||||||
import { useRoute, useRouteData } from "../../../context/route"
|
import { useRoute, useRouteData } from "../../../context/route"
|
||||||
import { useData } from "../../../context/data"
|
import { useData } from "../../../context/data"
|
||||||
|
import { useSDK } from "../../../context/sdk"
|
||||||
import { useTheme, selectedForeground } from "../../../context/theme"
|
import { useTheme, selectedForeground } from "../../../context/theme"
|
||||||
import { Locale } from "../../../util/locale"
|
import { Locale } from "../../../util/locale"
|
||||||
import { useBindings, useCommandShortcut } from "../../../keymap"
|
import { useBindings, useCommandShortcut } from "../../../keymap"
|
||||||
@@ -19,12 +20,12 @@ interface SubagentEntry {
|
|||||||
export function SubagentsTab(props: { sessionID: string }) {
|
export function SubagentsTab(props: { sessionID: string }) {
|
||||||
const route = useRouteData("session")
|
const route = useRouteData("session")
|
||||||
const data = useData()
|
const data = useData()
|
||||||
|
const sdk = useSDK()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const fg = selectedForeground(theme)
|
const fg = selectedForeground(theme)
|
||||||
const navigate = useRoute().navigate
|
const navigate = useRoute().navigate
|
||||||
const composer = useComposerTab()
|
const composer = useComposerTab()
|
||||||
const interruptHint = useCommandShortcut("composer.subagent.interrupt")
|
const interruptHint = useCommandShortcut("composer.subagent.interrupt")
|
||||||
const backgroundHint = useCommandShortcut("composer.background")
|
|
||||||
|
|
||||||
const session = createMemo(() => data.session.get(props.sessionID))
|
const session = createMemo(() => data.session.get(props.sessionID))
|
||||||
|
|
||||||
@@ -132,10 +133,7 @@ export function SubagentsTab(props: { sessionID: string }) {
|
|||||||
hints: () => {
|
hints: () => {
|
||||||
const entry = selectedEntry()
|
const entry = selectedEntry()
|
||||||
if (!entry || entry.status !== "running") return []
|
if (!entry || entry.status !== "running") return []
|
||||||
return [
|
return [{ label: "interrupt", shortcut: interruptHint() }]
|
||||||
{ label: "interrupt", shortcut: interruptHint() },
|
|
||||||
...(entry.current ? [{ label: "background", shortcut: backgroundHint() }] : []),
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
onClose: () => {
|
onClose: () => {
|
||||||
const parentID = session()?.parentID
|
const parentID = session()?.parentID
|
||||||
@@ -185,15 +183,7 @@ export function SubagentsTab(props: { sessionID: string }) {
|
|||||||
run() {
|
run() {
|
||||||
const entry = selectedEntry()
|
const entry = selectedEntry()
|
||||||
if (!entry || entry.status !== "running") return
|
if (!entry || entry.status !== "running") return
|
||||||
},
|
void sdk.api.session.interrupt({ sessionID: entry.sessionID })
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "composer.background",
|
|
||||||
title: "Background subagent",
|
|
||||||
category: "Composer",
|
|
||||||
run() {
|
|
||||||
const entry = selectedEntry()
|
|
||||||
if (!entry || entry.status !== "running" || !entry.current) return
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -202,7 +192,6 @@ export function SubagentsTab(props: { sessionID: string }) {
|
|||||||
{ key: "down", desc: "Next subagent", group: "Subagents", cmd: "composer.subagent.down" },
|
{ key: "down", desc: "Next subagent", group: "Subagents", cmd: "composer.subagent.down" },
|
||||||
{ key: "return", desc: "Navigate to subagent", group: "Subagents", cmd: "composer.subagent.select" },
|
{ key: "return", desc: "Navigate to subagent", group: "Subagents", cmd: "composer.subagent.select" },
|
||||||
{ key: "ctrl+d", desc: "Interrupt subagent", group: "Subagents", cmd: "composer.subagent.interrupt" },
|
{ key: "ctrl+d", desc: "Interrupt subagent", group: "Subagents", cmd: "composer.subagent.interrupt" },
|
||||||
{ key: "ctrl+b", desc: "Background subagent", group: "Subagents", cmd: "composer.background" },
|
|
||||||
],
|
],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
@@ -172,16 +172,7 @@ export function Session() {
|
|||||||
const messages = sessionMessages
|
const messages = sessionMessages
|
||||||
const descendantSessionIDs = createMemo(() => {
|
const descendantSessionIDs = createMemo(() => {
|
||||||
if (session()?.parentID) return []
|
if (session()?.parentID) return []
|
||||||
const sessions = data.session.list()
|
return data.session.family(route.sessionID).filter((id) => id !== route.sessionID)
|
||||||
const childrenByParent = sessions.reduce((acc, item) => {
|
|
||||||
if (!item.parentID) return acc
|
|
||||||
acc.set(item.parentID, [...(acc.get(item.parentID) ?? []), item.id])
|
|
||||||
return acc
|
|
||||||
}, new Map<string, string[]>())
|
|
||||||
function collect(sessionID: string): string[] {
|
|
||||||
return (childrenByParent.get(sessionID) ?? []).flatMap((id) => [id, ...collect(id)])
|
|
||||||
}
|
|
||||||
return collect(route.sessionID)
|
|
||||||
})
|
})
|
||||||
const permissions = createMemo(() => {
|
const permissions = createMemo(() => {
|
||||||
if (session()?.parentID) return []
|
if (session()?.parentID) return []
|
||||||
@@ -2271,7 +2262,7 @@ export function formatSubagentToolcalls(count: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function formatSubagentTitle(agent: string, description: string, background: boolean) {
|
export function formatSubagentTitle(agent: string, description: string, background: boolean) {
|
||||||
return `${agent} Subagent${background ? " (background)" : ""} — ${description}`
|
return `${agent} Subagent — ${description}${background ? " [background]" : ""}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatSubagentRetry(attempt: number, message: string) {
|
export function formatSubagentRetry(attempt: number, message: string) {
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import type { IntegrationInfo } from "@opencode-ai/sdk/v2"
|
||||||
|
|
||||||
|
export function hasConnectedProvider(integrations: readonly Pick<IntegrationInfo, "connections">[]) {
|
||||||
|
return integrations.some((integration) => integration.connections.length > 0)
|
||||||
|
}
|
||||||
@@ -915,3 +915,122 @@ test("projects live context updates with their message ID", async () => {
|
|||||||
app.renderer.destroy()
|
app.renderer.destroy()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function sessionInfo(id: string, parentID: string | undefined) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
parentID,
|
||||||
|
projectID: "proj_test",
|
||||||
|
cost: 0,
|
||||||
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
time: { created: 0, updated: 0 },
|
||||||
|
title: id,
|
||||||
|
location: { directory },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mounts a DataProvider whose `/api/session/:id` responses are driven by the
|
||||||
|
// given parent map (sessionID -> parentID). Roots omit the entry. Reused across
|
||||||
|
// the family-index tests below.
|
||||||
|
async function mountData(parents: Record<string, string>) {
|
||||||
|
const calls = createFetch((url) => {
|
||||||
|
const match = url.pathname.match(/^\/api\/session\/([^/]+)$/)
|
||||||
|
if (match && match[1] !== "active") return json({ data: sessionInfo(match[1], parents[match[1]]) })
|
||||||
|
})
|
||||||
|
let data!: ReturnType<typeof useData>
|
||||||
|
let ready!: () => void
|
||||||
|
const mounted = new Promise<void>((resolve) => {
|
||||||
|
ready = resolve
|
||||||
|
})
|
||||||
|
function Probe() {
|
||||||
|
data = useData()
|
||||||
|
onMount(ready)
|
||||||
|
return <box />
|
||||||
|
}
|
||||||
|
const app = await testRender(() => (
|
||||||
|
<TestTuiContexts>
|
||||||
|
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
|
||||||
|
<ProjectProvider>
|
||||||
|
<DataProvider>
|
||||||
|
<Probe />
|
||||||
|
</DataProvider>
|
||||||
|
</ProjectProvider>
|
||||||
|
</SDKProvider>
|
||||||
|
</TestTuiContexts>
|
||||||
|
))
|
||||||
|
await mounted
|
||||||
|
return { data, app }
|
||||||
|
}
|
||||||
|
|
||||||
|
test("groups an orphan child under its missing parent until the root arrives", async () => {
|
||||||
|
const { data, app } = await mountData({ child: "root" })
|
||||||
|
try {
|
||||||
|
await data.session.refresh("child")
|
||||||
|
// Parent info is absent, so the missing parent is the furthest-known ancestor.
|
||||||
|
expect(data.session.root("child")).toBe("root")
|
||||||
|
expect(data.session.family("child")).toEqual(["child"])
|
||||||
|
expect(data.session.family("root")).toEqual(["child"])
|
||||||
|
|
||||||
|
await data.session.refresh("root")
|
||||||
|
expect(data.session.root("root")).toBe("root")
|
||||||
|
// The tentative root entry folds into the now-known root's family.
|
||||||
|
expect(data.session.family("child")).toEqual(["child", "root"])
|
||||||
|
expect(data.session.family("root")).toEqual(["child", "root"])
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("indexes arbitrarily deep nesting under a single root", async () => {
|
||||||
|
const { data, app } = await mountData({ grandchild: "child", child: "root" })
|
||||||
|
try {
|
||||||
|
await data.session.refresh("grandchild")
|
||||||
|
expect(data.session.root("grandchild")).toBe("child")
|
||||||
|
expect(data.session.family("grandchild")).toEqual(["grandchild"])
|
||||||
|
|
||||||
|
await data.session.refresh("child")
|
||||||
|
// grandchild's tentative family (keyed by the missing "child") merges up
|
||||||
|
// toward the still-missing "root".
|
||||||
|
expect(data.session.root("child")).toBe("root")
|
||||||
|
expect(data.session.family("grandchild")).toEqual(["grandchild", "child"])
|
||||||
|
|
||||||
|
await data.session.refresh("root")
|
||||||
|
expect(data.session.root("grandchild")).toBe("root")
|
||||||
|
expect(data.session.root("child")).toBe("root")
|
||||||
|
expect(data.session.family("root")).toEqual(["grandchild", "child", "root"])
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("re-registering an existing session is idempotent", async () => {
|
||||||
|
const { data, app } = await mountData({ grandchild: "child", child: "root" })
|
||||||
|
try {
|
||||||
|
await data.session.refresh("grandchild")
|
||||||
|
await data.session.refresh("child")
|
||||||
|
await data.session.refresh("root")
|
||||||
|
const before = data.session.family("root")
|
||||||
|
expect(before).toEqual(["grandchild", "child", "root"])
|
||||||
|
|
||||||
|
await data.session.refresh("child")
|
||||||
|
await data.session.refresh("root")
|
||||||
|
await data.session.refresh("grandchild")
|
||||||
|
expect(data.session.family("root")).toEqual(before)
|
||||||
|
expect(data.session.family("root")).toHaveLength(3)
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("stops at the last non-repeating ancestor on a parent cycle", async () => {
|
||||||
|
const { data, app } = await mountData({ x: "y", y: "x" })
|
||||||
|
try {
|
||||||
|
await data.session.refresh("x")
|
||||||
|
await data.session.refresh("y")
|
||||||
|
// Does not hang; walking up from "y" stops before re-entering "x".
|
||||||
|
expect(data.session.root("y")).toBe("x")
|
||||||
|
expect(data.session.family("y")).toEqual(["x", "y"])
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ describe("TUI inline tool wrapping", () => {
|
|||||||
test("keeps background state attached to the subagent identity", () => {
|
test("keeps background state attached to the subagent identity", () => {
|
||||||
expect(formatSubagentTitle("Explore", "Inspect renderer", false)).toBe("Explore Subagent — Inspect renderer")
|
expect(formatSubagentTitle("Explore", "Inspect renderer", false)).toBe("Explore Subagent — Inspect renderer")
|
||||||
expect(formatSubagentTitle("Explore", "Inspect renderer", true)).toBe(
|
expect(formatSubagentTitle("Explore", "Inspect renderer", true)).toBe(
|
||||||
"Explore Subagent (background) — Inspect renderer",
|
"Explore Subagent — Inspect renderer [background]",
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { hasConnectedProvider } from "../../src/util/connected-provider"
|
||||||
|
|
||||||
|
describe("hasConnectedProvider", () => {
|
||||||
|
test("is false without integration credentials", () => {
|
||||||
|
expect(hasConnectedProvider([])).toBe(false)
|
||||||
|
expect(hasConnectedProvider([{ connections: [] }])).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("is true after any provider integration is connected", () => {
|
||||||
|
expect(hasConnectedProvider([{ connections: [{ type: "credential", id: "cred_1", label: "Work" }] }])).toBe(true)
|
||||||
|
expect(hasConnectedProvider([{ connections: [{ type: "env", name: "OPENAI_API_KEY" }] }])).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,23 @@
|
|||||||
# V2 Schema Changelog
|
# V2 Schema Changelog
|
||||||
|
|
||||||
|
## 2026-07-01: Synthetic Message Metadata And Model-Visible Leak Fix
|
||||||
|
|
||||||
|
- Add optional `metadata: Record<string, unknown>` to the durable `session.next.synthetic.1` event data so synthetic messages can carry a durable ledger (e.g. lazy-instruction dedup paths).
|
||||||
|
- Add optional `metadata` to the `SessionV2.synthetic` method and the `POST /api/session/:sessionID/synthetic` HTTP endpoint payload.
|
||||||
|
- Stop forwarding `SessionMessage.Synthetic.metadata` (inherited from `Base.metadata`) to the provider message in `to-llm-message`. Synthetic metadata is bookkeeping; the model must not see it.
|
||||||
|
|
||||||
|
Change:
|
||||||
|
|
||||||
|
- Give durable synthetic messages an optional metadata channel so Location-scoped services can stamp durable, model-hidden annotations (e.g. lazy-instruction dedup claims) without depending on `SessionV2`.
|
||||||
|
- `to-llm-message` no longer includes `metadata` on the lowered synthetic user message. Previously `Base.metadata` was forwarded to the provider for every synthetic message; it is now withheld so the model only sees the synthetic text.
|
||||||
|
|
||||||
|
Compatibility:
|
||||||
|
|
||||||
|
- The added durable-event field is optional so previously recorded experimental events remain decodable; no durable-event version bump.
|
||||||
|
- Existing projected synthetic messages decode without `metadata`; the lazy-instruction dedup treats absent metadata as no prior claim.
|
||||||
|
- No database migration is required.
|
||||||
|
- Provider-visible behavior changes: the model no longer receives synthetic message metadata. Existing sessions that relied on synthetic metadata being model-visible should move that information into the synthetic text.
|
||||||
|
|
||||||
## 2026-06-26: Add Finite Session History
|
## 2026-06-26: Add Finite Session History
|
||||||
|
|
||||||
- Add `GET /api/session/:sessionID/history` and generated Promise, Effect, and legacy JavaScript client methods.
|
- Add `GET /api/session/:sessionID/history` and generated Promise, Effect, and legacy JavaScript client methods.
|
||||||
|
|||||||
Reference in New Issue
Block a user