Compare commits

...

14 Commits

Author SHA1 Message Date
Kit Langton 9b5f689b4e fix(core): keep model-facing background instruction out of rendered shell output 2026-07-02 09:22:11 -04:00
Kit Langton 15227b57a9 fix(core): keep background shell notices accurate after completion 2026-07-02 00:31:04 -04:00
Kit Langton 674d08f9be fix(core): route ChatGPT OAuth to the codex backend (#34843) 2026-07-02 00:15:34 -04:00
opencode-agent[bot] 02f012f5d1 fix(tui): clear onboarding after provider connect (#34819)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-07-01 21:10:24 -05:00
Dax Raad bea6e1499d feat(core): discover instructions on directory reads 2026-07-01 21:11:22 -04:00
Dax Raad 50a1fe49bc fix(core): relativize instruction description to project root 2026-07-01 21:03:46 -04:00
Dax Raad 6380919fda fix(core): use relative paths in instruction load description 2026-07-01 21:02:47 -04:00
Dax Raad d972aa9d83 feat(core): tool execute hooks, session instructions, synthetic endpoint
- V2 tool execute hooks: add `ctx.tool.execute.before/after` plugin API,
  core ToolHooks service, and registry wiring so hosted/local tool calls
  run registered before/after hooks; provider-executed calls are excluded.
- Session synthetic endpoint: add `POST /api/session/:sessionID/synthetic`
  with text/description/metadata plus regenerated Promise/Effect/JS client
  surfaces.
- SessionInstructions service: read tool discovers nearby AGENTS.md walking
  up to the Location root (exclusive) and injects them as durable synthetic
  instructions, with lazy history dedup of prior claims.
- to-llm-message: stop forwarding synthetic message metadata to the model so
  bookkeeping annotations stay model-hidden.
- schema changelog entry for synthetic metadata and the metadata leak fix.
2026-07-01 20:50:10 -04:00
Dax Raad d544ab4d91 feat(tui): show background label on subagent line 2026-07-01 20:25:21 -04:00
Dax Raad ce228bfd7c feat(tui): maintain session family index in data context 2026-07-01 20:22:33 -04:00
Dax Raad eabf85aea2 fix(tui): wire subagent interrupt and drop granular background commands 2026-07-01 20:00:28 -04:00
Dax Raad 2f4a688790 chore: set up orchestrator plugin and mute prompt status labels 2026-07-01 18:51:18 -04:00
Dax Raad 4a90ffedfb fix(server): restore provider node deps after dev merge 2026-07-01 18:39:43 -04:00
Dax Raad 95cf5039be fix(tui): color prompt work counts 2026-07-01 18:23:07 -04:00
51 changed files with 1604 additions and 231 deletions
+36
View File
@@ -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" })
})
})
},
}
-16
View File
@@ -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")
})
})
},
}
+65 -51
View File
@@ -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]
+14
View File
@@ -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>(
{ {
+24
View File
@@ -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
View File
@@ -1 +0,0 @@
File to save in: ~/.local/share/opencode/worktree/012780/location-layer-tiers/packages/core/src/effect/
+2
View File
@@ -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,
+2
View File
@@ -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,
], ],
}) })
+43
View File
@@ -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
}
+37 -1
View File
@@ -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
+10 -5
View File
@@ -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)
+2
View File
@@ -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)
}), }),
+115
View File
@@ -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 },
+16
View File
@@ -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":
+92
View File
@@ -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: [] })
+45 -7
View File
@@ -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,
],
}) })
+47 -12
View File
@@ -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],
}) })
+11 -3
View File
@@ -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>`,
}) })
}), }),
+3
View File
@@ -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>`,
}) })
}) })
+66
View File
@@ -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: [] })
}),
)
}) })
+2
View File
@@ -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,
]), ]),
[ [
+4
View File
@@ -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(
+38 -2
View File
@@ -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_")
+1
View File
@@ -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")
}), }),
), ),
), ),
+63 -52
View File
@@ -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>
+1 -1
View File
@@ -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"
+24 -1
View File
@@ -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 }>
} }
+20
View File
@@ -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 },
+1
View File
@@ -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
+21
View File
@@ -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) {
+2 -2
View File
@@ -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
+36 -18
View File
@@ -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}>
+2 -7
View File
@@ -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,
)
} }
+56 -3
View File
@@ -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" },
], ],
})) }))
+2 -11
View File
@@ -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)
}
+119
View File
@@ -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)
})
})
+18
View File
@@ -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.