refactor(core): rename system context to instructions (#35583)

This commit is contained in:
Kit Langton 2026-07-06 14:29:29 -04:00 committed by GitHub
parent 1a52e1118e
commit 91f1815732
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
62 changed files with 1547 additions and 1070 deletions

View file

@ -161,5 +161,5 @@ const table = sqliteTable("session", {
- Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe step boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's step allowance; a batch of steers resets it once.
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
- Keep EventV2 replay owner claims separate from clustered Session execution ownership.
- Keep the System Context algebra and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Checkpoint persistence Session-owned. The runner composes all context producers explicitly in `loadSystemContext`; there is no context registry.
- The durable Applied record is what the model was last told, per source. Reconcile narrates drift as chronological System updates and never rewrites the baseline; only completed compaction rebaselines, and move or committed revert resets the checkpoint. Unavailable sources keep the model's prior belief, blocking only a session's first baseline.
- Keep the Instructions algebra and built-ins in `src/instructions`; keep instruction producers with their observed domains, and keep Session History selection plus `InstructionCheckpoint` and `InstructionEntry` persistence Session-owned. `InstructionDiscovery` observes ambient global and upward-project instructions. The runner composes built-ins, discovery, guidance, and entries explicitly in `loadInstructions`; there is no instruction registry.
- The durable `Instructions.Applied` record is what the model was last told, per instruction source. Reconciliation narrates drift through `session.instructions.updated` and never rewrites the baseline; only completed compaction rebaselines, while Session movement or committed revert resets the `InstructionCheckpoint`. Unavailable sources keep the model's prior belief, blocking only a Session's first instruction baseline.

View file

@ -4,40 +4,47 @@ OpenCode sessions preserve durable conversational history while assembling the r
## Language
**System Context**:
The structured collection of contextual facts presented to the model as initial instructions and chronological updates.
_Avoid_: System prompt
**Model Context**:
The complete model-visible input assembled for one **Step**, including system instructions, **Session History**, tool definitions, and step-local additions. **Instructions** are one component of Model Context, not a synonym for it.
_Avoid_: System Context
**Instructions**:
The opaque algebra of independently refreshable typed instruction sources that render the durable instruction baseline and chronological updates shown to the model.
_Avoid_: Model Context, System Context
**Session History**:
The projected chronological conversation selected for a **Step** after applying the active compaction and **Context Epoch** cutoffs.
The projected chronological conversation selected for a **Step** after applying the active compaction and **InstructionCheckpoint** baseline cutoffs.
_Avoid_: Session Context
**Context Source**:
One independently observed typed value within the **System Context**, represented by a stable key, JSON codec, infallible loader, pure baseline/update renderers, and an optional removal renderer for dynamic sources.
**Instruction Source**:
One independently observed typed value within **Instructions**, represented by a stable namespaced key, JSON codec, loader, pure baseline/update renderers, and an optional removal renderer.
_Avoid_: Prompt fragment
**System Context Registry**:
The Location-scoped registry of ordered, scoped producers that contribute to the current **System Context**.
**InstructionEntry**:
One API-managed, durable, per-Session instruction value. Its slash-free client key maps to the `api/<key>` **Instruction Source** key. Entries deliberately render to the model as mechanism-neutral `<context>` blocks: the model sees session context, not how it was attached.
**Mid-Conversation System Message**:
A durable chronological instruction that tells the model the newly effective state of a changed **Context Source**.
_Avoid_: System update, system notification, raw text diff
**InstructionDiscovery**:
The Location-scoped service that observes ambient global and upward-project `AGENTS.md` files as one ordered aggregate **Instruction Source**.
**Context Epoch**:
The span during which one initially rendered **System Context** remains the immutable provider-cache baseline, ending at completed compaction, Session movement, or an incompatible context transition that requires a fresh baseline.
**InstructionCheckpoint**:
The Session-owned durable instruction baseline, baseline sequence, and `Instructions.Applied` record used to prepare later Steps.
**Baseline System Context**:
The full **System Context** rendered at the start of a **Context Epoch**.
**Instruction Update**:
A durable chronological System message published as `session.instructions.updated` that tells the model the newly effective state of one or more changed **Instruction Sources**.
_Avoid_: System notification, raw text diff
**Instruction Baseline**:
The exact joined instruction text stored by **InstructionCheckpoint** and sent as immutable provider-cache prefix state until completed compaction rebaselines it or Session movement or committed revert resets it.
_Avoid_: Live system prompt
**Context Snapshot**:
The overwriteable model-hidden JSON state used to compare each **Context Source** with the value last admitted to a **Step**.
**Applied Instructions**:
The overwriteable model-hidden `Instructions.Applied` record in **InstructionCheckpoint**, containing what the model was last told per **Instruction Source**.
**Unavailable Context**:
An expected temporary inability to observe a **Context Source** value; the runtime retains its prior effective state and emits no update, or omits it until first successfully loaded.
**Unavailable Instruction Source**:
An expected temporary inability to observe an **Instruction Source** value; the runtime retains its prior effective state and emits no update, while an unavailable source blocks creation of the first complete **Instruction Baseline**.
**Safe Step Boundary**:
The point immediately before a provider request, after durable input promotion and any required tool settlement, where context changes may be admitted chronologically.
The point during Step preparation, after prior tool settlement and before durable input promotion, where instruction changes may be admitted chronologically.
**Admitted Prompt**:
A durable user input accepted into the Session inbox but not yet included in **Session History**.
@ -46,7 +53,7 @@ A durable user input accepted into the Session inbox but not yet included in **S
The durable transition that removes an **Admitted Prompt** from pending input and appends its user message to **Session History**.
**Step**:
One logical LLM call spanning pre-flight context checkpoint preparation, input promotion, request build, and compaction check; the provider stream; and tool settlement.
One logical LLM call spanning pre-flight instruction checkpoint preparation, input promotion, request build, and compaction check; the provider stream; and tool settlement.
_Avoid_: provider turn, turn (unqualified)
**Physical Attempt**:
@ -100,16 +107,22 @@ _Avoid_: Response envelope
## Relationships
- A **System Context** is an opaque carrier composed from zero or more **Context Sources**.
- **Session History** contains projected conversational messages and admitted **Mid-Conversation System Messages**; the active **Baseline System Context** remains separate provider-request state.
- The **System Context Registry** uses stable-keyed scoped contributions to assemble the current **System Context**; contributor removal naturally removes its sources at the next **Safe Step Boundary**.
- A changed **Context Source** may produce one **Mid-Conversation System Message** containing its newly effective state.
- A **Mid-Conversation System Message** persists the exact combined rendered text sent to the model.
- The current **Context Snapshot** advances atomically with the corresponding durable **Mid-Conversation System Message**.
- A **Context Snapshot** stores one codec-encoded JSON value and, for removable dynamic sources, a pre-rendered removal message per stable **Context Source** key.
- Changes from multiple **Context Sources** admitted at one safe boundary combine into one **Mid-Conversation System Message**.
- Context changes are sampled and admitted lazily at a **Safe Step Boundary**, never pushed asynchronously when their source changes.
- At a **Safe Step Boundary**, newly promoted user input or settled tool results precede any combined **Mid-Conversation System Message**.
- **Instructions** is an opaque carrier composed from zero or more **Instruction Sources**.
- **Model Context** is broader than **Instructions**. For each **Step**, the runner assembles the selected agent or provider system text, the **Instruction Baseline**, **Session History**, available tools, and step-local additions into one model request.
- **Session History** contains projected conversational messages and admitted **Instruction Updates**; the active **Instruction Baseline** remains separate provider-request state.
- The runner explicitly loads and combines instruction built-ins, **InstructionDiscovery**, selected-agent skill guidance, reference guidance, MCP guidance, and **InstructionEntry** values. There is no instruction registry.
- `Instructions.combine(...)` preserves caller order and rejects duplicate stable namespaced source keys. The runner loads its producers concurrently, then combines them in its fixed declared order.
- Each **Instruction Source** loader returns one coherent typed value or explicitly reports unavailability. `Instructions.make(...)` hides the value type so differently typed sources compose uniformly; its codec compares and stores the value, while pure renderers produce baseline, update, and optional removal text.
- `Instructions.initialize(...)` observes composed **Instructions** once and produces a complete **Instruction Baseline** with **Applied Instructions**.
- `Instructions.reconcile(...)` observes composed **Instructions** once and returns either unchanged or one combined chronological update. It never rewrites the baseline.
- `Instructions.rebaseline(...)` renders a fresh baseline after completed compaction, recalling previously applied values for sources that are temporarily unavailable.
- A changed **Instruction Source** may contribute text to one **Instruction Update** containing the newly effective state.
- An **Instruction Update** persists the exact combined rendered text sent to the model through `session.instructions.updated`.
- **Applied Instructions** advances atomically with the corresponding durable **Instruction Update**.
- **Applied Instructions** stores one codec-encoded JSON value and, for removable sources, a pre-rendered removal message per stable **Instruction Source** key.
- Changes from multiple **Instruction Sources** admitted at one safe boundary combine into one **Instruction Update**.
- Instruction changes are sampled and admitted lazily at a **Safe Step Boundary**, never pushed asynchronously when their source changes.
- At a **Safe Step Boundary**, prior tool results are already settled; instruction preparation completes before newly admitted user input promotes.
- An **Admitted Prompt** is replayable pending input, not yet model-visible **Session History**.
- **Prompt Promotion** atomically consumes the pending inbox entry and appends its model-visible user message.
- Steering prompts promote at the next **Safe Step Boundary** while the current **Session Drain** still requires continuation. Promoting any newly admitted user input resets the selected agent's step allowance; multiple prompts promoted at one boundary reset it once.
@ -117,36 +130,28 @@ _Avoid_: Response envelope
- A **Session Drain** is process-local coordination rather than a durable domain entity. Durable recovery must reason from prompts, projected history, physical attempts, and tool state rather than inventing an enclosing execution identity.
- An **Execution** contains one or more **Session Drains**; a **Session Drain** contains one reserved assistant-turn span at a time; that span contains **Steps**; and each **Step** contains one or more **Physical Attempts** plus any tool calls it requires.
- A **Step** record covers only the model-visible span from first assistant output through tool settlement; pre-flight leaves no record, and one Step settles at most one record.
- The first **Step** renders the latest complete **Baseline System Context** and initializes its **Context Snapshot** without emitting a redundant **Mid-Conversation System Message**; unavailable initial context blocks the Step instead of persisting an incomplete baseline.
- Initial **System Context** preparation precedes the first durable input promotion so an unavailable baseline leaves that input pending and retryable; ordinary reconciliation remains after promotion.
- Compaction starts a new **Context Epoch** with a freshly rendered **Baseline System Context** and **Context Snapshot**; prior **Mid-Conversation System Messages** remain durable audit history but leave projected model history.
- A newly registered core or plugin-defined **Context Source** absent from the current snapshot emits its baseline rendering once at the next **Safe Step Boundary**.
- **Context Source** keys are stable and namespaced; duplicate keys fail composition. `SystemContext.combine(...)` preserves caller order; the **System Context Registry** evaluates producers concurrently and combines them in stable contribution-key order so rendered context remains deterministic.
- Each **Context Source** loader returns one coherent typed value. `SystemContext.make(...)` hides that value type so differently typed sources compose uniformly. Its codec compares and stores that value; its pure renderers produce model-visible baseline, update, and removal text only when needed.
- `SystemContext.initialize(...)` observes a composed **System Context** once and produces a fresh **Baseline System Context** with its **Context Snapshot**.
- `SystemContext.reconcile(...)` observes a composed **System Context** once and returns exactly one next action: unchanged, updated, replacement ready, or replacement blocked.
- `SystemContext.replace(...)` renders a fresh generation after completed compaction or another baseline-replacing transition; it reports replacement blocked while previously admitted context is unavailable.
- **Unavailable Context** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text.
- Ordinary **Context Source** loaders return values directly; loaders that intentionally use stale-while-revalidate may explicitly return **Unavailable Context**.
- Nested project instruction discovery after successful reads remains a follow-up; when implemented, discovered instructions must be admitted durably at the next **Safe Step Boundary**.
- Location-scoped services naturally re-resolve effective context when a moved session next runs in its destination location.
- Moving a Session clears its active **Context Epoch**, so the destination must initialize a complete baseline before another prompt can promote.
- Instruction discovery, source identity, persistence, and file loading belong to the instruction service; the **System Context** abstraction only composes effectful producers and renders loaded values.
- The first instruction-service slice observes global and upward project `AGENTS.md` files as one ordered aggregate **Context Source** at each **Safe Step Boundary**.
- Built-in and instruction context producers register through the **System Context Registry** with stable contribution keys. Plugin-defined context registration and hot-reload lifecycle remain a follow-up built on the same scoped registry seam.
- Selected-agent available-skill guidance is a **Context Source** composed with Location-wide registry sources immediately before Context Epoch admission. It lists only names and descriptions permitted for that agent; skill bodies and locations are exposed only through the permission-checked `skill` tool.
- The first **Step** renders the latest complete **Instruction Baseline** and creates its **InstructionCheckpoint** without emitting a redundant **Instruction Update**; an unavailable initial source blocks the Step instead of persisting an incomplete baseline.
- Instruction preparation precedes durable input promotion on every Step so an unavailable first baseline leaves pending input untouched and later updates enter history before newly promoted input.
- Completed compaction rebaselines the **InstructionCheckpoint** from current **Instructions** and removes earlier **Instruction Updates** from active projected model history while preserving durable audit history.
- A newly composed **Instruction Source** absent from **Applied Instructions** emits its baseline rendering once at the next **Safe Step Boundary**.
- **Unavailable Instruction Source** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text.
- **InstructionDiscovery** observes ambient instructions as one ordered aggregate **Instruction Source**.
- Ambient discovery reads global and upward-project `AGENTS.md` files and honors `OPENCODE_DISABLE_PROJECT_CONFIG` for project files.
- After a successful internal file or directory read, nearby `AGENTS.md` files toward the Location root are injected once per Session as durable synthetic instruction messages.
- **InstructionEntry** stores API-managed per-Session JSON values. Each entry contributes one `api/<key>` **Instruction Source**, so adding, replacing, or removing an entry is reconciled at the next **Safe Step Boundary**.
- Location-scoped instruction producers naturally re-resolve when a moved Session next runs in its destination Location.
- Moving a Session resets its **InstructionCheckpoint**, so the destination must initialize a complete baseline before another prompt can promote. Committed revert also resets the checkpoint.
- Selected-agent available-skill guidance is an **Instruction Source** composed explicitly by the runner. It lists only names and descriptions permitted for that agent; skill bodies and locations are exposed only through the permission-checked `skill` tool.
- The selected agent and model are sampled when a **Step** starts. Changes admitted after that boundary apply to the next Step and do not restart the current Step.
- Selected-agent available-skill guidance remains a **Context Source**. An agent switch that changes that guidance produces a **Mid-Conversation System Message** while preserving the current baseline.
- An agent switch that changes selected-agent guidance produces an **Instruction Update** while preserving the current baseline.
- Local tool authorization and pending permission requests retain the effective agent of the **Step** that issued the call; a later agent switch cannot change that call's policy.
- Context source changes never wake idle sessions; the next naturally scheduled **Safe Step Boundary** loads and compares current values lazily.
- Once admitted, a **Mid-Conversation System Message** remains durable even if the following **Physical Attempt** fails and is replayed unchanged on retry.
- **Mid-Conversation System Messages** remain durable Session-message history; normal user-facing transcript surfaces may hide them.
- The date **Context Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later.
- A **Context Epoch** begins with one immutable **Baseline System Context**.
- A **Baseline System Context** is stored durably and reused verbatim across process restarts within its **Context Epoch**.
- A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix.
- Completed compaction starts a new **Context Epoch** on the next **Physical Attempt**, folding the current complete **System Context** into a fresh baseline and removing earlier **Mid-Conversation System Messages** from active model history.
- A model/provider switch preserves the current **Context Epoch** and chronological conversation history; the new selection applies to the next **Step**.
- Instruction source changes never wake idle Sessions; the next naturally scheduled **Safe Step Boundary** loads and compares current values lazily.
- Once admitted, an **Instruction Update** remains durable even if the following **Physical Attempt** fails and is replayed unchanged on retry.
- **Instruction Updates** remain durable Session-message history; normal user-facing transcript surfaces may hide them.
- The date **Instruction Source** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later.
- An **Instruction Baseline** is stored durably and reused verbatim across process restarts until rebaseline or reset.
- An **Instruction Baseline** durably preserves the exact joined text used for its part of the active provider-cache prefix.
- A model/provider switch preserves the current **InstructionCheckpoint** and chronological conversation history; the new selection applies to the next **Step**.
- **Native Continuation Metadata** remains in durable history. Step projection includes it only for a successful exact originating provider/model match; failed Steps and incompatible models omit opaque metadata, while non-empty visible reasoning lowers to ordinary assistant text after a model switch. This conservative relation may widen only when recorded provider tests establish compatibility.
- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding.
- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing.
@ -190,16 +195,16 @@ _Avoid_: Response envelope
- `sessions.message({ sessionID, messageID })` is a required resource lookup. An unknown Session fails with `SessionNotFoundError`; a known Session with an absent or differently owned message fails with `MessageNotFoundError` without disclosing cross-Session ownership. Absence is not represented as `undefined` across the public HTTP boundary.
- `sessions.interrupt({ sessionID })` first verifies that the durable Session exists, failing with `SessionNotFoundError` otherwise. For a known Session, interruption is idempotent: idle, already-settled, or locally unowned execution is a no-op.
- `sessions.active()` snapshots the current process's foreground Session drain registry as a record of Session IDs to `{ type: "running" }`. Missing IDs are inactive; background subagents and tasks do not make their parent Session active, and process restart clears the registry.
- `sessions.context({ sessionID })` preserves the existing message-only operation. It returns projected conversational messages selected as Session context; it does not include or represent the complete provider request context, whose baseline system context and other contributions remain separate.
- **Open question**: Should a future, separately named operation expose the complete provider request context, including baseline system context, selected source contributions, and context-epoch metadata?
- `sessions.context({ sessionID })` preserves the existing message-only operation. It returns projected **Session History**; it does not include or represent the complete **Model Context**, whose system text, **Instruction Baseline**, tools, and step-local additions remain separate.
- **Open question**: Should a future, separately named operation expose complete **Model Context**, including the instruction baseline, applied instruction metadata, tools, and step-local additions?
- `sessions.prompt(...)` exposes `resume?: boolean`. Omitting it preserves durable admission followed by an advisory execution wake; `resume: false` requests durable admit-only behavior.
- The public operation remains `sessions.prompt(...)`; `SessionInput.admit` is the internal primitive, while the public `Admission` result and `resume` option express its durable admission semantics.
- `sessions.create(...)` accepts an optional `location`. Omission resolves through the connected OpenCode instance's default or current location; an explicit value selects a known location. Networked and embedded transports use the same handler semantics.
- `sessions.switchAgent({ sessionID, agent })` is part of the common client alongside `sessions.switchModel(...)`. It affects subsequent Session activity and fails with `SessionNotFoundError` for an unknown Session.
- The **Embedded OpenCode** Layer delegates to the same scoped creation path; it does not define a second implementation.
- A **PTY Environment** adapter observes plugins in the request Location while passing the resolved PTY working directory to the hook; standalone servers use an empty adapter.
- A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise.
- When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply.
- An **Instruction Update** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise.
- When the aggregate discovered instruction set changes, its **Instruction Update** includes the complete current ordered set and supersedes the prior aggregate value; when no discovered instructions remain, the message states that previously loaded instructions no longer apply.
- Ambient project instruction discovery honors `OPENCODE_DISABLE_PROJECT_CONFIG`; global instructions remain eligible.
- Oversized textual **Model Tool Output** retains a bounded preview in Session history while its complete text moves to managed tool-output storage. Arbitrary structured-result size is a separate concern.
- One tool settlement receives one aggregate textual limit, using the configured maximum lines or UTF-8 bytes, whichever is reached first. The limit is provider-independent; token pressure belongs to context assembly and compaction.
@ -232,9 +237,9 @@ Before stabilizing the client API:
## Example dialogue
> **Dev:** "The date changed while the session was active. Should the **Mid-Conversation System Message** say what the old date was?"
> **Domain expert:** "No. Emit the newly effective date so the agent can act on the current **System Context**."
> **Dev:** "The date changed while the session was active. Should the **Instruction Update** say what the old date was?"
> **Domain expert:** "No. Emit the newly effective date so the agent can act on the current instructions."
## Flagged ambiguities
- Legacy `experimental.chat.system.transform` can mutate the assembled baseline system prompt arbitrarily, but V2 plugins do not yet expose an equivalent hook. Decide separately whether to port it, replace dynamic uses with plugin-defined **Context Sources**, or narrow its semantics.
- Legacy `experimental.chat.system.transform` can mutate assembled system text arbitrarily, but V2 plugins do not yet expose an equivalent hook. Decide separately whether to port it, model dynamic uses as explicit **Instruction Sources**, or narrow its semantics.

View file

@ -196,33 +196,35 @@ export type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["param
export type Endpoint4_18Output = EffectValue<ReturnType<RawClient["server.session"]["session.context"]>>["data"]
export type SessionContextOperation<E = never> = (input: Endpoint4_18Input) => Effect.Effect<Endpoint4_18Output, E>
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.context.entry.list"]>[0]
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.instructions.entry.list"]>[0]
export type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] }
export type Endpoint4_19Output = EffectValue<
ReturnType<RawClient["server.session"]["session.context.entry.list"]>
ReturnType<RawClient["server.session"]["session.instructions.entry.list"]>
>["data"]
export type SessionListContextEntriesOperation<E = never> = (
export type SessionInstructionsEntryListOperation<E = never> = (
input: Endpoint4_19Input,
) => Effect.Effect<Endpoint4_19Output, E>
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.context.entry.put"]>[0]
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.instructions.entry.put"]>[0]
export type Endpoint4_20Input = {
readonly sessionID: Endpoint4_20Request["params"]["sessionID"]
readonly key: Endpoint4_20Request["params"]["key"]
readonly value: Endpoint4_20Request["payload"]["value"]
}
export type Endpoint4_20Output = EffectValue<ReturnType<RawClient["server.session"]["session.context.entry.put"]>>
export type SessionPutContextEntryOperation<E = never> = (
export type Endpoint4_20Output = EffectValue<ReturnType<RawClient["server.session"]["session.instructions.entry.put"]>>
export type SessionInstructionsEntryPutOperation<E = never> = (
input: Endpoint4_20Input,
) => Effect.Effect<Endpoint4_20Output, E>
type Endpoint4_21Request = Parameters<RawClient["server.session"]["session.context.entry.remove"]>[0]
type Endpoint4_21Request = Parameters<RawClient["server.session"]["session.instructions.entry.remove"]>[0]
export type Endpoint4_21Input = {
readonly sessionID: Endpoint4_21Request["params"]["sessionID"]
readonly key: Endpoint4_21Request["params"]["key"]
}
export type Endpoint4_21Output = EffectValue<ReturnType<RawClient["server.session"]["session.context.entry.remove"]>>
export type SessionRemoveContextEntryOperation<E = never> = (
export type Endpoint4_21Output = EffectValue<
ReturnType<RawClient["server.session"]["session.instructions.entry.remove"]>
>
export type SessionInstructionsEntryRemoveOperation<E = never> = (
input: Endpoint4_21Input,
) => Effect.Effect<Endpoint4_21Output, E>
@ -273,9 +275,13 @@ export interface SessionApi<E = never> {
readonly revertClear: SessionRevertClearOperation<E>
readonly revertCommit: SessionRevertCommitOperation<E>
readonly context: SessionContextOperation<E>
readonly listContextEntries: SessionListContextEntriesOperation<E>
readonly putContextEntry: SessionPutContextEntryOperation<E>
readonly removeContextEntry: SessionRemoveContextEntryOperation<E>
readonly instructions: {
readonly entry: {
readonly list: SessionInstructionsEntryListOperation<E>
readonly put: SessionInstructionsEntryPutOperation<E>
readonly remove: SessionInstructionsEntryRemoveOperation<E>
}
}
readonly log: SessionLogOperation<E>
readonly interrupt: SessionInterruptOperation<E>
readonly background: SessionBackgroundOperation<E>

View file

@ -266,33 +266,33 @@ const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18I
Effect.map((value) => value.data),
)
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.context.entry.list"]>[0]
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.instructions.entry.list"]>[0]
type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] }
const Endpoint4_19 = (raw: RawClient["server.session"]) => (input: Endpoint4_19Input) =>
raw["session.context.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.context.entry.put"]>[0]
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.instructions.entry.put"]>[0]
type Endpoint4_20Input = {
readonly sessionID: Endpoint4_20Request["params"]["sessionID"]
readonly key: Endpoint4_20Request["params"]["key"]
readonly value: Endpoint4_20Request["payload"]["value"]
}
const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) =>
raw["session.context.entry.put"]({
raw["session.instructions.entry.put"]({
params: { sessionID: input["sessionID"], key: input["key"] },
payload: { value: input["value"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint4_21Request = Parameters<RawClient["server.session"]["session.context.entry.remove"]>[0]
type Endpoint4_21Request = Parameters<RawClient["server.session"]["session.instructions.entry.remove"]>[0]
type Endpoint4_21Input = {
readonly sessionID: Endpoint4_21Request["params"]["sessionID"]
readonly key: Endpoint4_21Request["params"]["key"]
}
const Endpoint4_21 = (raw: RawClient["server.session"]) => (input: Endpoint4_21Input) =>
raw["session.context.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
Effect.mapError(mapClientError),
)
@ -354,9 +354,7 @@ const adaptGroup4 = (raw: RawClient["server.session"]) => ({
revertClear: Endpoint4_16(raw),
revertCommit: Endpoint4_17(raw),
context: Endpoint4_18(raw),
listContextEntries: Endpoint4_19(raw),
putContextEntry: Endpoint4_20(raw),
removeContextEntry: Endpoint4_21(raw),
instructions: { entry: { list: Endpoint4_19(raw), put: Endpoint4_20(raw), remove: Endpoint4_21(raw) } },
log: Endpoint4_22(raw),
interrupt: Endpoint4_23(raw),
background: Endpoint4_24(raw),

View file

@ -18,7 +18,11 @@ type PromisifyOperation<Operation> = Operation extends (
? (...args: Args) => Promise<Success>
: Operation extends (...args: infer Args) => Stream.Stream<infer Success, unknown, unknown>
? (...args: Args) => AsyncIterable<Success>
: Operation
: Operation extends (...args: infer _Args) => unknown
? Operation
: Operation extends object
? PromisifyApi<Operation>
: Operation
type PromisifyApi<Api> = {
readonly [Name in keyof Api]: PromisifyOperation<Api[Name]>

View file

@ -43,12 +43,12 @@ import type {
SessionRevertCommitOutput,
SessionContextInput,
SessionContextOutput,
SessionListContextEntriesInput,
SessionListContextEntriesOutput,
SessionPutContextEntryInput,
SessionPutContextEntryOutput,
SessionRemoveContextEntryInput,
SessionRemoveContextEntryOutput,
SessionInstructionsEntryListInput,
SessionInstructionsEntryListOutput,
SessionInstructionsEntryPutInput,
SessionInstructionsEntryPutOutput,
SessionInstructionsEntryRemoveInput,
SessionInstructionsEntryRemoveOutput,
SessionLogInput,
SessionLogOutput,
SessionInterruptInput,
@ -607,40 +607,44 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
listContextEntries: (input: SessionListContextEntriesInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionListContextEntriesOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/context-entry`,
successStatus: 200,
declaredStatuses: [404, 400, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
putContextEntry: (input: SessionPutContextEntryInput, requestOptions?: RequestOptions) =>
request<SessionPutContextEntryOutput>(
{
method: "PUT",
path: `/api/session/${encodeURIComponent(input.sessionID)}/context-entry/${encodeURIComponent(input.key)}`,
body: { value: input["value"] },
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
},
requestOptions,
),
removeContextEntry: (input: SessionRemoveContextEntryInput, requestOptions?: RequestOptions) =>
request<SessionRemoveContextEntryOutput>(
{
method: "DELETE",
path: `/api/session/${encodeURIComponent(input.sessionID)}/context-entry/${encodeURIComponent(input.key)}`,
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
},
requestOptions,
),
instructions: {
entry: {
list: (input: SessionInstructionsEntryListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionInstructionsEntryListOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/instructions/entries`,
successStatus: 200,
declaredStatuses: [404, 400, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
put: (input: SessionInstructionsEntryPutInput, requestOptions?: RequestOptions) =>
request<SessionInstructionsEntryPutOutput>(
{
method: "PUT",
path: `/api/session/${encodeURIComponent(input.sessionID)}/instructions/entries/${encodeURIComponent(input.key)}`,
body: { value: input["value"] },
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
},
requestOptions,
),
remove: (input: SessionInstructionsEntryRemoveInput, requestOptions?: RequestOptions) =>
request<SessionInstructionsEntryRemoveOutput>(
{
method: "DELETE",
path: `/api/session/${encodeURIComponent(input.sessionID)}/instructions/entries/${encodeURIComponent(input.key)}`,
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
},
requestOptions,
),
},
},
log: (input: SessionLogInput, requestOptions?: RequestOptions): AsyncIterable<SessionLogOutput> =>
sse<SessionLogOutput>(
{

View file

@ -1091,26 +1091,26 @@ export type SessionContextOutput = {
>
}["data"]
export type SessionListContextEntriesInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionInstructionsEntryListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionListContextEntriesOutput = {
export type SessionInstructionsEntryListOutput = {
readonly data: ReadonlyArray<{ readonly key: string; readonly value: JsonValue }>
}["data"]
export type SessionPutContextEntryInput = {
export type SessionInstructionsEntryPutInput = {
readonly sessionID: { readonly sessionID: string; readonly key: string }["sessionID"]
readonly key: { readonly sessionID: string; readonly key: string }["key"]
readonly value: { readonly value: JsonValue }["value"]
}
export type SessionPutContextEntryOutput = void
export type SessionInstructionsEntryPutOutput = void
export type SessionRemoveContextEntryInput = {
export type SessionInstructionsEntryRemoveInput = {
readonly sessionID: { readonly sessionID: string; readonly key: string }["sessionID"]
readonly key: { readonly sessionID: string; readonly key: string }["key"]
}
export type SessionRemoveContextEntryOutput = void
export type SessionInstructionsEntryRemoveOutput = void
export type SessionLogInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
@ -1212,7 +1212,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.context.updated"
readonly type: "session.instructions.updated"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly text: string }
@ -4506,7 +4506,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.context.updated"
readonly type: "session.instructions.updated"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly text: string }

View file

@ -2,9 +2,39 @@ import { Effect } from "effect"
import { OpenCode as EffectOpenCode, type AppApi as EffectApi } from "../src/effect"
type EffectClient = Effect.Success<ReturnType<typeof EffectOpenCode.make>>
type PromiseClient = ReturnType<typeof import("../src/promise").OpenCode.make>
declare const effectClient: EffectClient
declare const promiseClient: PromiseClient
const effectApi: EffectApi<unknown> = effectClient
void effectApi
declare const sessionID: Parameters<typeof effectApi.session.instructions.entry.list>[0]["sessionID"]
const effectList: Effect.Effect<
ReadonlyArray<{ readonly key: string; readonly value: unknown }>,
unknown
> = effectApi.session.instructions.entry.list({ sessionID })
const effectPut: Effect.Effect<void, unknown> = effectApi.session.instructions.entry.put({
sessionID,
key: "review-notes",
value: { text: "Check the diff" },
})
const effectRemove: Effect.Effect<void, unknown> = effectApi.session.instructions.entry.remove({
sessionID,
key: "review-notes",
})
const promiseList: Promise<ReadonlyArray<{ readonly key: string; readonly value: unknown }>> =
promiseClient.session.instructions.entry.list({ sessionID: "ses_test" })
const promisePut: Promise<void> = promiseClient.session.instructions.entry.put({
sessionID: "ses_test",
key: "review-notes",
value: { text: "Check the diff" },
})
const promiseRemove: Promise<void> = promiseClient.session.instructions.entry.remove({
sessionID: "ses_test",
key: "review-notes",
})
void [effectList, effectPut, effectRemove, promiseList, promisePut, promiseRemove]

View file

@ -27,6 +27,57 @@ test("session.get returns the decoded Effect projection", async () => {
expect(DateTime.toEpochMillis(result.time.created)).toBe(1_717_171_717_000)
})
test("session instructions methods use the public HTTP contract", async () => {
const requests: Array<{ method: string; url: string; body?: unknown }> = []
const instructions = [{ key: "review-notes", value: { text: "Check the diff", priority: 1 } }]
const httpClient = HttpClient.make((request) => {
requests.push({
method: request.method,
url: request.url,
body: request.body._tag === "Uint8Array" ? JSON.parse(new TextDecoder().decode(request.body.body)) : undefined,
})
return Effect.succeed(
HttpClientResponse.fromWeb(
request,
request.method === "GET" ? Response.json({ data: instructions }) : new Response(null, { status: 204 }),
),
)
})
const result = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
const listed = yield* client.session.instructions.entry.list({ sessionID: Session.ID.make("ses_test") })
yield* client.session.instructions.entry.put({
sessionID: Session.ID.make("ses_test"),
key: "review-notes",
value: instructions[0].value,
})
yield* client.session.instructions.entry.remove({
sessionID: Session.ID.make("ses_test"),
key: "review-notes",
})
return listed
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(result).toEqual(instructions)
expect(requests).toEqual([
{
method: "GET",
url: "http://localhost:3000/api/session/ses_test/instructions/entries",
body: undefined,
},
{
method: "PUT",
url: "http://localhost:3000/api/session/ses_test/instructions/entries/review-notes",
body: { value: { text: "Check the diff", priority: 1 } },
},
{
method: "DELETE",
url: "http://localhost:3000/api/session/ses_test/instructions/entries/review-notes",
body: undefined,
},
])
})
test("event.subscribe exposes and decodes the native Effect event stream", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(

View file

@ -148,6 +148,51 @@ test("session.get returns the wire projection", async () => {
expect(result.time.created).toBe(1_717_171_717_000)
})
test("session instructions methods use the public HTTP contract", async () => {
const requests: Array<{ method: string; url: string; body?: unknown }> = []
const instructions = [{ key: "review-notes", value: { text: "Check the diff", priority: 1 } }]
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
requests.push({
method: request.method,
url: request.url,
body: request.method === "PUT" ? await request.json() : undefined,
})
if (request.method === "GET") return Response.json({ data: instructions })
return new Response(null, { status: 204 })
},
})
const result = await client.session.instructions.entry.list({ sessionID: "ses_test" })
await client.session.instructions.entry.put({
sessionID: "ses_test",
key: "review-notes",
value: instructions[0].value,
})
await client.session.instructions.entry.remove({ sessionID: "ses_test", key: "review-notes" })
expect(result).toEqual(instructions)
expect(requests).toEqual([
{
method: "GET",
url: "http://localhost:3000/api/session/ses_test/instructions/entries",
body: undefined,
},
{
method: "PUT",
url: "http://localhost:3000/api/session/ses_test/instructions/entries/review-notes",
body: { value: { text: "Check the diff", priority: 1 } },
},
{
method: "DELETE",
url: "http://localhost:3000/api/session/ses_test/instructions/entries/review-notes",
body: undefined,
},
])
})
test("event.subscribe exposes the Promise event stream wire projection", async () => {
const client = OpenCode.make({
baseUrl: "http://localhost:3000",

View file

@ -2350,12 +2350,12 @@
"summary": "Get session context"
}
},
"/api/session/{sessionID}/context-entry": {
"/api/session/{sessionID}/instructions/entries": {
"get": {
"tags": [
"sessions"
],
"operationId": "v2.session.context.entry.list",
"operationId": "v2.session.instructions.entry.list",
"parameters": [
{
"name": "sessionID",
@ -2383,7 +2383,7 @@
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SessionContextEntry.Info"
"$ref": "#/components/schemas/InstructionEntry.Info"
}
}
},
@ -2433,16 +2433,16 @@
}
}
},
"description": "List API-managed context entries attached to the session's system context.",
"summary": "List context entries"
"description": "List API-managed instruction entries attached to the session.",
"summary": "List instruction entries"
}
},
"/api/session/{sessionID}/context-entry/{key}": {
"/api/session/{sessionID}/instructions/entries/{key}": {
"put": {
"tags": [
"sessions"
],
"operationId": "v2.session.context.entry.put",
"operationId": "v2.session.instructions.entry.put",
"parameters": [
{
"name": "sessionID",
@ -2461,7 +2461,7 @@
"name": "key",
"in": "path",
"schema": {
"$ref": "#/components/schemas/SessionContextEntry.Key"
"$ref": "#/components/schemas/InstructionEntry.Key"
},
"required": true
}
@ -2509,8 +2509,8 @@
}
}
},
"description": "Attach or replace one durable context entry. The value is rendered into the session's system context; changes announce as updates at the next turn boundary.",
"summary": "Put context entry",
"description": "Attach or replace one durable instruction entry. Changes announce as updates at the next step boundary.",
"summary": "Put instruction entry",
"requestBody": {
"content": {
"application/json": {
@ -2533,7 +2533,7 @@
"tags": [
"sessions"
],
"operationId": "v2.session.context.entry.remove",
"operationId": "v2.session.instructions.entry.remove",
"parameters": [
{
"name": "sessionID",
@ -2552,7 +2552,7 @@
"name": "key",
"in": "path",
"schema": {
"$ref": "#/components/schemas/SessionContextEntry.Key"
"$ref": "#/components/schemas/InstructionEntry.Key"
},
"required": true
}
@ -2600,8 +2600,8 @@
}
}
},
"description": "Remove one context entry; the removal is announced to the model at the next turn boundary.",
"summary": "Remove context entry"
"description": "Remove one instruction entry; the removal is announced to the model at the next step boundary.",
"summary": "Remove instruction entry"
}
},
"/api/session/{sessionID}/log": {
@ -11692,7 +11692,7 @@
}
]
},
"SessionContextEntry.Key": {
"InstructionEntry.Key": {
"type": "string",
"allOf": [
{
@ -11701,11 +11701,11 @@
}
]
},
"SessionContextEntry.Info": {
"InstructionEntry.Info": {
"type": "object",
"properties": {
"key": {
"$ref": "#/components/schemas/SessionContextEntry.Key"
"$ref": "#/components/schemas/InstructionEntry.Key"
},
"value": {}
},
@ -12385,7 +12385,7 @@
],
"additionalProperties": false
},
"session.next.context.updated": {
"session.next.instructions.updated": {
"type": "object",
"properties": {
"id": {
@ -12402,7 +12402,7 @@
"type": {
"type": "string",
"enum": [
"session.next.context.updated"
"session.next.instructions.updated"
]
},
"durable": {
@ -14917,7 +14917,7 @@
"$ref": "#/components/schemas/session.next.prompt.admitted"
},
{
"$ref": "#/components/schemas/session.next.context.updated"
"$ref": "#/components/schemas/session.next.instructions.updated"
},
{
"$ref": "#/components/schemas/session.next.synthetic"
@ -26060,7 +26060,7 @@
"$ref": "#/components/schemas/session.next.execution.settled"
},
{
"$ref": "#/components/schemas/session.next.context.updated"
"$ref": "#/components/schemas/session.next.instructions.updated"
},
{
"$ref": "#/components/schemas/session.next.synthetic"

View file

@ -54,7 +54,9 @@ const json = (value: unknown, status = 200) =>
const singleOperation = (operation: Record<string, unknown>, method = "get"): Document => ({
openapi: "3.1.0",
paths: { "/test": { [method]: { operationId: "test", responses: { 200: { description: "Success" } }, ...operation } } },
paths: {
"/test": { [method]: { operationId: "test", responses: { 200: { description: "Success" } }, ...operation } },
},
})
describe("OpenAPI.fromSpec", () => {
@ -94,7 +96,12 @@ describe("OpenAPI.fromSpec", () => {
const remove = toolAt(api.tools, "users.remove")
expect(api.skipped).toEqual([])
if (!Tool.isDefinition(get) || !Tool.isDefinition(create) || !Tool.isDefinition(search) || !Tool.isDefinition(remove)) {
if (
!Tool.isDefinition(get) ||
!Tool.isDefinition(create) ||
!Tool.isDefinition(search) ||
!Tool.isDefinition(remove)
) {
throw new Error("happy-path fixture did not generate every operation")
}
expect(inputTypeScript(get)).toBe(
@ -110,7 +117,8 @@ describe("OpenAPI.fromSpec", () => {
const result = await Effect.runPromise(
CodeMode.make({ tools: { api: api.tools } })
.execute(`
.execute(
`
const user = await tools.api.users.get({
userId: "user-1",
include: ["profile", "permissions"],
@ -128,7 +136,8 @@ describe("OpenAPI.fromSpec", () => {
})
const removed = await tools.api.users.remove({ userId: "user-1" })
return { user, created, summary, removed }
`)
`,
)
.pipe(Effect.provide(client.layer)),
)
@ -196,11 +205,11 @@ describe("OpenAPI.fromSpec", () => {
if (!Tool.isDefinition(switchAgent)) throw new Error("v2.session.switchAgent was not generated")
expect(inputTypeScript(switchAgent)).toBe("{ sessionID: string; agent: string }")
const contextEntryPut = toolAt(result.tools, "v2.session.contextEntry.put")
expect(Tool.isDefinition(contextEntryPut)).toBe(true)
if (!Tool.isDefinition(contextEntryPut)) throw new Error("v2.session.contextEntry.put was not generated")
expect(inputTypeScript(contextEntryPut)).toBe("{ sessionID: string; key: string; value: unknown }")
expect(toolAt(result.tools, "v2_session_context_entry_put_2")).toBeUndefined()
const instructionPut = toolAt(result.tools, "v2.session.instructions.entry.put")
expect(Tool.isDefinition(instructionPut)).toBe(true)
if (!Tool.isDefinition(instructionPut)) throw new Error("v2.session.instructions.entry.put was not generated")
expect(inputTypeScript(instructionPut)).toBe("{ sessionID: string; key: string; value: unknown }")
expect(toolAt(result.tools, "v2_session_instructions_entry_put_2")).toBeUndefined()
expect(toolAt(result.tools, "v2.pty.connect")).toBeUndefined()
expect(toolAt(result.tools, "v2.session.log")).toBeUndefined()
expect(toolAt(result.tools, "v2.event.subscribe")).toBeUndefined()
@ -481,9 +490,9 @@ describe("OpenAPI.fromSpec", () => {
expect(url.searchParams.get("nullable")).toBe("null")
expect(url.searchParams.get("constructor")).toBe("safe")
expect(client.requests[0]!.headers.meta).toBe("a=b,c=d")
await expect(
Effect.runPromise(tool.run({ keys: [undefined] }).pipe(Effect.provide(client.layer))),
).rejects.toThrow("unsupported nested value")
await expect(Effect.runPromise(tool.run({ keys: [undefined] }).pipe(Effect.provide(client.layer)))).rejects.toThrow(
"unsupported nested value",
)
})
test("skips unsupported parameter encodings and malformed security", () => {
@ -585,7 +594,10 @@ describe("OpenAPI.fromSpec", () => {
test("applies authentication carriers without prototype or collision loss", async () => {
const client = recordingClient(() => json({ ok: true }))
const authenticated = (security: ReadonlyArray<Record<string, ReadonlyArray<string>>>, schemes: Record<string, unknown>) =>
const authenticated = (
security: ReadonlyArray<Record<string, ReadonlyArray<string>>>,
schemes: Record<string, unknown>,
) =>
OpenAPI.fromSpec({
baseUrl,
spec: { ...singleOperation({}), security, components: { securitySchemes: schemes } },
@ -601,13 +613,10 @@ describe("OpenAPI.fromSpec", () => {
expect(new URL(client.requests[0]!.url).searchParams.get("__proto__")).toBe("secret")
const duplicate = toolAt(
authenticated(
[{ first: [], second: [] }],
{
first: { type: "apiKey", in: "header", name: "x-key" },
second: { type: "apiKey", in: "header", name: "x-key" },
},
).tools,
authenticated([{ first: [], second: [] }], {
first: { type: "apiKey", in: "header", name: "x-key" },
second: { type: "apiKey", in: "header", name: "x-key" },
}).tools,
"test",
)
if (!Tool.isDefinition(duplicate)) throw new Error("duplicate auth tool was not generated")
@ -632,8 +641,7 @@ describe("OpenAPI.fromSpec", () => {
},
},
auth: {
resolve: ({ name }) =>
Effect.succeed(name === "bearer" ? { type: "bearer", token: "secret" } : undefined),
resolve: ({ name }) => Effect.succeed(name === "bearer" ? { type: "bearer", token: "secret" } : undefined),
},
})
const alternativeTool = toolAt(alternative.tools, "test")
@ -765,9 +773,7 @@ describe("OpenAPI.fromSpec", () => {
const oversized = recordingClient(
() => new Response(null, { headers: { "content-length": String(50 * 1024 * 1024 + 1) } }),
)
const malformed = recordingClient(
() => new Response("{", { headers: { "content-type": "application/json" } }),
)
const malformed = recordingClient(() => new Response("{", { headers: { "content-type": "application/json" } }))
const chunked = recordingClient(() => new Response(new Uint8Array(50 * 1024 * 1024 + 1)))
await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(oversized.layer)))).rejects.toThrow(

View file

@ -19,7 +19,7 @@
"./effect/layer-node": "./src/effect/layer-node.ts",
"./effect/app-node": "./src/effect/app-node.ts",
"./session/runner": "./src/session/runner/index.ts",
"./system-context": "./src/system-context/index.ts",
"./instructions": "./src/instructions/index.ts",
"./*": "./src/*.ts"
},
"imports": {

View file

@ -1,9 +1,9 @@
{
"version": "7",
"dialect": "sqlite",
"id": "96e9fe64-d810-4102-8f79-3317a88bb6d2",
"id": "992b24b9-f3e9-41f5-87a5-4917d1423169",
"prevIds": [
"22e57fed-b9b8-4e94-a3b4-f94bece680a8"
"96e9fe64-660f-4a73-9414-b38bb7eac290"
],
"ddl": [
{
@ -50,6 +50,14 @@
"name": "project",
"entityType": "tables"
},
{
"name": "instruction_checkpoint",
"entityType": "tables"
},
{
"name": "instruction_entry",
"entityType": "tables"
},
{
"name": "message",
"entityType": "tables"
@ -58,14 +66,6 @@
"name": "part",
"entityType": "tables"
},
{
"name": "session_context_epoch",
"entityType": "tables"
},
{
"name": "session_context_entry",
"entityType": "tables"
},
{
"name": "session_input",
"entityType": "tables"
@ -786,6 +786,96 @@
"entityType": "columns",
"table": "project"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "instruction_checkpoint"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "baseline",
"entityType": "columns",
"table": "instruction_checkpoint"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "snapshot",
"entityType": "columns",
"table": "instruction_checkpoint"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "baseline_seq",
"entityType": "columns",
"table": "instruction_checkpoint"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "instruction_entry"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "key",
"entityType": "columns",
"table": "instruction_entry"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "value",
"entityType": "columns",
"table": "instruction_entry"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "instruction_entry"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "instruction_entry"
},
{
"type": "text",
"notNull": false,
@ -896,96 +986,6 @@
"entityType": "columns",
"table": "part"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "session_context_epoch"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "baseline",
"entityType": "columns",
"table": "session_context_epoch"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "snapshot",
"entityType": "columns",
"table": "session_context_epoch"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "baseline_seq",
"entityType": "columns",
"table": "session_context_epoch"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "session_context_entry"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "key",
"entityType": "columns",
"table": "session_context_entry"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "value",
"entityType": "columns",
"table": "session_context_entry"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "session_context_entry"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "session_context_entry"
},
{
"type": "text",
"notNull": false,
@ -1621,6 +1621,36 @@
"entityType": "fks",
"table": "project_directory"
},
{
"columns": [
"session_id"
],
"tableTo": "session",
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_instruction_checkpoint_session_id_session_id_fk",
"entityType": "fks",
"table": "instruction_checkpoint"
},
{
"columns": [
"session_id"
],
"tableTo": "session",
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_instruction_entry_session_id_session_id_fk",
"entityType": "fks",
"table": "instruction_entry"
},
{
"columns": [
"session_id"
@ -1651,36 +1681,6 @@
"entityType": "fks",
"table": "part"
},
{
"columns": [
"session_id"
],
"tableTo": "session",
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_session_context_epoch_session_id_session_id_fk",
"entityType": "fks",
"table": "session_context_epoch"
},
{
"columns": [
"session_id"
],
"tableTo": "session",
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_session_context_entry_session_id_session_id_fk",
"entityType": "fks",
"table": "session_context_entry"
},
{
"columns": [
"session_id"
@ -1782,9 +1782,9 @@
"key"
],
"nameExplicit": false,
"name": "session_context_entry_pk",
"name": "instruction_entry_pk",
"entityType": "pks",
"table": "session_context_entry"
"table": "instruction_entry"
},
{
"columns": [
@ -1877,6 +1877,15 @@
"table": "project",
"entityType": "pks"
},
{
"columns": [
"session_id"
],
"nameExplicit": false,
"name": "instruction_checkpoint_pk",
"table": "instruction_checkpoint",
"entityType": "pks"
},
{
"columns": [
"id"
@ -1895,15 +1904,6 @@
"table": "part",
"entityType": "pks"
},
{
"columns": [
"session_id"
],
"nameExplicit": false,
"name": "session_context_epoch_pk",
"table": "session_context_epoch",
"entityType": "pks"
},
{
"columns": [
"id"
@ -2252,4 +2252,4 @@
}
],
"renames": []
}
}

View file

@ -44,5 +44,6 @@ export const migrations = (
import("./migration/20260703090000_reset_v2_event_rename_sweep"),
import("./migration/20260703181610_event_created_column"),
import("./migration/20260703190000_reset_v2_shell_event_payloads"),
import("./migration/20260705180000_rename_instructions"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]

View file

@ -0,0 +1,17 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260705180000_rename_instructions",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session_context_entry\` RENAME TO \`instruction_entry\``)
yield* tx.run(`ALTER TABLE \`session_context_epoch\` RENAME TO \`instruction_checkpoint\``)
yield* tx.run(`
UPDATE \`event\`
SET \`type\` = 'session.instructions.updated.1'
WHERE \`type\` = 'session.context.updated.1'
`)
})
},
} satisfies DatabaseMigration.Migration

View file

@ -125,6 +125,26 @@ export default {
\`commands\` text
);
`)
yield* tx.run(`
CREATE TABLE \`instruction_checkpoint\` (
\`session_id\` text PRIMARY KEY,
\`baseline\` text NOT NULL,
\`snapshot\` text NOT NULL,
\`baseline_seq\` integer NOT NULL,
CONSTRAINT \`fk_instruction_checkpoint_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE \`instruction_entry\` (
\`session_id\` text NOT NULL,
\`key\` text NOT NULL,
\`value\` text NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
CONSTRAINT \`instruction_entry_pk\` PRIMARY KEY(\`session_id\`, \`key\`),
CONSTRAINT \`fk_instruction_entry_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE \`message\` (
\`id\` text PRIMARY KEY,
@ -146,26 +166,6 @@ export default {
CONSTRAINT \`fk_part_message_id_message_id_fk\` FOREIGN KEY (\`message_id\`) REFERENCES \`message\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE \`session_context_epoch\` (
\`session_id\` text PRIMARY KEY,
\`baseline\` text NOT NULL,
\`snapshot\` text NOT NULL,
\`baseline_seq\` integer NOT NULL,
CONSTRAINT \`fk_session_context_epoch_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE \`session_context_entry\` (
\`session_id\` text NOT NULL,
\`key\` text NOT NULL,
\`value\` text NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
CONSTRAINT \`session_context_entry_pk\` PRIMARY KEY(\`session_id\`, \`key\`),
CONSTRAINT \`fk_session_context_entry_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE \`session_input\` (
\`id\` text PRIMARY KEY,

View file

@ -1,4 +1,4 @@
export * as InstructionContext from "./instruction-context"
export * as InstructionDiscovery from "./instruction-discovery"
import { Array, Context, Effect, Layer, Schema } from "effect"
import { isAbsolute, join, relative, sep } from "path"
@ -7,22 +7,22 @@ import { Flag } from "./flag/flag"
import { Global } from "./global"
import { Location } from "./location"
import { AbsolutePath } from "./schema"
import { SystemContext } from "./system-context/index"
import { Instructions } from "./instructions/index"
import { makeLocationNode } from "./effect/app-node"
class File extends Schema.Class<File>("InstructionContext.File")({
class File extends Schema.Class<File>("InstructionDiscovery.File")({
path: AbsolutePath,
content: Schema.String,
}) {}
const Files = Schema.Array(File)
const key = SystemContext.Key.make("core/instructions")
const key = Instructions.Key.make("core/instructions")
export interface Interface {
readonly load: () => Effect.Effect<SystemContext.SystemContext>
readonly load: () => Effect.Effect<Instructions.Instructions>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/InstructionContext") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/InstructionDiscovery") {}
const layer = Layer.effect(
Service,
@ -31,8 +31,8 @@ const layer = Layer.effect(
const global = yield* Global.Service
const location = yield* Location.Service
const source = (value: ReadonlyArray<File> | SystemContext.Unavailable) =>
SystemContext.make({
const source = (value: ReadonlyArray<File> | Instructions.Unavailable) =>
Instructions.make({
key,
codec: Schema.toCodecJson(Files),
load: Effect.succeed(value),
@ -42,7 +42,7 @@ const layer = Layer.effect(
removed: () => "Previously loaded instructions no longer apply.",
})
const observe = Effect.fn("InstructionContext.observe")(function* () {
const observe = Effect.fn("InstructionDiscovery.observe")(function* () {
const start = yield* fs.resolve(location.directory)
const stop = yield* fs.resolve(location.project.directory)
const fromProject = relative(stop, start)
@ -74,7 +74,7 @@ const layer = Layer.effect(
{ concurrency: "unbounded" },
)
if (files.some((file, index) => file === undefined && discovered.has(paths[index])))
return SystemContext.unavailable
return Instructions.unavailable
return files.filter((file): file is File => file !== undefined)
})
@ -82,14 +82,14 @@ const layer = Layer.effect(
load: () =>
observe().pipe(
Effect.map((files) =>
files === SystemContext.unavailable
files === Instructions.unavailable
? source(files)
: files.length === 0
? SystemContext.empty
? Instructions.empty
: source(files),
),
Effect.catch(() => Effect.succeed(source(SystemContext.unavailable))),
Effect.catchDefect(() => Effect.succeed(source(SystemContext.unavailable))),
Effect.catch(() => Effect.succeed(source(Instructions.unavailable))),
Effect.catchDefect(() => Effect.succeed(source(Instructions.unavailable))),
),
})
}),

View file

@ -1,15 +1,15 @@
export * as SystemContextBuiltIns from "./builtins"
export * as InstructionBuiltIns from "./builtins"
import { makeLocationNode } from "../effect/app-node"
import { Context, DateTime, Effect, Layer, Schema } from "effect"
import { Location } from "../location"
import { SystemContext } from "./index"
import { Instructions } from "./index"
export interface Interface {
readonly load: () => Effect.Effect<SystemContext.SystemContext>
readonly load: () => Effect.Effect<Instructions.Instructions>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SystemContextBuiltIns") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/InstructionBuiltIns") {}
const layer = Layer.effect(
Service,
@ -23,17 +23,17 @@ const layer = Layer.effect(
` Platform: ${process.platform}`,
"</env>",
].join("\n")
const context = SystemContext.combine([
SystemContext.make({
key: SystemContext.Key.make("core/environment"),
const instructions = Instructions.combine([
Instructions.make({
key: Instructions.Key.make("core/environment"),
codec: Schema.toCodecJson(Schema.String),
load: Effect.succeed(environment),
baseline: (environment) =>
["Here is some useful information about the environment you are running in:", environment].join("\n"),
update: (_previous, environment) => ["The environment you are running in is now:", environment].join("\n"),
}),
SystemContext.make({
key: SystemContext.Key.make("core/date"),
Instructions.make({
key: Instructions.Key.make("core/date"),
codec: Schema.toCodecJson(Schema.String),
load: DateTime.nowAsDate.pipe(Effect.map((date) => date.toDateString())),
baseline: (date) => `Today's date: ${date}`,
@ -41,7 +41,7 @@ const layer = Layer.effect(
}),
])
return Service.of({ load: () => Effect.succeed(context) })
return Service.of({ load: () => Effect.succeed(instructions) })
}),
)

View file

@ -1,13 +1,13 @@
export * as SystemContext from "./index"
export * as Instructions from "./index"
import { Effect, Option, Schema } from "effect"
/**
* Models privileged system context as independently refreshable typed sources.
* Models privileged instructions as independently refreshable typed sources.
*
* `Source<A>` describes how to observe, compare, and render one value. `make`
* closes over `A`, producing an opaque `SystemContext` that composes uniformly
* with contexts built from other value types.
* closes over `A`, producing opaque `Instructions` that compose uniformly with
* instructions built from other value types.
*
* The durable `Applied` record tracks what the model was last told, per source:
* it is the model's current belief. Interpreters uphold one invariant
@ -16,21 +16,21 @@ import { Effect, Option, Schema } from "effect"
* baseline text.
*
* Returning `unavailable` means observation failed temporarily. It differs from
* removing a source from the context: the model's prior belief stands.
* removing a source from the instructions: the model's prior belief stands.
* `reconcile` retains the applied value silently, and `rebaseline` restates the
* belief by rendering the last-applied value instead of a live observation.
*
* @module
*/
/** Stable namespaced identity for one independently refreshable context source. */
/** Stable namespaced identity for one independently refreshable instruction source. */
export const Key = Schema.String.check(Schema.isPattern(/^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._/-]*$/)).pipe(
Schema.brand("SystemContext.Key"),
Schema.brand("Instructions.Key"),
)
export type Key = typeof Key.Type
/** Indicates that a source could not be observed without treating it as removed. */
export const unavailable = Symbol.for("@opencode/SystemContext.Unavailable")
export const unavailable = Symbol.for("@opencode/Instructions.Unavailable")
export type Unavailable = typeof unavailable
/** Defines one typed source before its value type is hidden by `make`. */
@ -43,11 +43,11 @@ export interface Source<A> {
readonly removed?: (previous: A) => string
}
const ContextTypeId: unique symbol = Symbol.for("@opencode/SystemContext")
const InstructionsTypeId: unique symbol = Symbol.for("@opencode/Instructions")
/** Opaque carrier for composable system context sources. */
export interface SystemContext {
readonly [ContextTypeId]: ReadonlyArray<PackedSource>
/** Opaque carrier for composable instruction sources. */
export interface Instructions {
readonly [InstructionsTypeId]: ReadonlyArray<PackedSource>
}
/** The value last applied to the model for one admitted source. */
@ -76,19 +76,19 @@ export interface Updated {
export type ReconcileResult = { readonly _tag: "Unchanged" } | Updated
export class InitializationBlocked extends Schema.TaggedErrorClass<InitializationBlocked>()(
"SystemContext.InitializationBlocked",
"Instructions.InitializationBlocked",
{ keys: Schema.Array(Key) },
) {
override get message() {
return `System context initialization blocked by unavailable sources: ${this.keys.join(", ")}`
return `Instruction initialization blocked by unavailable sources: ${this.keys.join(", ")}`
}
}
export class DuplicateKeyError extends Schema.TaggedErrorClass<DuplicateKeyError>()("SystemContext.DuplicateKeyError", {
export class DuplicateKeyError extends Schema.TaggedErrorClass<DuplicateKeyError>()("Instructions.DuplicateKeyError", {
key: Key,
}) {
override get message() {
return `Duplicate system context key: ${this.key}`
return `Duplicate instruction key: ${this.key}`
}
}
@ -112,16 +112,16 @@ interface Entry {
readonly observed: Observed | Unavailable
}
/** The identity context. */
export const empty = context([])
/** The identity instruction set. */
export const empty = instructions([])
/** Closes a typed source into a context that composes with differently typed sources. */
export function make<A>(source: Source<A>): SystemContext {
/** Closes a typed source into instructions that compose with differently typed sources. */
export function make<A>(source: Source<A>): Instructions {
const decode = Schema.decodeUnknownOption(source.codec)
const encode = Schema.encodeSync(source.codec)
const equivalent = Schema.toEquivalence(source.codec)
const baseline = (value: A) => requireText(source.key, "baseline", source.baseline(value))
return context([
return instructions([
{
key: source.key,
recall: (stored) =>
@ -179,23 +179,23 @@ export function diffByKey<A>(
}
}
/** Combines contexts in order and rejects duplicate source keys immediately. */
export function combine(values: ReadonlyArray<SystemContext>): SystemContext {
const sources = values.flatMap((value) => value[ContextTypeId])
/** Combines instructions in order and rejects duplicate source keys immediately. */
export function combine(values: ReadonlyArray<Instructions>): Instructions {
const sources = values.flatMap((value) => value[InstructionsTypeId])
assertUniqueKeys(sources)
return context(sources)
return instructions(sources)
}
const observe = (value: SystemContext) =>
const observe = (value: Instructions) =>
Effect.forEach(
value[ContextTypeId],
value[InstructionsTypeId],
(source) =>
source.load.pipe(Effect.map((observed): Entry => ({ key: source.key, recall: source.recall, observed }))),
{ concurrency: "unbounded" },
)
/** Creates the first baseline. Blocks rather than admit a baseline missing an unobservable source. */
export function initialize(value: SystemContext): Effect.Effect<Baseline, InitializationBlocked> {
export function initialize(value: Instructions): Effect.Effect<Baseline, InitializationBlocked> {
return observe(value).pipe(
Effect.flatMap((entries) => {
const blocked = entries.flatMap((entry) => (entry.observed === unavailable ? [entry.key] : []))
@ -213,7 +213,7 @@ export function initialize(value: SystemContext): Effect.Effect<Baseline, Initia
}
/** Narrates drift between current source values and the model's beliefs. Never rewrites the baseline. */
export function reconcile(value: SystemContext, previous: Applied): Effect.Effect<ReconcileResult> {
export function reconcile(value: Instructions, previous: Applied): Effect.Effect<ReconcileResult> {
return observe(value).pipe(
Effect.map((entries): ReconcileResult => {
const updates: string[] = []
@ -253,7 +253,7 @@ export function reconcile(value: SystemContext, previous: Applied): Effect.Effec
}
/** Rebuilds the baseline, restating unobservable sources from the model's last-applied beliefs. */
export function rebaseline(value: SystemContext, previous: Applied): Effect.Effect<Baseline> {
export function rebaseline(value: Instructions, previous: Applied): Effect.Effect<Baseline> {
return observe(value).pipe(
Effect.map((entries): Baseline => {
const parts: string[] = []
@ -277,8 +277,8 @@ export function rebaseline(value: SystemContext, previous: Applied): Effect.Effe
)
}
function context(sources: ReadonlyArray<PackedSource>): SystemContext {
return { [ContextTypeId]: sources }
function instructions(sources: ReadonlyArray<PackedSource>): Instructions {
return { [InstructionsTypeId]: sources }
}
function render(parts: ReadonlyArray<string>) {
@ -294,7 +294,7 @@ function isUnavailable(value: unknown): value is Unavailable {
}
function requireText(key: Key, kind: string, text: string) {
if (text.length === 0) throw new Error(`System context source ${key} rendered an empty ${kind}`)
if (text.length === 0) throw new Error(`Instruction source ${key} rendered an empty ${kind}`)
return text
}

View file

@ -44,9 +44,9 @@ import { SessionTodo } from "./session/todo"
import { SkillV2 } from "./skill"
import { SkillGuidance } from "./skill/guidance"
import { Snapshot } from "./snapshot"
import { InstructionContext } from "./instruction-context"
import { SystemContextBuiltIns } from "./system-context/builtins"
import { SessionContextEntry } from "./session/context-entry"
import { InstructionDiscovery } from "./instruction-discovery"
import { InstructionBuiltIns } from "./instructions/builtins"
import { InstructionEntry } from "./session/instruction-entry"
import { SessionInstructions } from "./session/instructions"
import { McpTool } from "./tool/mcp"
import { ReadToolFileSystem } from "./tool/read-filesystem"
@ -112,8 +112,8 @@ const locationServiceNodes = [
Pty.node,
Shell.node,
SkillV2.node,
SystemContextBuiltIns.node,
InstructionContext.node,
InstructionBuiltIns.node,
InstructionDiscovery.node,
LocationMutation.node,
FileMutation.node,
MCP.node,
@ -125,7 +125,7 @@ const locationServiceNodes = [
SkillGuidance.node,
ReferenceGuidance.node,
SessionTodo.node,
SessionContextEntry.node,
InstructionEntry.node,
Form.node,
QuestionV2.node,
Generate.node,

View file

@ -7,7 +7,7 @@ import { AgentV2 } from "../agent"
import { PermissionV2 } from "../permission"
import { McpTool } from "../tool/mcp"
import { MCP } from "./index"
import { SystemContext } from "../system-context/index"
import { Instructions } from "../instructions/index"
const Summary = Schema.Struct({
server: Schema.String,
@ -31,7 +31,7 @@ const render = (servers: ReadonlyArray<Summary>) =>
["<mcp_instructions>", ...entries(servers), "</mcp_instructions>"].join("\n")
const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary>) => {
const diff = SystemContext.diffByKey(
const diff = Instructions.diffByKey(
previous,
current,
(server) => server.server,
@ -56,7 +56,7 @@ const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary
}
export interface Interface {
readonly load: (agent: AgentV2.Selection) => Effect.Effect<SystemContext.SystemContext>
readonly load: (agent: AgentV2.Selection) => Effect.Effect<Instructions.Instructions>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/McpGuidance") {}
@ -69,9 +69,9 @@ export const layer = Layer.effect(
return Service.of({
load: Effect.fn("McpGuidance.load")(function* (selection) {
const agent = selection.info
if (!agent) return SystemContext.empty
if (!agent) return Instructions.empty
if (Flag.CODEMODE_ENABLED && PermissionV2.evaluate("execute", "*", agent.permissions).effect === "deny")
return SystemContext.empty
return Instructions.empty
const [instructions, tools] = yield* Effect.all([mcp.instructions(), mcp.tools()], {
concurrency: "unbounded",
})
@ -88,9 +88,9 @@ export const layer = Layer.effect(
)
})
.map((item) => ({ server: item.server, instructions: item.instructions }))
if (visible.length === 0) return SystemContext.empty
return SystemContext.make({
key: SystemContext.Key.make("core/mcp-guidance"),
if (visible.length === 0) return Instructions.empty
return Instructions.make({
key: Instructions.Key.make("core/mcp-guidance"),
codec: Schema.toCodecJson(Schema.Array(Summary)),
load: Effect.succeed(visible),
baseline: render,

View file

@ -3,7 +3,7 @@ export * as ReferenceGuidance from "./guidance"
import { makeLocationNode } from "../effect/app-node"
import { Context, Effect, Layer, Schema } from "effect"
import { Reference } from "../reference"
import { SystemContext } from "../system-context/index"
import { Instructions } from "../instructions/index"
const Summary = Schema.Struct({
name: Schema.String,
@ -29,7 +29,7 @@ const render = (references: ReadonlyArray<typeof Summary.Type>) =>
].join("\n")
const update = (previous: ReadonlyArray<typeof Summary.Type>, current: ReadonlyArray<typeof Summary.Type>) => {
const diff = SystemContext.diffByKey(
const diff = Instructions.diffByKey(
previous,
current,
(reference) => reference.name,
@ -54,7 +54,7 @@ const update = (previous: ReadonlyArray<typeof Summary.Type>, current: ReadonlyA
}
export interface Interface {
readonly load: () => Effect.Effect<SystemContext.SystemContext>
readonly load: () => Effect.Effect<Instructions.Instructions>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ReferenceGuidance") {}
@ -74,9 +74,9 @@ const layer = Layer.effect(
description: reference.description,
}))
.toSorted((a, b) => a.name.localeCompare(b.name))
if (available.length === 0) return SystemContext.empty
return SystemContext.make({
key: SystemContext.Key.make("core/reference-guidance"),
if (available.length === 0) return Instructions.empty
return Instructions.make({
key: Instructions.Key.make("core/reference-guidance"),
codec: Schema.toCodecJson(Schema.Array(Summary)),
load: Effect.succeed(available),
baseline: render,

View file

@ -4,7 +4,7 @@ import { Database } from "../database/database"
import { MessageDecodeError } from "./error"
import { SessionMessage } from "./message"
import { SessionSchema } from "./schema"
import { SessionContextCheckpointTable, SessionMessageTable } from "./sql"
import { InstructionCheckpointTable, SessionMessageTable } from "./sql"
type DatabaseService = Database.Interface["db"]
@ -70,9 +70,9 @@ export const load = Effect.fn("SessionHistory.load")(function* (db: DatabaseServ
const [epoch, compaction] = yield* Effect.all(
[
db
.select({ baselineSeq: SessionContextCheckpointTable.baseline_seq })
.from(SessionContextCheckpointTable)
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
.select({ baselineSeq: InstructionCheckpointTable.baseline_seq })
.from(InstructionCheckpointTable)
.where(eq(InstructionCheckpointTable.session_id, sessionID))
.get()
.pipe(Effect.orDie),
latestCompaction(db, sessionID),

View file

@ -1,37 +1,37 @@
export * as SessionContextCheckpoint from "./context-checkpoint"
export * as InstructionCheckpoint from "./instruction-checkpoint"
import { eq } from "drizzle-orm"
import { Effect, Option, Schema } from "effect"
import type { Database } from "../database/database"
import { EventV2 } from "../event"
import { SystemContext } from "../system-context/index"
import { Instructions } from "../instructions/index"
import { SessionEvent } from "./event"
import { SessionHistory } from "./history"
import { SessionSchema } from "./schema"
import { SessionContextCheckpointTable } from "./sql"
import { InstructionCheckpointTable } from "./sql"
type DatabaseService = Database.Interface["db"]
const decodeApplied = Schema.decodeUnknownOption(SystemContext.Applied)
const decodeApplied = Schema.decodeUnknownOption(Instructions.Applied)
/**
* Loads or creates the session's durable context checkpoint, narrating any
* Loads or creates the session's durable instruction checkpoint, narrating any
* drift since the model was last told as a chronological update. Completed
* compaction rebaselines; nothing else rewrites the baseline. Runs before
* input promotion so a blocked first step leaves pending inputs untouched.
*/
export const prepare = Effect.fn("SessionContextCheckpoint.prepare")(function* (
export const prepare = Effect.fn("InstructionCheckpoint.prepare")(function* (
db: DatabaseService,
events: EventV2.Interface,
context: Effect.Effect<SystemContext.SystemContext>,
instructions: Effect.Effect<Instructions.Instructions>,
sessionID: SessionSchema.ID,
) {
const [value, stored, compaction] = yield* Effect.all(
[context, find(db, sessionID), SessionHistory.latestCompaction(db, sessionID)],
[instructions, find(db, sessionID), SessionHistory.latestCompaction(db, sessionID)],
{ concurrency: "unbounded" },
)
if (!stored) {
const baseline = yield* SystemContext.initialize(value)
const baseline = yield* Instructions.initialize(value)
const baselineSeq = yield* insert(db, sessionID, baseline)
return { baseline: baseline.text, baselineSeq }
}
@ -40,28 +40,28 @@ export const prepare = Effect.fn("SessionContextCheckpoint.prepare")(function* (
// treating every source as new, re-announcing baselines as updates.
const applied = Option.getOrElse(decodeApplied(stored.snapshot), () => ({}))
if (compaction !== undefined && compaction.seq > stored.baseline_seq) {
const baseline = yield* SystemContext.rebaseline(value, applied)
const baseline = yield* Instructions.rebaseline(value, applied)
yield* rewrite(db, sessionID, compaction.seq, baseline)
return { baseline: baseline.text, baselineSeq: compaction.seq }
}
const result = yield* SystemContext.reconcile(value, applied)
const result = yield* Instructions.reconcile(value, applied)
if (result._tag === "Unchanged") return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
yield* events.publish(
SessionEvent.ContextUpdated,
SessionEvent.InstructionsUpdated,
{ sessionID, text: result.text },
{ commit: () => advance(db, sessionID, result.applied).pipe(Effect.orDie) },
)
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
})
export const reset = Effect.fn("SessionContextCheckpoint.reset")(function* (
export const reset = Effect.fn("InstructionCheckpoint.reset")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
) {
yield* db
.delete(SessionContextCheckpointTable)
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
.delete(InstructionCheckpointTable)
.where(eq(InstructionCheckpointTable.session_id, sessionID))
.run()
.pipe(Effect.orDie)
})
@ -69,8 +69,8 @@ export const reset = Effect.fn("SessionContextCheckpoint.reset")(function* (
const find = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
return yield* db
.select()
.from(SessionContextCheckpointTable)
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
.from(InstructionCheckpointTable)
.where(eq(InstructionCheckpointTable.session_id, sessionID))
.get()
.pipe(Effect.orDie)
})
@ -78,11 +78,11 @@ const find = Effect.fnUntraced(function* (db: DatabaseService, sessionID: Sessio
const insert = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
baseline: SystemContext.Baseline,
baseline: Instructions.Baseline,
) {
const baselineSeq = yield* EventV2.latestSequence(db, sessionID)
yield* db
.insert(SessionContextCheckpointTable)
.insert(InstructionCheckpointTable)
.values({
session_id: sessionID,
baseline: baseline.text,
@ -98,33 +98,33 @@ const rewrite = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
baselineSeq: number,
baseline: SystemContext.Baseline,
baseline: Instructions.Baseline,
) {
const updated = yield* db
.update(SessionContextCheckpointTable)
.update(InstructionCheckpointTable)
.set({
baseline: baseline.text,
snapshot: baseline.applied,
baseline_seq: baselineSeq,
})
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
.returning({ sessionID: SessionContextCheckpointTable.session_id })
.where(eq(InstructionCheckpointTable.session_id, sessionID))
.returning({ sessionID: InstructionCheckpointTable.session_id })
.get()
.pipe(Effect.orDie)
if (!updated) return yield* Effect.die(new Error("Context checkpoint not found"))
if (!updated) return yield* Effect.die(new Error("Instruction checkpoint not found"))
})
const advance = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
applied: SystemContext.Applied,
applied: Instructions.Applied,
) {
const updated = yield* db
.update(SessionContextCheckpointTable)
.update(InstructionCheckpointTable)
.set({ snapshot: applied })
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
.returning({ sessionID: SessionContextCheckpointTable.session_id })
.where(eq(InstructionCheckpointTable.session_id, sessionID))
.returning({ sessionID: InstructionCheckpointTable.session_id })
.get()
.pipe(Effect.orDie)
if (!updated) return yield* Effect.die(new Error("Context checkpoint not found"))
if (!updated) return yield* Effect.die(new Error("Instruction checkpoint not found"))
})

View file

@ -1,17 +1,17 @@
export * as SessionContextEntry from "./context-entry"
export * as InstructionEntry from "./instruction-entry"
import { and, asc, eq } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { SessionContextEntry } from "@opencode-ai/schema/session-context-entry"
import { InstructionEntry } from "@opencode-ai/schema/instruction-entry"
import { Database } from "../database/database"
import { makeLocationNode } from "../effect/app-node"
import { SystemContext } from "../system-context/index"
import { Instructions } from "../instructions/index"
import { SessionSchema } from "./schema"
import { SessionContextEntryTable } from "./sql"
import { InstructionEntryTable } from "./sql"
export const Key = SessionContextEntry.Key
export const Key = InstructionEntry.Key
export type Key = typeof Key.Type
export const Info = SessionContextEntry.Info
export const Info = InstructionEntry.Info
export type Info = typeof Info.Type
export interface Interface {
@ -22,11 +22,11 @@ export interface Interface {
readonly value: Schema.Json
}) => Effect.Effect<void>
readonly remove: (input: { readonly sessionID: SessionSchema.ID; readonly key: Key }) => Effect.Effect<void>
/** Produces one SystemContext source per stored entry, keyed `api/<key>`. */
readonly load: (sessionID: SessionSchema.ID) => Effect.Effect<SystemContext.SystemContext>
/** Produces one Instructions source per stored entry, keyed `api/<key>`. */
readonly load: (sessionID: SessionSchema.ID) => Effect.Effect<Instructions.Instructions>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionContextEntry") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/InstructionEntry") {}
const renderValue = (value: Schema.Json) => (typeof value === "string" ? value : JSON.stringify(value, null, 2))
@ -36,8 +36,8 @@ const renderBlock = (key: Key, value: Schema.Json) =>
// Rendering stays mechanism-neutral: the model sees session context, not how
// it was attached. Only chronological updates and removals carry narration.
const source = (entry: Info) =>
SystemContext.make({
key: SystemContext.Key.make(`api/${entry.key}`),
Instructions.make({
key: Instructions.Key.make(`api/${entry.key}`),
codec: Schema.toCodecJson(Schema.Json),
load: Effect.succeed(entry.value),
baseline: (value) => renderBlock(entry.key, value),
@ -54,49 +54,47 @@ const layer = Layer.effect(
Effect.gen(function* () {
const { db } = yield* Database.Service
const list = Effect.fn("SessionContextEntry.list")(function* (sessionID: SessionSchema.ID) {
const list = Effect.fn("InstructionEntry.list")(function* (sessionID: SessionSchema.ID) {
const rows = yield* db
.select()
.from(SessionContextEntryTable)
.where(eq(SessionContextEntryTable.session_id, sessionID))
.orderBy(asc(SessionContextEntryTable.key))
.from(InstructionEntryTable)
.where(eq(InstructionEntryTable.session_id, sessionID))
.orderBy(asc(InstructionEntryTable.key))
.all()
.pipe(Effect.orDie)
return rows.map((row) => ({ key: row.key, value: row.value }))
})
const put = Effect.fn("SessionContextEntry.put")(function* (input: {
const put = Effect.fn("InstructionEntry.put")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly key: Key
readonly value: Schema.Json
}) {
yield* db
.insert(SessionContextEntryTable)
.insert(InstructionEntryTable)
.values({ session_id: input.sessionID, key: input.key, value: input.value })
.onConflictDoUpdate({
target: [SessionContextEntryTable.session_id, SessionContextEntryTable.key],
target: [InstructionEntryTable.session_id, InstructionEntryTable.key],
set: { value: input.value, time_updated: Date.now() },
})
.run()
.pipe(Effect.orDie)
})
const remove = Effect.fn("SessionContextEntry.remove")(function* (input: {
const remove = Effect.fn("InstructionEntry.remove")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly key: Key
}) {
yield* db
.delete(SessionContextEntryTable)
.where(
and(eq(SessionContextEntryTable.session_id, input.sessionID), eq(SessionContextEntryTable.key, input.key)),
)
.delete(InstructionEntryTable)
.where(and(eq(InstructionEntryTable.session_id, input.sessionID), eq(InstructionEntryTable.key, input.key)))
.run()
.pipe(Effect.orDie)
})
const load = Effect.fn("SessionContextEntry.load")(function* (sessionID: SessionSchema.ID) {
const load = Effect.fn("InstructionEntry.load")(function* (sessionID: SessionSchema.ID) {
const entries = yield* list(sessionID)
return SystemContext.combine(entries.map(source))
return Instructions.combine(entries.map(source))
})
return Service.of({ list, put, remove, load })

View file

@ -145,7 +145,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
"session.prompt.promoted": () => Effect.void,
"session.prompt.admitted": () => Effect.void,
"session.execution.settled": () => Effect.void,
"session.context.updated": (event) =>
"session.instructions.updated": (event) =>
adapter.appendMessage(
SessionMessage.System.make({
id: SessionMessage.ID.fromEvent(event.id),
@ -154,6 +154,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
time: { created: event.created },
}),
),
"session.instructions.discovered": () => Effect.void,
"session.synthetic": (event) => {
return adapter.appendMessage(
SessionMessage.Synthetic.make({

View file

@ -13,11 +13,11 @@ import { SessionMessage } from "./message"
import { SessionMessageUpdater } from "./message-updater"
import { SessionInput } from "./input"
import { WorkspaceV2 } from "../workspace"
import { SessionContextCheckpoint } from "./context-checkpoint"
import { InstructionCheckpoint } from "./instruction-checkpoint"
import {
MessageTable,
PartTable,
SessionContextCheckpointTable,
InstructionCheckpointTable,
SessionInputTable,
SessionMessageTable,
SessionTable,
@ -220,13 +220,13 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
// folding at the same baseline horizon.
const checkpoint = yield* db
.select()
.from(SessionContextCheckpointTable)
.where(eq(SessionContextCheckpointTable.session_id, event.data.parentID))
.from(InstructionCheckpointTable)
.where(eq(InstructionCheckpointTable.session_id, event.data.parentID))
.get()
.pipe(Effect.orDie)
if (checkpoint) {
yield* db
.insert(SessionContextCheckpointTable)
.insert(InstructionCheckpointTable)
.values({ ...checkpoint, session_id: event.data.sessionID })
.run()
.pipe(Effect.orDie)
@ -497,7 +497,7 @@ const layer = Layer.effectDiscard(
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie)
yield* SessionContextCheckpoint.reset(db, event.data.sessionID)
yield* InstructionCheckpoint.reset(db, event.data.sessionID)
}),
)
yield* events.project(SessionV1.Event.Deleted, (event) =>
@ -634,7 +634,7 @@ const layer = Layer.effectDiscard(
})
}),
)
yield* events.project(SessionEvent.ContextUpdated, (event) => run(db, event))
yield* events.project(SessionEvent.InstructionsUpdated, (event) => run(db, event))
yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
yield* events.project(SessionEvent.Skill.Activated, (event) =>
insertMessage(db, event, {
@ -718,7 +718,7 @@ const layer = Layer.effectDiscard(
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie)
yield* SessionContextCheckpoint.reset(db, event.data.sessionID)
yield* InstructionCheckpoint.reset(db, event.data.sessionID)
}),
)
}),

View file

@ -5,11 +5,11 @@ import { Context, Effect } from "effect"
import { SessionSchema } from "../schema"
import type { MessageDecodeError } from "../error"
import { SessionRunnerModel } from "./model"
import type { SystemContext } from "../../system-context/index"
import type { Instructions } from "../../instructions/index"
import type { ToolOutputStore } from "../../tool-output-store"
export type RunError =
LLMError | SessionRunnerModel.Error | MessageDecodeError | SystemContext.InitializationBlocked | ToolOutputStore.Error
LLMError | SessionRunnerModel.Error | MessageDecodeError | Instructions.InitializationBlocked | ToolOutputStore.Error
/** Runs one local continuation from already-recorded Session history. */
export interface Interface {

View file

@ -10,23 +10,23 @@ import {
isContextOverflowFailure,
type ProviderErrorEvent,
} from "@opencode-ai/llm"
import { Cause, DateTime, Effect, Exit, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
import { Cause, Effect, Exit, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
import { AgentV2 } from "../../agent"
import { Config } from "../../config"
import { Database } from "../../database/database"
import { EventV2 } from "../../event"
import { Location } from "../../location"
import { SystemContext } from "../../system-context/index"
import { SystemContextBuiltIns } from "../../system-context/builtins"
import { InstructionContext } from "../../instruction-context"
import { Instructions } from "../../instructions/index"
import { InstructionBuiltIns } from "../../instructions/builtins"
import { InstructionDiscovery } from "../../instruction-discovery"
import { SkillGuidance } from "../../skill/guidance"
import { ReferenceGuidance } from "../../reference/guidance"
import { McpGuidance } from "../../mcp/guidance"
import { SessionContextEntry } from "../context-entry"
import { InstructionEntry } from "../instruction-entry"
import { QuestionTool } from "../../tool/question"
import { ToolRegistry } from "../../tool/registry"
import { ToolOutputStore } from "../../tool-output-store"
import { SessionContextCheckpoint } from "../context-checkpoint"
import { InstructionCheckpoint } from "../instruction-checkpoint"
import { SessionCompaction } from "../compaction"
import { SessionEvent } from "../event"
import { SessionHistory } from "../history"
@ -104,12 +104,12 @@ const layer = Layer.effect(
const models = yield* SessionRunnerModel.Service
const store = yield* SessionStore.Service
const location = yield* Location.Service
const builtins = yield* SystemContextBuiltIns.Service
const instructions = yield* InstructionContext.Service
const builtins = yield* InstructionBuiltIns.Service
const discovery = yield* InstructionDiscovery.Service
const skillGuidance = yield* SkillGuidance.Service
const referenceGuidance = yield* ReferenceGuidance.Service
const mcpGuidance = yield* McpGuidance.Service
const contextEntries = yield* SessionContextEntry.Service
const entries = yield* InstructionEntry.Service
const snapshots = yield* Snapshot.Service
const db = (yield* Database.Service).db
const compaction = yield* SessionCompaction.Service
@ -152,18 +152,18 @@ const layer = Layer.effect(
const isQuestionCancelled = (cause: Cause.Cause<unknown>) =>
cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof QuestionTool.CancelledError)
const loadSystemContext = (agent: AgentV2.Selection, sessionID: SessionSchema.ID) =>
const loadInstructions = (agent: AgentV2.Selection, sessionID: SessionSchema.ID) =>
Effect.all(
[
builtins.load(),
instructions.load(),
discovery.load(),
skillGuidance.load(agent),
referenceGuidance.load(),
mcpGuidance.load(agent),
contextEntries.load(sessionID),
entries.load(sessionID),
],
{ concurrency: "unbounded" },
).pipe(Effect.map(SystemContext.combine))
).pipe(Effect.map(Instructions.combine))
const attemptStep = Effect.fn("SessionRunner.attemptStep")(function* (
sessionID: SessionSchema.ID,
@ -177,10 +177,10 @@ const layer = Layer.effect(
const agent = yield* agents.select(session.agent)
// Establish what the model knows before admitting what the user said, so
// a blocked first step leaves pending inputs untouched.
const checkpoint = yield* SessionContextCheckpoint.prepare(
const checkpoint = yield* InstructionCheckpoint.prepare(
db,
events,
loadSystemContext(agent, session.id),
loadInstructions(agent, session.id),
session.id,
)
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
@ -458,12 +458,12 @@ export const node = makeLocationNode({
SessionRunnerModel.node,
SessionStore.node,
Location.node,
SystemContextBuiltIns.node,
InstructionContext.node,
InstructionBuiltIns.node,
InstructionDiscovery.node,
SkillGuidance.node,
ReferenceGuidance.node,
McpGuidance.node,
SessionContextEntry.node,
InstructionEntry.node,
SessionCompaction.node,
SessionTitle.node,
Config.node,

View file

@ -11,8 +11,7 @@ import type { SessionSchema } from "./schema"
import type { MessageID, PartID, SessionV1 } from "../v1/session"
import { WorkspaceV2 } from "../workspace"
import { Timestamps } from "../database/schema.sql"
import type { SystemContext } from "../system-context/index"
import { AgentV2 } from "../agent"
import type { Instructions } from "../instructions/index"
import type { Revert } from "@opencode-ai/schema/revert"
import type { Schema } from "effect"
@ -166,8 +165,8 @@ export const SessionInputTable = sqliteTable(
],
)
export const SessionContextEntryTable = sqliteTable(
"session_context_entry",
export const InstructionEntryTable = sqliteTable(
"instruction_entry",
{
session_id: text()
.$type<SessionSchema.ID>()
@ -180,12 +179,12 @@ export const SessionContextEntryTable = sqliteTable(
(table) => [primaryKey({ columns: [table.session_id, table.key] })],
)
export const SessionContextCheckpointTable = sqliteTable("session_context_epoch", {
export const InstructionCheckpointTable = sqliteTable("instruction_checkpoint", {
session_id: text()
.$type<SessionSchema.ID>()
.primaryKey()
.references(() => SessionTable.id, { onDelete: "cascade" }),
baseline: text().notNull(),
snapshot: text({ mode: "json" }).notNull().$type<SystemContext.Applied>(),
snapshot: text({ mode: "json" }).notNull().$type<Instructions.Applied>(),
baseline_seq: integer().notNull(),
})

View file

@ -5,7 +5,7 @@ import { Context, Effect, Layer, Schema } from "effect"
import { AgentV2 } from "../agent"
import { PermissionV2 } from "../permission"
import { SkillV2 } from "../skill"
import { SystemContext } from "../system-context/index"
import { Instructions } from "../instructions/index"
const Summary = Schema.Struct({
name: Schema.String,
@ -31,7 +31,7 @@ const render = (skills: ReadonlyArray<Summary>) =>
].join("\n")
const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary>) => {
const diff = SystemContext.diffByKey(
const diff = Instructions.diffByKey(
previous,
current,
(skill) => skill.name,
@ -56,7 +56,7 @@ const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary
}
export interface Interface {
readonly load: (agent: AgentV2.Selection) => Effect.Effect<SystemContext.SystemContext>
readonly load: (agent: AgentV2.Selection) => Effect.Effect<Instructions.Instructions>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SkillGuidance") {}
@ -69,10 +69,10 @@ const layer = Layer.effect(
return Service.of({
load: Effect.fn("SkillGuidance.load")(function* (selection) {
const agent = selection.info
if (!agent) return SystemContext.empty
if (!agent) return Instructions.empty
const permitted = SkillV2.available(yield* skills.list(), agent)
if (permitted.length === 0 && PermissionV2.evaluate("skill", "*", agent.permissions).effect === "deny")
return SystemContext.empty
return Instructions.empty
const available = permitted
.flatMap((skill) =>
skill.description === undefined || skill.autoinvoke === false
@ -80,8 +80,8 @@ const layer = Layer.effect(
: [{ name: skill.name, description: skill.description }],
)
.toSorted((a, b) => a.name.localeCompare(b.name))
return SystemContext.make({
key: SystemContext.Key.make("core/skill-guidance"),
return Instructions.make({
key: Instructions.Key.make("core/skill-guidance"),
codec: Schema.toCodecJson(Schema.Array(Summary)),
load: Effect.succeed(available),
baseline: render,

View file

@ -23,11 +23,11 @@ export const Output = Schema.Struct({
})
export const description = [
"Load a specialized skill when the task at hand matches one of the available skills in the system context.",
"Load a specialized skill when the task at hand matches one of the available skills in the instructions.",
"",
"Use this tool to inject the skill's instructions and resources into the current conversation. The output may contain detailed workflow guidance as well as references to scripts, files, etc. in the same directory as the skill.",
"",
"The skill name must match one of the available skills in the system context.",
"The skill name must match one of the available skills in the instructions.",
].join("\n")
export const toModelOutput = (skill: SkillV2.Info, files: ReadonlyArray<string>) => {

View file

@ -15,6 +15,7 @@ import eventSourcedSessionInputMigration from "@opencode-ai/core/database/migrat
import contextEpochAgentMigration from "@opencode-ai/core/database/migration/20260605042240_add_context_epoch_agent"
import simplifyIntegrationCredentialsMigration from "@opencode-ai/core/database/migration/20260611192811_lush_chimera"
import simplifySessionInputMigration from "@opencode-ai/core/database/migration/20260622202450_simplify_session_input"
import renameInstructionsMigration from "@opencode-ai/core/database/migration/20260705180000_rename_instructions"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
@ -73,11 +74,11 @@ describe("DatabaseMigration", () => {
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_input'`),
).toEqual({ name: "session_input" })
expect(
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_context_epoch'`),
).toEqual({ name: "session_context_epoch" })
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'instruction_checkpoint'`),
).toEqual({ name: "instruction_checkpoint" })
expect(
yield* db.get(
sql`SELECT name FROM pragma_table_info('session_context_epoch') WHERE name IN ('agent', 'replacement_seq', 'revision')`,
sql`SELECT name FROM pragma_table_info('instruction_checkpoint') WHERE name IN ('agent', 'replacement_seq', 'revision')`,
),
).toBeUndefined()
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: migrations.length })
@ -131,6 +132,42 @@ describe("DatabaseMigration", () => {
)
})
test("renames instruction state without losing rows or durable updates", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`)
yield* db.run(
sql`CREATE TABLE session_context_entry (session_id text NOT NULL, key text NOT NULL, value text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, PRIMARY KEY(session_id, key))`,
)
yield* db.run(
sql`CREATE TABLE session_context_epoch (session_id text PRIMARY KEY, baseline text NOT NULL, snapshot text NOT NULL, baseline_seq integer NOT NULL)`,
)
yield* db.run(sql`CREATE TABLE event (type text NOT NULL)`)
yield* db.run(sql`INSERT INTO session_context_entry VALUES ('ses_test', 'plan', '"ready"', 1, 2)`)
yield* db.run(sql`INSERT INTO session_context_epoch VALUES ('ses_test', 'baseline', '{}', 7)`)
yield* db.run(sql`INSERT INTO event VALUES ('session.context.updated.1')`)
yield* DatabaseMigration.applyOnly(db, [renameInstructionsMigration])
expect(yield* db.get(sql`SELECT * FROM instruction_entry`)).toEqual({
session_id: "ses_test",
key: "plan",
value: '"ready"',
time_created: 1,
time_updated: 2,
})
expect(yield* db.get(sql`SELECT * FROM instruction_checkpoint`)).toEqual({
session_id: "ses_test",
baseline: "baseline",
snapshot: "{}",
baseline_seq: 7,
})
expect(yield* db.get(sql`SELECT type FROM event`)).toEqual({ type: "session.instructions.updated.1" })
}),
)
})
test("keeps legacy credential fields nullable", async () => {
await run(
Effect.gen(function* () {
@ -264,10 +301,12 @@ describe("DatabaseMigration", () => {
sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('projected', 'session', 'user', 9, 1, 1, '{}')`,
)
yield* db.run(
sql`INSERT INTO session_context_epoch (session_id, baseline, snapshot, baseline_seq) VALUES ('session', 'baseline', '{}', 9)`,
sql`INSERT INTO instruction_checkpoint (session_id, baseline, snapshot, baseline_seq) VALUES ('session', 'baseline', '{}', 9)`,
)
yield* db.run(sql`ALTER TABLE instruction_checkpoint RENAME TO session_context_epoch`)
yield* db.run(sql`DELETE FROM migration WHERE id = ${simplifySessionInputMigration.id}`)
yield* DatabaseMigration.applyOnly(db, [simplifySessionInputMigration])
yield* db.run(sql`ALTER TABLE session_context_epoch RENAME TO instruction_checkpoint`)
const database = Layer.succeed(Database.Service, { db })
yield* EventV2.Service.use((service) =>
@ -299,7 +338,7 @@ describe("DatabaseMigration", () => {
(SELECT COUNT(*) FROM workspace) AS workspaces,
(SELECT COUNT(*) FROM session_input) AS sessionInputs,
(SELECT COUNT(*) FROM session_message) AS sessionMessages,
(SELECT COUNT(*) FROM session_context_epoch) AS contextEpochs,
(SELECT COUNT(*) FROM instruction_checkpoint) AS instructionCheckpoints,
(SELECT seq FROM event_sequence WHERE aggregate_id = 'session') AS seq,
(SELECT type FROM event WHERE aggregate_id = 'session') AS eventType
`),
@ -311,7 +350,7 @@ describe("DatabaseMigration", () => {
workspaces: 0,
sessionInputs: 0,
sessionMessages: 0,
contextEpochs: 0,
instructionCheckpoints: 0,
seq: 0,
eventType: "session.updated.1",
})

View file

@ -712,8 +712,8 @@ describe("EventV2", () => {
Effect.gen(function* () {
const events = yield* EventV2.Service
const aggregateID = Session.ID.create()
const received = new Array<typeof SessionEvent.ContextUpdated.Type>()
yield* events.project(SessionEvent.ContextUpdated, (event) =>
const received = new Array<typeof SessionEvent.InstructionsUpdated.Type>()
yield* events.project(SessionEvent.InstructionsUpdated, (event) =>
Effect.sync(() => {
received.push(event)
}),
@ -722,7 +722,7 @@ describe("EventV2", () => {
yield* events.replay({
id: EventV2.ID.create(),
created: DateTime.makeUnsafe(0),
type: EventV2.versionedType(SessionEvent.ContextUpdated.type, 1),
type: EventV2.versionedType(SessionEvent.InstructionsUpdated.type, 1),
seq: 0,
aggregateID,
data: { sessionID: aggregateID, text: "context" },

View file

@ -6,10 +6,10 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Global } from "@opencode-ai/core/global"
import { InstructionContext } from "@opencode-ai/core/instruction-context"
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SystemContext } from "@opencode-ai/core/system-context"
import { Instructions } from "@opencode-ai/core/instructions"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
@ -21,13 +21,13 @@ const instructionLayer = (input: {
locationServiceLayer: Layer.Layer<Location.Service>
filesystemLayer?: Layer.Layer<FSUtil.Service>
}) =>
AppNodeBuilder.build(InstructionContext.node, [
AppNodeBuilder.build(InstructionDiscovery.node, [
[Global.node, Global.layerWith({ config: input.config })],
[Location.node, input.locationServiceLayer],
...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []),
])
describe("InstructionContext", () => {
describe("InstructionDiscovery", () => {
it.live("loads global and upward project AGENTS.md files as one aggregate context", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
@ -51,7 +51,7 @@ describe("InstructionContext", () => {
await fs.writeFile(packageFile, "package")
})
const load = InstructionContext.Service.pipe(
const load = InstructionDiscovery.Service.pipe(
Effect.flatMap((service) => service.load()),
Effect.provide(
instructionLayer({
@ -69,7 +69,7 @@ describe("InstructionContext", () => {
),
)
const initialized = yield* SystemContext.initialize(yield* load)
const initialized = yield* Instructions.initialize(yield* load)
expect(initialized.text).toBe(
[
`Instructions from: ${globalFile}\nglobal`,
@ -80,13 +80,13 @@ describe("InstructionContext", () => {
expect(initialized.text).not.toContain("outside")
yield* Effect.promise(() => fs.writeFile(packageFile, "changed"))
expect(yield* SystemContext.reconcile(yield* load, initialized.applied)).toMatchObject({
expect(yield* Instructions.reconcile(yield* load, initialized.applied)).toMatchObject({
_tag: "Updated",
text: expect.stringContaining(`Instructions from: ${packageFile}\nchanged`),
})
yield* Effect.promise(() => fs.rm(packageFile))
const partial = yield* SystemContext.reconcile(yield* load, initialized.applied)
const partial = yield* Instructions.reconcile(yield* load, initialized.applied)
expect(partial).toEqual({
_tag: "Updated",
text: [
@ -98,7 +98,7 @@ describe("InstructionContext", () => {
})
yield* Effect.promise(() => Promise.all([fs.rm(globalFile), fs.rm(projectFile)]))
expect(yield* SystemContext.reconcile(yield* load, initialized.applied)).toEqual({
expect(yield* Instructions.reconcile(yield* load, initialized.applied)).toEqual({
_tag: "Updated",
text: "Previously loaded instructions no longer apply.",
applied: {},
@ -117,7 +117,7 @@ describe("InstructionContext", () => {
Effect.gen(function* () {
const file = path.join(tmp.path, "AGENTS.md")
yield* Effect.promise(() => fs.writeFile(file, ""))
const context = yield* InstructionContext.Service.pipe(
const context = yield* InstructionDiscovery.Service.pipe(
Effect.flatMap((service) => service.load()),
Effect.provide(
instructionLayer({
@ -130,7 +130,7 @@ describe("InstructionContext", () => {
),
)
expect((yield* SystemContext.initialize(context)).text).toBe(`Instructions from: ${file}\n`)
expect((yield* Instructions.initialize(context)).text).toBe(`Instructions from: ${file}\n`)
}),
),
),
@ -146,7 +146,7 @@ describe("InstructionContext", () => {
),
),
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
const context = yield* InstructionContext.Service.pipe(
const context = yield* InstructionDiscovery.Service.pipe(
Effect.flatMap((service) => service.load()),
Effect.provide(
instructionLayer({
@ -161,7 +161,7 @@ describe("InstructionContext", () => {
)
expect(
yield* SystemContext.reconcile(context, {
yield* Instructions.reconcile(context, {
"core/instructions": {
value: [{ path: "/repo/AGENTS.md", content: "old" }],
removed: "Previously loaded instructions no longer apply.",
@ -186,7 +186,7 @@ describe("InstructionContext", () => {
),
),
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
const context = yield* InstructionContext.Service.pipe(
const context = yield* InstructionDiscovery.Service.pipe(
Effect.flatMap((service) => service.load()),
Effect.provide(
instructionLayer({
@ -201,7 +201,7 @@ describe("InstructionContext", () => {
)
expect(
yield* SystemContext.reconcile(context, {
yield* Instructions.reconcile(context, {
"core/instructions": {
value: [{ path: file, content: "old" }],
removed: "Previously loaded instructions no longer apply.",
@ -230,7 +230,7 @@ describe("InstructionContext", () => {
),
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
yield* InstructionContext.Service.pipe(
yield* InstructionDiscovery.Service.pipe(
Effect.flatMap((service) => service.load()),
Effect.provide(
instructionLayer({
@ -260,7 +260,7 @@ describe("InstructionContext", () => {
let scanned = false
process.env.OPENCODE_DISABLE_PROJECT_CONFIG = "1"
yield* InstructionContext.Service.pipe(
yield* InstructionDiscovery.Service.pipe(
Effect.flatMap((service) => service.load()),
Effect.provide(
instructionLayer({
@ -292,7 +292,7 @@ describe("InstructionContext", () => {
it.effect("does not discover project instructions outside the canonical project root", () =>
Effect.gen(function* () {
let scanned = false
yield* InstructionContext.Service.pipe(
yield* InstructionDiscovery.Service.pipe(
Effect.flatMap((service) => service.load()),
Effect.provide(
instructionLayer({

View file

@ -0,0 +1,83 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import * as TestClock from "effect/testing/TestClock"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Global } from "@opencode-ai/core/global"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Instructions } from "@opencode-ai/core/instructions"
import { InstructionBuiltIns } from "@opencode-ai/core/instructions/builtins"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
const directory = AbsolutePath.make(FSUtil.resolve("/repo/packages/core"))
const projectDirectory = AbsolutePath.make(FSUtil.resolve("/repo"))
const timestamp = Date.parse("2026-06-03T12:00:00.000Z")
const localDate = (time: number) => new Date(time).toDateString()
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory },
{ projectDirectory, vcs: { type: "git", store: AbsolutePath.make(FSUtil.resolve("/repo/.git")) } },
),
),
)
const it = testEffect(
AppNodeBuilder.build(InstructionBuiltIns.node, [
[Location.node, locationLayer],
[Global.node, Global.layerWith({ config: "/global" })],
]),
)
describe("InstructionBuiltIns", () => {
it.effect("loads location-scoped environment and host-local date instructions", () =>
Effect.gen(function* () {
yield* TestClock.setTime(timestamp)
const context = yield* InstructionBuiltIns.Service
const initialized = yield* Instructions.initialize(yield* context.load())
expect(initialized.text).toBe(
[
"Here is some useful information about the environment you are running in:",
"<env>",
` Working directory: ${directory}`,
` Workspace root folder: ${projectDirectory}`,
" Is directory a git repo: yes",
` Platform: ${process.platform}`,
"</env>",
"",
`Today's date: ${localDate(timestamp)}`,
].join("\n"),
)
}),
)
it.effect("reconciles the date without repeating unchanged environment instructions", () =>
Effect.gen(function* () {
yield* TestClock.setTime(timestamp)
const context = yield* InstructionBuiltIns.Service
const initialized = yield* Instructions.initialize(yield* context.load())
yield* TestClock.setTime(timestamp + 24 * 60 * 60 * 1000)
const refreshed = yield* Instructions.reconcile(yield* context.load(), initialized.applied)
expect(refreshed).toMatchObject({
_tag: "Updated",
text: `Today's date is now: ${localDate(timestamp + 24 * 60 * 60 * 1000)}`,
})
}),
)
it.effect("does not update again within the same local calendar day", () =>
Effect.gen(function* () {
yield* TestClock.setTime(timestamp)
const context = yield* InstructionBuiltIns.Service
const initialized = yield* Instructions.initialize(yield* context.load())
yield* TestClock.setTime(timestamp + 60 * 60 * 1000)
expect(yield* Instructions.reconcile(yield* context.load(), initialized.applied)).toEqual({ _tag: "Unchanged" })
}),
)
})

View file

@ -1,17 +1,17 @@
import { describe, expect } from "bun:test"
import { Cause, Effect, Exit, Schema } from "effect"
import { SystemContext } from "@opencode-ai/core/system-context"
import { Instructions } from "@opencode-ai/core/instructions"
import { it } from "../lib/effect"
const key = SystemContext.Key.make
const key = Instructions.Key.make
const stringContext = (input: {
key: string
value: string | SystemContext.Unavailable
value: string | Instructions.Unavailable
baseline?: (value: string) => string
update?: (previous: string, current: string) => string
removed?: (value: string) => string
}) =>
SystemContext.make({
Instructions.make({
key: key(input.key),
codec: Schema.toCodecJson(Schema.String),
load: Effect.succeed(input.value),
@ -20,10 +20,10 @@ const stringContext = (input: {
removed: input.removed,
})
describe("SystemContext", () => {
describe("Instructions", () => {
it.effect("stores the canonical JSON encoding of the loaded value", () =>
Effect.gen(function* () {
const context = SystemContext.make({
const context = Instructions.make({
key: key("core/date"),
codec: Schema.toCodecJson(Schema.DateFromString),
load: Effect.succeed(new Date("2026-06-03T12:00:00.000Z")),
@ -32,15 +32,15 @@ describe("SystemContext", () => {
removed: () => "Date removed",
})
expect((yield* SystemContext.initialize(context)).applied["core/date"].value).toBe("2026-06-03T12:00:00.000Z")
expect((yield* Instructions.initialize(context)).applied["core/date"].value).toBe("2026-06-03T12:00:00.000Z")
}),
)
it.effect("loads once and initializes a baseline with the applied values", () =>
Effect.gen(function* () {
let loads = 0
const context = SystemContext.combine([
SystemContext.make({
const context = Instructions.combine([
Instructions.make({
key: key("core/date"),
codec: Schema.toCodecJson(Schema.String),
load: Effect.sync(() => {
@ -54,7 +54,7 @@ describe("SystemContext", () => {
stringContext({ key: "core/location", value: "/repo", baseline: (value) => `Directory: ${value}` }),
])
expect(yield* SystemContext.initialize(context)).toEqual({
expect(yield* Instructions.initialize(context)).toEqual({
text: "Today's date is 2026-06-03.\n\nDirectory: /repo",
applied: {
"core/date": { value: "2026-06-03", removed: "The date was removed." },
@ -71,7 +71,7 @@ describe("SystemContext", () => {
"core/date": { value: "2026-06-03", removed: "The date was removed." },
"core/location": { value: "/repo", removed: "Removed: /repo" },
}
const changed = SystemContext.combine([
const changed = Instructions.combine([
stringContext({
key: "core/date",
value: "2026-06-04",
@ -81,7 +81,7 @@ describe("SystemContext", () => {
stringContext({ key: "core/location", value: "/repo" }),
])
expect(yield* SystemContext.reconcile(changed, previous)).toEqual({
expect(yield* Instructions.reconcile(changed, previous)).toEqual({
_tag: "Updated",
text: "The date changed from 2026-06-03 to 2026-06-04.",
applied: {
@ -91,8 +91,8 @@ describe("SystemContext", () => {
})
expect(
yield* SystemContext.reconcile(
SystemContext.combine([
yield* Instructions.reconcile(
Instructions.combine([
stringContext({ key: "core/date", value: "2026-06-03", removed: () => "The date was removed." }),
stringContext({ key: "core/location", value: "/repo" }),
]),
@ -110,7 +110,7 @@ describe("SystemContext", () => {
baseline: (skill) => `Available skill: ${skill}`,
})
expect(yield* SystemContext.reconcile(context, {})).toEqual({
expect(yield* Instructions.reconcile(context, {})).toEqual({
_tag: "Updated",
text: "Available skill: effect",
applied: { "core/skills": { value: "effect" } },
@ -121,30 +121,28 @@ describe("SystemContext", () => {
it.effect("retains the belief while a source is temporarily unavailable", () =>
Effect.gen(function* () {
const previous = { "core/remote": { value: "instructions", removed: "Instructions removed" } }
const context = stringContext({ key: "core/remote", value: SystemContext.unavailable })
const context = stringContext({ key: "core/remote", value: Instructions.unavailable })
expect(yield* SystemContext.reconcile(context, previous)).toEqual({ _tag: "Unchanged" })
expect(yield* Instructions.reconcile(context, previous)).toEqual({ _tag: "Unchanged" })
}),
)
it.effect("blocks initialization while a source is unavailable", () =>
Effect.gen(function* () {
const exit = yield* SystemContext.initialize(
stringContext({ key: "core/remote", value: SystemContext.unavailable }),
const exit = yield* Instructions.initialize(
stringContext({ key: "core/remote", value: Instructions.unavailable }),
).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit))
expect(Cause.squash(exit.cause)).toEqual(
new SystemContext.InitializationBlocked({ keys: [key("core/remote")] }),
)
expect(Cause.squash(exit.cause)).toEqual(new Instructions.InitializationBlocked({ keys: [key("core/remote")] }))
}),
)
it.effect("emits the previously stored removal message", () =>
Effect.gen(function* () {
expect(
yield* SystemContext.reconcile(SystemContext.empty, {
yield* Instructions.reconcile(Instructions.empty, {
"core/instructions": { value: "contents", removed: "Instructions removed; stop applying them." },
}),
).toEqual({
@ -157,13 +155,13 @@ describe("SystemContext", () => {
it.effect("retains an unannounced removal silently", () =>
Effect.gen(function* () {
expect(yield* SystemContext.reconcile(SystemContext.empty, { "core/date": { value: "2026-06-04" } })).toEqual({
expect(yield* Instructions.reconcile(Instructions.empty, { "core/date": { value: "2026-06-04" } })).toEqual({
_tag: "Unchanged",
})
// The retained belief survives alongside other updates.
expect(
yield* SystemContext.reconcile(stringContext({ key: "core/skills", value: "effect" }), {
yield* Instructions.reconcile(stringContext({ key: "core/skills", value: "effect" }), {
"core/date": { value: "2026-06-04" },
}),
).toEqual({
@ -180,7 +178,7 @@ describe("SystemContext", () => {
it.effect("renders multiple removals in stable key order", () =>
Effect.gen(function* () {
expect(
yield* SystemContext.reconcile(SystemContext.empty, {
yield* Instructions.reconcile(Instructions.empty, {
"core/z": { value: "z", removed: "Removed z" },
"core/a": { value: "a", removed: "Removed a" },
}),
@ -190,7 +188,7 @@ describe("SystemContext", () => {
it.effect("rejects empty model-visible renderings", () =>
Effect.gen(function* () {
const exit = yield* SystemContext.initialize(
const exit = yield* Instructions.initialize(
stringContext({ key: "core/empty", value: "value", baseline: () => "" }),
).pipe(Effect.exit)
@ -202,7 +200,7 @@ describe("SystemContext", () => {
it.effect("re-announces the baseline when a stored value no longer decodes", () =>
Effect.gen(function* () {
expect(
yield* SystemContext.reconcile(stringContext({ key: "core/date", value: "2026-06-04" }), {
yield* Instructions.reconcile(stringContext({ key: "core/date", value: "2026-06-04" }), {
"core/date": { value: 42, removed: "Date removed" },
}),
).toEqual({
@ -215,7 +213,7 @@ describe("SystemContext", () => {
it.effect("renders undecodable re-announcements alongside other updates", () =>
Effect.gen(function* () {
const context = SystemContext.combine([
const context = Instructions.combine([
stringContext({
key: "core/date",
value: "2026-06-04",
@ -225,7 +223,7 @@ describe("SystemContext", () => {
])
expect(
yield* SystemContext.reconcile(context, {
yield* Instructions.reconcile(context, {
"core/date": { value: "2026-06-03" },
"core/location": { value: 42 },
}),
@ -243,7 +241,7 @@ describe("SystemContext", () => {
it.effect("rebaselines from one coherent source observation", () =>
Effect.gen(function* () {
let loads = 0
const context = SystemContext.make({
const context = Instructions.make({
key: key("core/date"),
codec: Schema.toCodecJson(Schema.String),
load: Effect.sync(() => {
@ -254,7 +252,7 @@ describe("SystemContext", () => {
update: (_previous, current) => current,
})
expect(yield* SystemContext.rebaseline(context, { "core/date": { value: "2026-06-03" } })).toEqual({
expect(yield* Instructions.rebaseline(context, { "core/date": { value: "2026-06-03" } })).toEqual({
text: "2026-06-04",
applied: { "core/date": { value: "2026-06-04" } },
})
@ -264,17 +262,17 @@ describe("SystemContext", () => {
it.effect("rebaselines an unavailable source from the last-applied belief", () =>
Effect.gen(function* () {
const context = SystemContext.combine([
const context = Instructions.combine([
stringContext({ key: "core/date", value: "2026-06-04" }),
stringContext({
key: "core/remote",
value: SystemContext.unavailable,
value: Instructions.unavailable,
baseline: (value) => `Instructions: ${value}`,
}),
])
expect(
yield* SystemContext.rebaseline(context, {
yield* Instructions.rebaseline(context, {
"core/remote": { value: "contents", removed: "Instructions removed" },
}),
).toEqual({
@ -289,11 +287,11 @@ describe("SystemContext", () => {
it.effect("drops undecodable beliefs and removed sources at rebaseline", () =>
Effect.gen(function* () {
const context = stringContext({ key: "core/remote", value: SystemContext.unavailable })
const context = stringContext({ key: "core/remote", value: Instructions.unavailable })
// Undecodable belief cannot be restated; removed source entries self-clean.
expect(
yield* SystemContext.rebaseline(context, {
yield* Instructions.rebaseline(context, {
"core/remote": { value: 42 },
"core/gone": { value: "gone" },
}),
@ -315,7 +313,7 @@ describe("SystemContext", () => {
]
expect(
SystemContext.diffByKey(
Instructions.diffByKey(
previous,
current,
(value) => value.name,
@ -337,19 +335,19 @@ describe("SystemContext", () => {
it.effect("rejects duplicate source keys", () =>
Effect.sync(() => {
expect(() =>
SystemContext.combine([
Instructions.combine([
stringContext({ key: "core/date", value: "one" }),
stringContext({ key: "core/date", value: "two" }),
]),
).toThrow(new SystemContext.DuplicateKeyError({ key: key("core/date") }))
).toThrow(new Instructions.DuplicateKeyError({ key: key("core/date") }))
}),
)
it.effect("combines contexts in order", () =>
it.effect("combines instructions in order", () =>
Effect.gen(function* () {
expect(
(yield* SystemContext.initialize(
SystemContext.combine([
(yield* Instructions.initialize(
Instructions.combine([
stringContext({ key: "core/date", value: "date" }),
stringContext({ key: "core/location", value: "location" }),
]),
@ -360,7 +358,7 @@ describe("SystemContext", () => {
it.effect("requires namespaced source keys", () =>
Effect.sync(() => {
const decodeKey = Schema.decodeUnknownSync(SystemContext.Key)
const decodeKey = Schema.decodeUnknownSync(Instructions.Key)
expect(decodeKey("core/date")).toBe(key("core/date"))
expect(() => decodeKey("date")).toThrow()
@ -369,7 +367,7 @@ describe("SystemContext", () => {
it.effect("requires namespaced applied keys", () =>
Effect.sync(() => {
const decodeApplied = Schema.decodeUnknownSync(SystemContext.Applied)
const decodeApplied = Schema.decodeUnknownSync(Instructions.Applied)
expect(Object.keys(decodeApplied({ "core/date": { value: "date" } }))).toEqual(["core/date"])
expect(() => decodeApplied({ date: { value: "date" } })).toThrow()

View file

@ -4,17 +4,17 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Reference } from "@opencode-ai/core/reference"
import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
import { SystemContext } from "@opencode-ai/core/system-context/index"
import { Instructions } from "@opencode-ai/core/instructions/index"
import { it } from "./lib/effect"
const guidanceLayer = (referenceLayer: Layer.Layer<Reference.Service>) =>
AppNodeBuilder.build(ReferenceGuidance.node, [[Reference.node, referenceLayer]])
describe("ReferenceGuidance", () => {
it.effect("lists available references in the system context", () =>
it.effect("lists available references in the instructions", () =>
Effect.gen(function* () {
const guidance = yield* ReferenceGuidance.Service
const generation = yield* SystemContext.initialize(yield* guidance.load())
const generation = yield* Instructions.initialize(yield* guidance.load())
expect(generation.text).toContain("<available_references>")
expect(generation.text).toContain("<name>docs</name>")
@ -46,7 +46,7 @@ describe("ReferenceGuidance", () => {
it.effect("omits guidance when no references are available", () =>
Effect.gen(function* () {
const guidance = yield* ReferenceGuidance.Service
const generation = yield* SystemContext.initialize(yield* guidance.load())
const generation = yield* Instructions.initialize(yield* guidance.load())
expect(generation.text).toBe("")
}).pipe(Effect.provide(guidanceLayer(Layer.mock(Reference.Service, { list: () => Effect.succeed([]) })))),
)
@ -54,7 +54,7 @@ describe("ReferenceGuidance", () => {
it.effect("omits references without descriptions", () =>
Effect.gen(function* () {
const guidance = yield* ReferenceGuidance.Service
const generation = yield* SystemContext.initialize(yield* guidance.load())
const generation = yield* Instructions.initialize(yield* guidance.load())
expect(generation.text).toBe("")
}).pipe(
Effect.provide(
@ -85,10 +85,10 @@ describe("ReferenceGuidance", () => {
let references = [reference("docs", "Use for product documentation")]
return Effect.gen(function* () {
const guidance = yield* ReferenceGuidance.Service
const initialized = yield* SystemContext.initialize(yield* guidance.load())
const initialized = yield* Instructions.initialize(yield* guidance.load())
references = [reference("docs", "Use for product documentation"), reference("examples", "Use for examples")]
const added = yield* SystemContext.reconcile(yield* guidance.load(), initialized.applied)
const added = yield* Instructions.reconcile(yield* guidance.load(), initialized.applied)
expect(added).toMatchObject({
_tag: "Updated",
text: [
@ -103,7 +103,7 @@ describe("ReferenceGuidance", () => {
references = [reference("examples", "Use for examples")]
expect(
yield* SystemContext.reconcile(yield* guidance.load(), added._tag === "Updated" ? added.applied : {}),
yield* Instructions.reconcile(yield* guidance.load(), added._tag === "Updated" ? added.applied : {}),
).toMatchObject({
_tag: "Updated",
text: "The following project references are no longer available and must not be used: docs.",

View file

@ -21,7 +21,7 @@ import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionInput } from "@opencode-ai/core/session/input"
import { Shell } from "@opencode-ai/schema/shell"
import {
SessionContextCheckpointTable,
InstructionCheckpointTable,
SessionInputTable,
SessionMessageTable,
SessionTable,
@ -80,7 +80,7 @@ describe("SessionProjector", () => {
])
.run()
yield* db
.insert(SessionContextCheckpointTable)
.insert(InstructionCheckpointTable)
.values({ session_id: sessionID, baseline: "baseline", snapshot: {}, baseline_seq: 0 })
.run()
const events = yield* EventV2.Service
@ -107,7 +107,7 @@ describe("SessionProjector", () => {
(yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all()).map((row) => row.id),
).toEqual([earlier])
// A committed revert resets the context checkpoint so the next turn re-initializes.
expect(yield* db.select().from(SessionContextCheckpointTable).get().pipe(Effect.orDie)).toBeUndefined()
expect(yield* db.select().from(InstructionCheckpointTable).get().pipe(Effect.orDie)).toBeUndefined()
}),
)

View file

@ -31,9 +31,9 @@ import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { Location } from "@opencode-ai/core/location"
import { SystemContextBuiltIns } from "@opencode-ai/core/system-context/builtins"
import { InstructionContext } from "@opencode-ai/core/instruction-context"
import { SystemContext } from "@opencode-ai/core/system-context"
import { InstructionBuiltIns } from "@opencode-ai/core/instructions/builtins"
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
import { Instructions } from "@opencode-ai/core/instructions"
import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
import { McpGuidance } from "@opencode-ai/core/mcp/guidance"
@ -73,18 +73,18 @@ const model = OpenAIChat.route
})
.model({ id: "gpt-4o-mini" })
const models = SessionRunnerModel.layerWith(() => Effect.succeed(SessionRunnerModel.resolved(model)))
const systemContext = Layer.mock(SystemContextBuiltIns.Service, { load: () => Effect.succeed(SystemContext.empty) })
const instructionContext = Layer.mock(InstructionContext.Service, { load: () => Effect.succeed(SystemContext.empty) })
const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
const mcpGuidance = Layer.mock(McpGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
const systemContext = Layer.mock(InstructionBuiltIns.Service, { load: () => Effect.succeed(Instructions.empty) })
const instructionContext = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) })
const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.succeed(Instructions.empty) })
const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(Instructions.empty) })
const mcpGuidance = Layer.mock(McpGuidance.Service, { load: () => Effect.succeed(Instructions.empty) })
const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))
const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
[Snapshot.node, Snapshot.noopLayer],
[LayerNodePlatform.llmClient, client],
[SessionRunnerModel.node, models],
[SystemContextBuiltIns.node, systemContext],
[InstructionContext.node, instructionContext],
[InstructionBuiltIns.node, systemContext],
[InstructionDiscovery.node, instructionContext],
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
[SkillGuidance.node, skillGuidance],
[ReferenceGuidance.node, referenceGuidance],
@ -119,8 +119,8 @@ const it = testEffect(
AgentV2.node,
ToolRegistry.node,
SessionRunnerModel.node,
SystemContextBuiltIns.node,
InstructionContext.node,
InstructionBuiltIns.node,
InstructionDiscovery.node,
SkillGuidance.node,
ReferenceGuidance.node,
Config.node,
@ -133,8 +133,8 @@ const it = testEffect(
[PermissionV2.node, permission],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
[SessionRunnerModel.node, models],
[SystemContextBuiltIns.node, systemContext],
[InstructionContext.node, instructionContext],
[InstructionBuiltIns.node, systemContext],
[InstructionDiscovery.node, instructionContext],
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
[SkillGuidance.node, skillGuidance],
[ReferenceGuidance.node, referenceGuidance],

View file

@ -46,16 +46,16 @@ import { Config } from "@opencode-ai/core/config"
import { ConfigCompaction } from "@opencode-ai/core/config/compaction"
import { Tool } from "@opencode-ai/core/tool/tool"
import {
SessionContextCheckpointTable,
InstructionCheckpointTable,
SessionInputTable,
SessionMessageTable,
SessionTable,
} from "@opencode-ai/core/session/sql"
import { SessionContextEntry } from "@opencode-ai/core/session/context-entry"
import { InstructionEntry } from "@opencode-ai/core/session/instruction-entry"
import { SessionStore } from "@opencode-ai/core/session/store"
import { SystemContext } from "@opencode-ai/core/system-context"
import { SystemContextBuiltIns } from "@opencode-ai/core/system-context/builtins"
import { InstructionContext } from "@opencode-ai/core/instruction-context"
import { Instructions } from "@opencode-ai/core/instructions"
import { InstructionBuiltIns } from "@opencode-ai/core/instructions/builtins"
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
import { McpGuidance } from "@opencode-ai/core/mcp/guidance"
@ -180,24 +180,24 @@ const models = SessionRunnerModel.layerWith((session) =>
),
),
)
const systemContextKey = SystemContext.Key.make("test/context")
const systemContextKey = Instructions.Key.make("test/context")
let systemBaseline = "Initial context"
let systemRemoved = false
let systemUnavailable = false
let systemLoadHook = Effect.void
const skillBaselines = new Map<AgentV2.ID, string>()
const systemContext = Layer.mock(SystemContextBuiltIns.Service, {
const systemContext = Layer.mock(InstructionBuiltIns.Service, {
load: () =>
Effect.sync(() =>
SystemContext.combine(
Instructions.combine(
systemRemoved
? []
: [
SystemContext.make({
Instructions.make({
key: systemContextKey,
codec: Schema.toCodecJson(Schema.String),
load: systemLoadHook.pipe(
Effect.andThen(Effect.sync(() => (systemUnavailable ? SystemContext.unavailable : systemBaseline))),
Effect.andThen(Effect.sync(() => (systemUnavailable ? Instructions.unavailable : systemBaseline))),
),
baseline: String,
update: (_previous, current) => current,
@ -207,24 +207,24 @@ const systemContext = Layer.mock(SystemContextBuiltIns.Service, {
),
),
})
const instructionContext = Layer.mock(InstructionContext.Service, { load: () => Effect.succeed(SystemContext.empty) })
const instructionContext = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) })
const skillGuidance = Layer.mock(SkillGuidance.Service, {
load: (agent) =>
Effect.succeed(
skillBaselines.has(agent.id)
? SystemContext.make({
key: SystemContext.Key.make("test/skill-guidance"),
? Instructions.make({
key: Instructions.Key.make("test/skill-guidance"),
codec: Schema.toCodecJson(Schema.String),
load: Effect.succeed(skillBaselines.get(agent.id)!),
baseline: String,
update: (_previous, current) => current,
removed: () => "Skill guidance removed",
})
: SystemContext.empty,
: Instructions.empty,
),
})
const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
const mcpGuidance = Layer.mock(McpGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(Instructions.empty) })
const mcpGuidance = Layer.mock(McpGuidance.Service, { load: () => Effect.succeed(Instructions.empty) })
const config = Layer.succeed(
Config.Service,
Config.Service.of({
@ -246,8 +246,8 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
[Snapshot.node, Snapshot.noopLayer],
[LayerNodePlatform.llmClient, client],
[SessionRunnerModel.node, models],
[SystemContextBuiltIns.node, systemContext],
[InstructionContext.node, instructionContext],
[InstructionBuiltIns.node, systemContext],
[InstructionDiscovery.node, instructionContext],
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
[SkillGuidance.node, skillGuidance],
[ReferenceGuidance.node, referenceGuidance],
@ -285,9 +285,9 @@ const it = testEffect(
ToolRegistry.toolsNode,
echoNode,
SessionRunnerModel.node,
SystemContextBuiltIns.node,
InstructionContext.node,
SessionContextEntry.node,
InstructionBuiltIns.node,
InstructionDiscovery.node,
InstructionEntry.node,
SkillGuidance.node,
ReferenceGuidance.node,
Config.node,
@ -300,8 +300,8 @@ const it = testEffect(
[LayerNodePlatform.llmClient, client],
[PermissionV2.node, permission],
[SessionRunnerModel.node, models],
[SystemContextBuiltIns.node, systemContext],
[InstructionContext.node, instructionContext],
[InstructionBuiltIns.node, systemContext],
[InstructionDiscovery.node, instructionContext],
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
[SkillGuidance.node, skillGuidance],
[ReferenceGuidance.node, referenceGuidance],
@ -709,14 +709,14 @@ describe("SessionRunnerLLM", () => {
const exit = yield* session.resume(sessionID).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(SystemContext.InitializationBlocked)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Instructions.InitializationBlocked)
expect(requests).toHaveLength(0)
expect(yield* SessionInput.hasPending(db, sessionID, "steer")).toBe(true)
expect(
yield* db
.select()
.from(SessionContextCheckpointTable)
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
.from(InstructionCheckpointTable)
.where(eq(InstructionCheckpointTable.session_id, sessionID))
.get(),
).toBeUndefined()
@ -747,8 +747,8 @@ describe("SessionRunnerLLM", () => {
expect(
yield* db
.select()
.from(SessionContextCheckpointTable)
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
.from(InstructionCheckpointTable)
.where(eq(InstructionCheckpointTable.session_id, sessionID))
.get(),
).toBeUndefined()
@ -774,16 +774,16 @@ describe("SessionRunnerLLM", () => {
const parent = yield* db
.select()
.from(SessionContextCheckpointTable)
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
.from(InstructionCheckpointTable)
.where(eq(InstructionCheckpointTable.session_id, sessionID))
.get()
.pipe(Effect.orDie)
expect(parent).toBeDefined()
expect(
yield* db
.select()
.from(SessionContextCheckpointTable)
.where(eq(SessionContextCheckpointTable.session_id, forked.id))
.from(InstructionCheckpointTable)
.where(eq(InstructionCheckpointTable.session_id, forked.id))
.get()
.pipe(Effect.orDie),
).toEqual({ ...parent!, session_id: forked.id })
@ -799,9 +799,9 @@ describe("SessionRunnerLLM", () => {
response = []
yield* session.resume(sessionID)
yield* db
.update(SessionContextCheckpointTable)
.update(InstructionCheckpointTable)
.set({ snapshot: { invalid: { value: "bad" } } })
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
.where(eq(InstructionCheckpointTable.session_id, sessionID))
.run()
.pipe(Effect.orDie)
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false })
@ -815,9 +815,9 @@ describe("SessionRunnerLLM", () => {
expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"])
expect(requests[0]?.messages.at(1)?.content).toEqual([{ type: "text", text: "Initial context" }])
const healed = yield* db
.select({ snapshot: SessionContextCheckpointTable.snapshot })
.from(SessionContextCheckpointTable)
.where(eq(SessionContextCheckpointTable.session_id, sessionID))
.select({ snapshot: InstructionCheckpointTable.snapshot })
.from(InstructionCheckpointTable)
.where(eq(InstructionCheckpointTable.session_id, sessionID))
.get()
.pipe(Effect.orDie)
expect(healed?.snapshot).toEqual({ "test/context": { value: "Initial context", removed: expect.any(String) } })
@ -849,7 +849,7 @@ describe("SessionRunnerLLM", () => {
yield* db
.select({ id: EventTable.id })
.from(EventTable)
.where(eq(EventTable.type, "session.context.updated.1"))
.where(eq(EventTable.type, "session.instructions.updated.1"))
.all()
.pipe(Effect.orDie),
).toHaveLength(1)
@ -1109,7 +1109,7 @@ describe("SessionRunnerLLM", () => {
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const contextEntries = yield* SessionContextEntry.Service
const contextEntries = yield* InstructionEntry.Service
yield* contextEntries.put({ sessionID, key: "deploy-target", value: "production" })
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })

View file

@ -5,7 +5,7 @@ import { AgentV2 } from "@opencode-ai/core/agent"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SkillV2 } from "@opencode-ai/core/skill"
import { SystemContext } from "@opencode-ai/core/system-context"
import { Instructions } from "@opencode-ai/core/instructions"
import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
import { it } from "../lib/effect"
@ -51,7 +51,7 @@ describe("SkillGuidance", () => {
const guidance = yield* SkillGuidance.Service
const initialized = yield* guidance
.load({ id: agent.id, info: agent })
.pipe(Effect.flatMap(SystemContext.initialize))
.pipe(Effect.flatMap(Instructions.initialize))
expect(initialized.text).toBe(
[
@ -71,7 +71,7 @@ describe("SkillGuidance", () => {
expect(
yield* guidance
.load({ id: agent.id, info: agent })
.pipe(Effect.flatMap((context) => SystemContext.reconcile(context, initialized.applied))),
.pipe(Effect.flatMap((context) => Instructions.reconcile(context, initialized.applied))),
).toMatchObject({
_tag: "Updated",
text: "The following skills are no longer available and must not be used: effect.",
@ -92,12 +92,12 @@ describe("SkillGuidance", () => {
const guidance = yield* SkillGuidance.Service
const initialized = yield* guidance
.load({ id: agent.id, info: agent })
.pipe(Effect.flatMap(SystemContext.initialize))
.pipe(Effect.flatMap(Instructions.initialize))
skills = [effect, debugging]
const added = yield* guidance
.load({ id: agent.id, info: agent })
.pipe(Effect.flatMap((context) => SystemContext.reconcile(context, initialized.applied)))
.pipe(Effect.flatMap((context) => Instructions.reconcile(context, initialized.applied)))
expect(added).toMatchObject({
_tag: "Updated",
text: [
@ -113,7 +113,7 @@ describe("SkillGuidance", () => {
const removed = yield* guidance
.load({ id: agent.id, info: agent })
.pipe(
Effect.flatMap((context) => SystemContext.reconcile(context, added._tag === "Updated" ? added.applied : {})),
Effect.flatMap((context) => Instructions.reconcile(context, added._tag === "Updated" ? added.applied : {})),
)
expect(removed).toMatchObject({
_tag: "Updated",
@ -129,13 +129,13 @@ describe("SkillGuidance", () => {
const guidance = yield* SkillGuidance.Service
const initialized = yield* guidance
.load({ id: agent.id, info: agent })
.pipe(Effect.flatMap(SystemContext.initialize))
.pipe(Effect.flatMap(Instructions.initialize))
skills = [SkillV2.Info.make({ ...effect, description: "Build applications with Effect v4" })]
expect(
yield* guidance
.load({ id: agent.id, info: agent })
.pipe(Effect.flatMap((context) => SystemContext.reconcile(context, initialized.applied))),
.pipe(Effect.flatMap((context) => Instructions.reconcile(context, initialized.applied))),
).toMatchObject({
_tag: "Updated",
text: expect.stringContaining(
@ -152,12 +152,12 @@ describe("SkillGuidance", () => {
})
return Effect.gen(function* () {
const guidance = yield* SkillGuidance.Service
expect(
yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(SystemContext.initialize)),
).toEqual({
text: "",
applied: {},
})
expect(yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(Instructions.initialize))).toEqual(
{
text: "",
applied: {},
},
)
}).pipe(Effect.provide(layer(() => [effect])))
})
@ -171,12 +171,12 @@ describe("SkillGuidance", () => {
})
return Effect.gen(function* () {
const guidance = yield* SkillGuidance.Service
expect(
yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(SystemContext.initialize)),
).toEqual({
text: "",
applied: {},
})
expect(yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(Instructions.initialize))).toEqual(
{
text: "",
applied: {},
},
)
}).pipe(Effect.provide(layer(() => [effect])))
})
@ -191,7 +191,7 @@ describe("SkillGuidance", () => {
return Effect.gen(function* () {
const guidance = yield* SkillGuidance.Service
expect(
(yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(SystemContext.initialize))).text,
(yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(Instructions.initialize))).text,
).toContain("<name>effect</name>")
}).pipe(Effect.provide(layer(() => [effect])))
})
@ -207,12 +207,12 @@ describe("SkillGuidance", () => {
})
return Effect.gen(function* () {
const guidance = yield* SkillGuidance.Service
expect(
yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(SystemContext.initialize)),
).toEqual({
text: "",
applied: {},
})
expect(yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(Instructions.initialize))).toEqual(
{
text: "",
applied: {},
},
)
}).pipe(Effect.provide(layer(() => [effect])))
})
})

View file

@ -1,133 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import * as TestClock from "effect/testing/TestClock"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Location } from "@opencode-ai/core/location"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Global } from "@opencode-ai/core/global"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SystemContext } from "@opencode-ai/core/system-context"
import { SystemContextBuiltIns } from "@opencode-ai/core/system-context/builtins"
import { InstructionContext } from "@opencode-ai/core/instruction-context"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
const directory = AbsolutePath.make(FSUtil.resolve("/repo/packages/core"))
const projectDirectory = AbsolutePath.make(FSUtil.resolve("/repo"))
const instructionFile = FSUtil.resolve("/repo/AGENTS.md")
const timestamp = Date.parse("2026-06-03T12:00:00.000Z")
const localDate = (time: number) => new Date(time).toDateString()
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory },
{ projectDirectory, vcs: { type: "git", store: AbsolutePath.make(FSUtil.resolve("/repo/.git")) } },
),
),
)
const builtInsNode = LayerNode.group([SystemContextBuiltIns.node, InstructionContext.node])
const it = testEffect(
AppNodeBuilder.build(builtInsNode, [
[Location.node, locationLayer],
[Global.node, Global.layerWith({ config: "/global" })],
]),
)
const instructionFS = Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
Effect.map((fs) =>
FSUtil.Service.of({
...fs,
up: () => Effect.succeed([instructionFile]),
readFileStringSafe: (path) => Effect.succeed(path === instructionFile ? "Be precise." : undefined),
}),
),
),
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
const itWithInstructions = testEffect(
AppNodeBuilder.build(builtInsNode, [
[Location.node, locationLayer],
[FSUtil.node, instructionFS],
[Global.node, Global.layerWith({ config: "/global" })],
]),
)
describe("SystemContextBuiltIns", () => {
it.effect("loads location-scoped environment and host-local date context", () =>
Effect.gen(function* () {
yield* TestClock.setTime(timestamp)
const context = yield* SystemContextBuiltIns.Service
const initialized = yield* SystemContext.initialize(yield* context.load())
expect(initialized.text).toBe(
[
"Here is some useful information about the environment you are running in:",
"<env>",
` Working directory: ${directory}`,
` Workspace root folder: ${projectDirectory}`,
" Is directory a git repo: yes",
` Platform: ${process.platform}`,
"</env>",
"",
`Today's date: ${localDate(timestamp)}`,
].join("\n"),
)
}),
)
it.effect("reconciles the date without repeating unchanged environment context", () =>
Effect.gen(function* () {
yield* TestClock.setTime(timestamp)
const context = yield* SystemContextBuiltIns.Service
const initialized = yield* SystemContext.initialize(yield* context.load())
yield* TestClock.setTime(timestamp + 24 * 60 * 60 * 1000)
const refreshed = yield* SystemContext.reconcile(yield* context.load(), initialized.applied)
expect(refreshed).toMatchObject({
_tag: "Updated",
text: `Today's date is now: ${localDate(timestamp + 24 * 60 * 60 * 1000)}`,
})
}),
)
it.effect("does not update again within the same local calendar day", () =>
Effect.gen(function* () {
yield* TestClock.setTime(timestamp)
const context = yield* SystemContextBuiltIns.Service
const initialized = yield* SystemContext.initialize(yield* context.load())
yield* TestClock.setTime(timestamp + 60 * 60 * 1000)
expect(yield* SystemContext.reconcile(yield* context.load(), initialized.applied)).toEqual({ _tag: "Unchanged" })
}),
)
itWithInstructions.effect("composes ambient instructions after built-in context", () =>
Effect.gen(function* () {
yield* TestClock.setTime(timestamp)
const builtIns = yield* SystemContextBuiltIns.Service
const instructions = yield* InstructionContext.Service
const context = {
load: () => Effect.all([builtIns.load(), instructions.load()]).pipe(Effect.map(SystemContext.combine)),
}
expect((yield* SystemContext.initialize(yield* context.load())).text).toBe(
[
"Here is some useful information about the environment you are running in:",
"<env>",
` Working directory: ${directory}`,
` Workspace root folder: ${projectDirectory}`,
" Is directory a git repo: yes",
` Platform: ${process.platform}`,
"</env>",
"",
`Today's date: ${localDate(timestamp)}`,
"",
`Instructions from: ${instructionFile}\nBe precise.`,
].join("\n"),
)
}),
)
})

File diff suppressed because one or more lines are too long

View file

@ -9,6 +9,7 @@ import {
compile as compileContract,
emitEffect,
emitEffectImported,
emitEffectShape,
emitPromise,
generate,
GenerationError,
@ -151,6 +152,201 @@ describe("HttpApiCodegen.generate", () => {
expect(contract.groups[0]?.endpoints.map((endpoint) => endpoint.operation.name)).toEqual(["listRequests", "list"])
})
test("supports explicit nested endpoint paths while string aliases remain flat", () => {
const source = HttpApi.make("test").add(
HttpApiGroup.make("server.session")
.add(HttpApiEndpoint.get("session.instructions.list", "/session/instructions", { success: Schema.String }))
.add(HttpApiEndpoint.put("session.instructions.put", "/session/instructions", { success: Schema.String }))
.add(HttpApiEndpoint.delete("session.instructions.remove", "/session/instructions", { success: Schema.String }))
.add(HttpApiEndpoint.get("session.messages", "/session/message", { success: Schema.String })),
)
const contract = compileContract(source, {
groupNames: { "server.session": "session" },
endpointNames: {
"session.instructions.list": ["instructions", "list"],
"session.instructions.put": ["instructions", "put"],
"session.instructions.remove": ["instructions", "remove"],
"session.messages": "instructions.flat",
},
})
expect(contract.groups[0]?.endpoints.map((endpoint) => endpoint.clientPath)).toEqual([
["instructions", "list"],
["instructions", "put"],
["instructions", "remove"],
["instructions.flat"],
])
expect(contract.groups[0]?.endpoints.map((endpoint) => endpoint.operation.name)).toEqual([
"instructions.list",
"instructions.put",
"instructions.remove",
"instructions.flat",
])
const promise = emitPromise(contract, {
outputTypes: {
"session.instructions.list": {
name: "InstructionListWire",
import: 'import type { InstructionListWire } from "./instruction-list-wire"',
},
},
})
const promiseClient = promise.files.find((file) => file.path === "client.ts")?.content
const promiseTypes = promise.files.find((file) => file.path === "types.ts")?.content
expect(promiseClient).toContain('"session": { "instructions": { "list": (requestOptions?: RequestOptions)')
expect(promiseClient).toContain('"put": (requestOptions?: RequestOptions)')
expect(promiseClient).toContain('"remove": (requestOptions?: RequestOptions)')
expect(promiseClient).toContain('"instructions.flat": (requestOptions?: RequestOptions)')
expect(promiseTypes).toContain('import type { InstructionListWire } from "./instruction-list-wire"')
expect(promiseTypes).toContain("export type SessionInstructionsListOutput = InstructionListWire")
expect(promiseTypes).toContain("export type SessionInstructionsPutOutput = string")
expect(promiseTypes).toContain("export type SessionInstructionsRemoveOutput = string")
const effect = emitEffect(contract)
expect(effect.files.find((file) => file.path === "session.ts")?.content).toContain(
'"instructions": { "list": Endpoint0(raw), "put": Endpoint1(raw), "remove": Endpoint2(raw) }, "instructions.flat": Endpoint3(raw)',
)
const imported = emitEffectImported(contract, { module: "@example/api", api: "Api" })
expect(imported.files.find((file) => file.path === "client.ts")?.content).toContain(
'"instructions": { "list": Endpoint0_0(raw), "put": Endpoint0_1(raw), "remove": Endpoint0_2(raw) }, "instructions.flat": Endpoint0_3(raw)',
)
const shape = emitEffectShape(contract, { module: "@example/api", api: "Api" })
const apiShape = shape.files.find((file) => file.path === "api.ts")?.content
expect(apiShape).toContain('readonly "instructions": { readonly "list": SessionInstructionsListOperation<E>')
expect(apiShape).toContain('readonly "put": SessionInstructionsPutOperation<E>')
expect(apiShape).toContain('readonly "remove": SessionInstructionsRemoveOperation<E>')
expect(apiShape).toContain('readonly "instructions.flat": SessionInstructionsFlatOperation<E>')
})
test("executes nested Promise endpoint aliases", async () => {
const source = HttpApi.make("test").add(
HttpApiGroup.make("session")
.add(HttpApiEndpoint.get("instructions.list", "/session/instructions", { success: Schema.String }))
.add(HttpApiEndpoint.put("instructions.put", "/session/instructions", { success: Schema.String }))
.add(HttpApiEndpoint.delete("instructions.remove", "/session/instructions", { success: Schema.String })),
)
const output = emitPromise(
compileContract(source, {
endpointNames: {
"instructions.list": ["instructions", "list"],
"instructions.put": ["instructions", "put"],
"instructions.remove": ["instructions", "remove"],
},
}),
)
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
const methods: Array<string> = []
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (_input: RequestInfo | URL, init?: RequestInit) => {
methods.push(init?.method ?? "GET")
return Response.json("ok")
},
})
expect(await client.session.instructions.list()).toBe("ok")
expect(await client.session.instructions.put()).toBe("ok")
expect(await client.session.instructions.remove()).toBe("ok")
expect(methods).toEqual(["GET", "PUT", "DELETE"])
} finally {
await rm(directory, { recursive: true, force: true })
}
})
test("rejects duplicate and leaf-namespace endpoint paths", () => {
const source = HttpApi.make("test").add(
HttpApiGroup.make("session")
.add(HttpApiEndpoint.get("first", "/first", { success: Schema.String }))
.add(HttpApiEndpoint.get("second", "/second", { success: Schema.String })),
)
expect(() =>
compileContract(source, { endpointNames: { first: ["instructions", "list"], second: ["instructions", "list"] } }),
).toThrow("Client endpoint name collision: session.instructions.list")
expect(() =>
compileContract(source, { endpointNames: { first: "instructions", second: ["instructions", "list"] } }),
).toThrow("Client endpoint name collision: session.instructions.list")
})
test("rejects nested root collisions across top-level groups", () => {
const source = HttpApi.make("test")
.add(
HttpApiGroup.make("first", { topLevel: true }).add(
HttpApiEndpoint.get("first.list", "/first", { success: Schema.String }),
),
)
.add(
HttpApiGroup.make("second", { topLevel: true }).add(
HttpApiEndpoint.get("second.put", "/second", { success: Schema.String }),
),
)
expect(() =>
compileContract(source, {
endpointNames: { "first.list": ["instructions", "list"], "second.put": ["instructions", "put"] },
}),
).toThrow("Client name collision: instructions")
})
test("rejects nested paths that collide after type-name normalization", () => {
const source = HttpApi.make("test").add(
HttpApiGroup.make("session")
.add(HttpApiEndpoint.get("first", "/first", { success: Schema.String }))
.add(HttpApiEndpoint.get("second", "/second", { success: Schema.String })),
)
expect(() => compileContract(source, { endpointNames: { first: ["foo", "bar"], second: "foo-bar" } })).toThrow(
"Client endpoint type collision: SessionFooBar",
)
})
test("rejects ambiguous and prototype-mutating nested path segments", () => {
const source = api(HttpApiEndpoint.get("get", "/session", { success: Schema.String }))
expect(() => compileContract(source, { endpointNames: { get: ["a.b", "get"] } })).toThrow(
"Nested client endpoint path segments cannot contain dots",
)
expect(() => compileContract(source, { endpointNames: { get: ["__proto__", "get"] } })).toThrow(
"Client endpoint path cannot contain __proto__",
)
})
test("rejects normalized group, operation-key, and group prototype collisions", () => {
const normalized = HttpApi.make("test")
.add(HttpApiGroup.make("foo-bar").add(HttpApiEndpoint.get("get", "/first", { success: Schema.String })))
.add(HttpApiGroup.make("foo.bar").add(HttpApiEndpoint.get("get", "/second", { success: Schema.String })))
expect(() => compileContract(normalized)).toThrow("Client group type collision: FooBar")
const endpointType = HttpApi.make("test")
.add(HttpApiGroup.make("foo").add(HttpApiEndpoint.get("first", "/first", { success: Schema.String })))
.add(HttpApiGroup.make("fooBar").add(HttpApiEndpoint.get("second", "/second", { success: Schema.String })))
expect(() =>
compileContract(endpointType, {
endpointNames: { first: ["bar", "baz"], second: ["baz"] },
}),
).toThrow("Client endpoint type collision: FooBarBaz")
const operationKey = HttpApi.make("test")
.add(HttpApiGroup.make("a.b").add(HttpApiEndpoint.get("get", "/first", { success: Schema.String })))
.add(HttpApiGroup.make("a").add(HttpApiEndpoint.get("b.c", "/second", { success: Schema.String })))
expect(() => compileContract(operationKey, { endpointNames: { get: "c", "b.c": ["b", "c"] } })).toThrow(
"Client operation key collision: a.b.c",
)
const prototype = HttpApi.make("test").add(
HttpApiGroup.make("session").add(HttpApiEndpoint.get("get", "/session", { success: Schema.String })),
)
expect(() => compileContract(prototype, { groupNames: { session: "__proto__" } })).toThrow(
"Client group name cannot be __proto__",
)
})
test("omits custom transport endpoints", () => {
const source = HttpApi.make("test").add(
HttpApiGroup.make("server.pty")
@ -358,22 +554,6 @@ describe("HttpApiCodegen.generate", () => {
emitPromise(compileContract(api(HttpApiEndpoint.get("read", "/file/*/tail", { success: Schema.String })))),
).toThrow("Unsupported Promise path wildcard: /file/*/tail")
expect(() =>
emitPromise(
compileContract(
api(
HttpApiEndpoint.get("binary", "/binary", {
success: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),
}),
),
),
),
).toThrow("Unsupported Promise success encoding: session.binary")
expect(() =>
emitPromise(compileContract(api(HttpApiEndpoint.get("read", "/file/*", { success: Schema.String })))),
).toThrow("Unsupported Promise path wildcard: /file/*")
expect(() =>
emitPromise(
compileContract(

View file

@ -67,9 +67,9 @@ export const endpointNames = {
"integration.attempt.status": "attemptStatus",
"integration.attempt.complete": "attemptComplete",
"integration.attempt.cancel": "attemptCancel",
"session.context.entry.list": "listContextEntries",
"session.context.entry.put": "putContextEntry",
"session.context.entry.remove": "removeContextEntry",
"session.instructions.entry.list": ["instructions", "entry", "list"],
"session.instructions.entry.put": ["instructions", "entry", "put"],
"session.instructions.entry.remove": ["instructions", "entry", "remove"],
"session.revert.stage": "revertStage",
"session.revert.clear": "revertClear",
"session.revert.commit": "revertCommit",

View file

@ -2,7 +2,7 @@ import { SessionMessage } from "@opencode-ai/schema/session-message"
import { SessionInput } from "@opencode-ai/schema/session-input"
import { PromptInput } from "@opencode-ai/schema/prompt-input"
import { Session } from "@opencode-ai/schema/session"
import { SessionContextEntry } from "@opencode-ai/schema/session-context-entry"
import { InstructionEntry } from "@opencode-ai/schema/instruction-entry"
import { Project } from "@opencode-ai/schema/project"
import { AbsolutePath, PositiveInt, RelativePath, statics } from "@opencode-ai/schema/schema"
import { Event } from "@opencode-ai/schema/event"
@ -444,23 +444,23 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
),
)
.add(
HttpApiEndpoint.get("session.context.entry.list", "/api/session/:sessionID/context-entry", {
HttpApiEndpoint.get("session.instructions.entry.list", "/api/session/:sessionID/instructions/entries", {
params: { sessionID: Session.ID },
success: Schema.Struct({ data: Schema.Array(SessionContextEntry.Info) }),
success: Schema.Struct({ data: Schema.Array(InstructionEntry.Info) }),
error: SessionNotFoundError,
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.context.entry.list",
summary: "List context entries",
description: "List API-managed context entries attached to the session's system context.",
identifier: "v2.session.instructions.entry.list",
summary: "List instruction entries",
description: "List API-managed instruction entries attached to the session.",
}),
),
)
.add(
HttpApiEndpoint.put("session.context.entry.put", "/api/session/:sessionID/context-entry/:key", {
params: { sessionID: Session.ID, key: SessionContextEntry.Key },
HttpApiEndpoint.put("session.instructions.entry.put", "/api/session/:sessionID/instructions/entries/:key", {
params: { sessionID: Session.ID, key: InstructionEntry.Key },
payload: Schema.Struct({ value: Schema.Json }),
success: HttpApiSchema.NoContent,
error: SessionNotFoundError,
@ -468,25 +468,26 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.context.entry.put",
summary: "Put context entry",
identifier: "v2.session.instructions.entry.put",
summary: "Put instruction entry",
description:
"Attach or replace one durable context entry. The value is rendered into the session's system context; changes announce as updates at the next turn boundary.",
"Attach or replace one durable instruction entry. Changes announce as updates at the next step boundary.",
}),
),
)
.add(
HttpApiEndpoint.delete("session.context.entry.remove", "/api/session/:sessionID/context-entry/:key", {
params: { sessionID: Session.ID, key: SessionContextEntry.Key },
HttpApiEndpoint.delete("session.instructions.entry.remove", "/api/session/:sessionID/instructions/entries/:key", {
params: { sessionID: Session.ID, key: InstructionEntry.Key },
success: HttpApiSchema.NoContent,
error: SessionNotFoundError,
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.context.entry.remove",
summary: "Remove context entry",
description: "Remove one context entry; the removal is announced to the model at the next turn boundary.",
identifier: "v2.session.instructions.entry.remove",
summary: "Remove instruction entry",
description:
"Remove one instruction entry; the removal is announced to the model at the next step boundary.",
}),
),
)

View file

@ -0,0 +1,20 @@
export * as InstructionEntry from "./instruction-entry.js"
import { Schema } from "effect"
/**
* Slash-free client-facing key for one API-managed instruction entry. The server
* derives the namespaced Instructions key as `api/<key>`, keeping the
* `api/*` namespace enforced by construction.
*/
export const Key = Schema.String.check(Schema.isPattern(/^[a-z0-9][a-z0-9._-]*$/)).annotate({
identifier: "InstructionEntry.Key",
description: "Instruction entry key (lowercase alphanumerics plus . _ -)",
})
export type Key = typeof Key.Type
export const Info = Schema.Struct({
key: Key,
value: Schema.Json.annotate({ description: "JSON value attached to the session's instructions" }),
}).annotate({ identifier: "InstructionEntry.Info" })
export interface Info extends Schema.Schema.Type<typeof Info> {}

View file

@ -1,20 +0,0 @@
export * as SessionContextEntry from "./session-context-entry.js"
import { Schema } from "effect"
/**
* Slash-free client-facing key for one API-managed context entry. The server
* derives the namespaced SystemContext key as `api/<key>`, keeping the
* `api/*` namespace enforced by construction.
*/
export const Key = Schema.String.check(Schema.isPattern(/^[a-z0-9][a-z0-9._-]*$/)).annotate({
identifier: "SessionContextEntry.Key",
description: "Context entry key (lowercase alphanumerics plus . _ -)",
})
export type Key = typeof Key.Type
export const Info = Schema.Struct({
key: Key,
value: Schema.Json.annotate({ description: "JSON value attached to the session's system context" }),
}).annotate({ identifier: "SessionContextEntry.Info" })
export interface Info extends Schema.Schema.Type<typeof Info> {}

View file

@ -130,15 +130,15 @@ export const ExecutionSettled = Event.ephemeral({
})
export type ExecutionSettled = typeof ExecutionSettled.Type
export const ContextUpdated = Event.durable({
type: "session.context.updated",
export const InstructionsUpdated = Event.durable({
type: "session.instructions.updated",
...options,
schema: {
...Base,
text: Schema.String,
},
})
export type ContextUpdated = typeof ContextUpdated.Type
export type InstructionsUpdated = typeof InstructionsUpdated.Type
export const Synthetic = Event.durable({
type: "session.synthetic",
@ -494,7 +494,7 @@ export const Definitions = Event.inventory(
PromptPromoted,
PromptAdmitted,
ExecutionSettled,
ContextUpdated,
InstructionsUpdated,
Synthetic,
Skill.Activated,
Shell.Started,

View file

@ -104,7 +104,8 @@ describe("public event manifest", () => {
"session.forked.1",
"session.prompt.promoted.1",
"session.prompt.admitted.1",
"session.context.updated.1",
"session.instructions.updated.1",
"session.synthetic.1",
"session.skill.activated.1",
"session.shell.started.1",

View file

@ -185,11 +185,11 @@ it.live(
resume: false,
})
const context = yield* opencode.sessions.context({ sessionID: id })
yield* opencode.sessions.putContextEntry({ sessionID: id, key: "deploy-target", value: "production" })
yield* opencode.sessions.putContextEntry({ sessionID: id, key: "flags", value: { beta: true } })
const contextEntries = yield* opencode.sessions.listContextEntries({ sessionID: id })
yield* opencode.sessions.removeContextEntry({ sessionID: id, key: "flags" })
const remainingContextEntries = yield* opencode.sessions.listContextEntries({ sessionID: id })
yield* opencode.sessions.instructions.entry.put({ sessionID: id, key: "deploy-target", value: "production" })
yield* opencode.sessions.instructions.entry.put({ sessionID: id, key: "flags", value: { beta: true } })
const contextEntries = yield* opencode.sessions.instructions.entry.list({ sessionID: id })
yield* opencode.sessions.instructions.entry.remove({ sessionID: id, key: "flags" })
const remainingContextEntries = yield* opencode.sessions.instructions.entry.list({ sessionID: id })
const wake = yield* opencode.sessions.prompt({
sessionID: id,
prompt: fixture.sdk.Prompt.make({ text: "Promote this input" }),
@ -219,7 +219,7 @@ it.live(
opencode.sessions.log({ sessionID: missingSessionID }).pipe(Stream.runHead, Effect.flip),
opencode.sessions.interrupt({ sessionID: missingSessionID }).pipe(Effect.flip),
opencode.sessions.message({ sessionID: missingSessionID, messageID: modelMessage.id }).pipe(Effect.flip),
opencode.sessions.listContextEntries({ sessionID: missingSessionID }).pipe(Effect.flip),
opencode.sessions.instructions.entry.list({ sessionID: missingSessionID }).pipe(Effect.flip),
],
{ concurrency: "unbounded" },
)

View file

@ -60,7 +60,7 @@ if (schemas) {
visit({ ...document, components: { ...document.components, schemas: undefined } })
for (const name of Object.keys(schemas)) {
if (
/^(SessionAgentSelected|SessionModelSelected|SessionMoved|SessionRenamed|SessionForked|SessionPromptPromoted|SessionPromptAdmitted|SessionExecutionSettled|SessionContextUpdated|SessionSynthetic|SessionSkillActivated|SessionShellStarted|SessionShellEnded|SessionStepStarted|SessionStepEnded|SessionStepFailed|SessionTextStarted|SessionTextDelta|SessionTextEnded|SessionReasoningStarted|SessionReasoningDelta|SessionReasoningEnded|SessionToolInputStarted|SessionToolInputDelta|SessionToolInputEnded|SessionToolCalled|SessionToolProgress|SessionToolSuccess|SessionToolFailed|SessionRetried|SessionCompactionStarted|SessionCompactionDelta|SessionCompactionEnded|SessionRevertStaged|SessionRevertCleared|SessionRevertCommitted)1$/.test(
/^(SessionAgentSelected|SessionModelSelected|SessionMoved|SessionRenamed|SessionForked|SessionPromptPromoted|SessionPromptAdmitted|SessionExecutionSettled|SessionInstructionsUpdated|SessionSynthetic|SessionSkillActivated|SessionShellStarted|SessionShellEnded|SessionStepStarted|SessionStepEnded|SessionStepFailed|SessionTextStarted|SessionTextDelta|SessionTextEnded|SessionToolInputStarted|SessionToolInputDelta|SessionToolInputEnded|SessionToolCalled|SessionToolProgress|SessionToolSuccess|SessionToolFailed|SessionRetried|SessionCompactionStarted|SessionCompactionDelta|SessionCompactionEnded|SessionRevertStaged|SessionRevertCleared|SessionRevertCommitted)1$/.test(
name,
) &&
!reachable.has(name)
@ -100,7 +100,7 @@ await createClient({
const generatedTypesPath = "./src/v2/gen/types.gen.ts"
const generatedTypes = await Bun.file(generatedTypesPath).text()
if (
/export type (SessionAgentSelected|SessionModelSelected|SessionMoved|SessionRenamed|SessionForked|SessionPromptPromoted|SessionPromptAdmitted|SessionExecutionSettled|SessionContextUpdated|SessionSynthetic|SessionSkillActivated|SessionShellStarted|SessionShellEnded|SessionStepStarted|SessionStepEnded|SessionStepFailed|SessionTextStarted|SessionTextDelta|SessionTextEnded|SessionReasoningStarted|SessionReasoningDelta|SessionReasoningEnded|SessionToolInputStarted|SessionToolInputDelta|SessionToolInputEnded|SessionToolCalled|SessionToolProgress|SessionToolSuccess|SessionToolFailed|SessionRetried|SessionCompactionStarted|SessionCompactionDelta|SessionCompactionEnded|SessionRevertStaged|SessionRevertCleared|SessionRevertCommitted)1 =/.test(
/export type (SessionAgentSelected|SessionModelSelected|SessionMoved|SessionRenamed|SessionForked|SessionPromptPromoted|SessionPromptAdmitted|SessionExecutionSettled|SessionInstructionsUpdated|SessionSynthetic|SessionSkillActivated|SessionShellStarted|SessionShellEnded|SessionStepStarted|SessionStepEnded|SessionStepFailed|SessionTextStarted|SessionTextDelta|SessionTextEnded|SessionToolInputStarted|SessionToolInputDelta|SessionToolInputEnded|SessionToolCalled|SessionToolProgress|SessionToolSuccess|SessionToolFailed|SessionRetried|SessionCompactionStarted|SessionCompactionDelta|SessionCompactionEnded|SessionRevertStaged|SessionRevertCleared|SessionRevertCommitted)1 =/.test(
generatedTypes,
)
) {

View file

@ -92,6 +92,7 @@ import type {
GlobalUpgradeResponses,
InstanceDisposeErrors,
InstanceDisposeResponses,
InstructionEntryKey2,
LocationRef2,
LspStatusErrors,
LspStatusResponses,
@ -185,7 +186,6 @@ import type {
SessionChildrenResponses,
SessionCommandErrors,
SessionCommandResponses,
SessionContextEntryKey2,
SessionCreateErrors,
SessionCreateResponses,
SessionDeleteErrors,
@ -364,12 +364,6 @@ import type {
V2SessionCommandResponses,
V2SessionCompactErrors,
V2SessionCompactResponses,
V2SessionContextEntryListErrors,
V2SessionContextEntryListResponses,
V2SessionContextEntryPutErrors,
V2SessionContextEntryPutResponses,
V2SessionContextEntryRemoveErrors,
V2SessionContextEntryRemoveResponses,
V2SessionContextErrors,
V2SessionContextResponses,
V2SessionCreateErrors,
@ -390,6 +384,12 @@ import type {
V2SessionFormStateResponses,
V2SessionGetErrors,
V2SessionGetResponses,
V2SessionInstructionsEntryListErrors,
V2SessionInstructionsEntryListResponses,
V2SessionInstructionsEntryPutErrors,
V2SessionInstructionsEntryPutResponses,
V2SessionInstructionsEntryRemoveErrors,
V2SessionInstructionsEntryRemoveResponses,
V2SessionInterruptErrors,
V2SessionInterruptResponses,
V2SessionListErrors,
@ -5262,9 +5262,9 @@ export class Revert extends HeyApiClient {
export class Entry extends HeyApiClient {
/**
* List context entries
* List instruction entries
*
* List API-managed context entries attached to the session's system context.
* List API-managed instruction entries attached to the session.
*/
public list<ThrowOnError extends boolean = false>(
parameters: {
@ -5274,25 +5274,25 @@ export class Entry extends HeyApiClient {
) {
const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }])
return (options?.client ?? this.client).get<
V2SessionContextEntryListResponses,
V2SessionContextEntryListErrors,
V2SessionInstructionsEntryListResponses,
V2SessionInstructionsEntryListErrors,
ThrowOnError
>({
url: "/api/session/{sessionID}/context-entry",
url: "/api/session/{sessionID}/instructions/entries",
...options,
...params,
})
}
/**
* Remove context entry
* Remove instruction entry
*
* Remove one context entry; the removal is announced to the model at the next turn boundary.
* Remove one instruction entry; the removal is announced to the model at the next step boundary.
*/
public remove<ThrowOnError extends boolean = false>(
parameters: {
sessionID: string
key: SessionContextEntryKey2
key: InstructionEntryKey2
},
options?: Options<never, ThrowOnError>,
) {
@ -5308,25 +5308,25 @@ export class Entry extends HeyApiClient {
],
)
return (options?.client ?? this.client).delete<
V2SessionContextEntryRemoveResponses,
V2SessionContextEntryRemoveErrors,
V2SessionInstructionsEntryRemoveResponses,
V2SessionInstructionsEntryRemoveErrors,
ThrowOnError
>({
url: "/api/session/{sessionID}/context-entry/{key}",
url: "/api/session/{sessionID}/instructions/entries/{key}",
...options,
...params,
})
}
/**
* Put context entry
* Put instruction entry
*
* Attach or replace one durable context entry. The value is rendered into the session's system context; changes announce as updates at the next turn boundary.
* Attach or replace one durable instruction entry. Changes announce as updates at the next step boundary.
*/
public put<ThrowOnError extends boolean = false>(
parameters: {
sessionID: string
key: SessionContextEntryKey2
key: InstructionEntryKey2
value?: unknown
},
options?: Options<never, ThrowOnError>,
@ -5344,11 +5344,11 @@ export class Entry extends HeyApiClient {
],
)
return (options?.client ?? this.client).put<
V2SessionContextEntryPutResponses,
V2SessionContextEntryPutErrors,
V2SessionInstructionsEntryPutResponses,
V2SessionInstructionsEntryPutErrors,
ThrowOnError
>({
url: "/api/session/{sessionID}/context-entry/{key}",
url: "/api/session/{sessionID}/instructions/entries/{key}",
...options,
...params,
headers: {
@ -5360,7 +5360,7 @@ export class Entry extends HeyApiClient {
}
}
export class Context extends HeyApiClient {
export class Instructions extends HeyApiClient {
private _entry?: Entry
get entry(): Entry {
return (this._entry ??= new Entry({ client: this.client }))
@ -6479,9 +6479,9 @@ export class Session3 extends HeyApiClient {
return (this._revert ??= new Revert({ client: this.client }))
}
private _context?: Context
get context2(): Context {
return (this._context ??= new Context({ client: this.client }))
private _instructions?: Instructions
get instructions(): Instructions {
return (this._instructions ??= new Instructions({ client: this.client }))
}
private _form?: Form

View file

@ -25,7 +25,7 @@ export type Event =
| EventSessionPromptPromoted
| EventSessionPromptAdmitted
| EventSessionExecutionSettled
| EventSessionContextUpdated
| EventSessionInstructionsUpdated
| EventSessionSynthetic
| EventSessionSkillActivated
| EventSessionShellStarted
@ -929,7 +929,7 @@ export type GlobalEvent = {
}
| {
id: string
type: "session.context.updated"
type: "session.instructions.updated"
properties: {
sessionID: string
text: string
@ -1731,7 +1731,7 @@ export type GlobalEvent = {
| SyncEventSessionForked
| SyncEventSessionPromptPromoted
| SyncEventSessionPromptAdmitted
| SyncEventSessionContextUpdated
| SyncEventSessionInstructionsUpdated
| SyncEventSessionSynthetic
| SyncEventSessionSkillActivated
| SyncEventSessionShellStarted
@ -2894,7 +2894,7 @@ export type SessionDurableEvent =
| SessionForked
| SessionPromptPromoted
| SessionPromptAdmitted
| SessionContextUpdated
| SessionInstructionsUpdated
| SessionSynthetic
| SessionSkillActivated
| SessionShellStarted
@ -3035,7 +3035,7 @@ export type V2Event =
| SessionPromptPromoted
| SessionPromptAdmitted
| SessionExecutionSettled
| SessionContextUpdated
| SessionInstructionsUpdated
| SessionSynthetic
| SessionSkillActivated
| SessionShellStarted
@ -3731,11 +3731,11 @@ export type SyncEventSessionPromptAdmitted = {
}
}
export type SyncEventSessionContextUpdated = {
export type SyncEventSessionInstructionsUpdated = {
type: "sync"
id: string
syncEvent: {
type: "session.context.updated.1"
type: "session.instructions.updated.1"
id: string
seq: number
aggregateID: string
@ -4527,10 +4527,10 @@ export type SessionMessage =
| SessionMessageAssistant
| SessionMessageCompaction
export type SessionContextEntryKey = string
export type InstructionEntryKey = string
export type SessionContextEntryInfo = {
key: SessionContextEntryKey
export type InstructionEntryInfo = {
key: InstructionEntryKey
value: unknown
}
@ -4671,13 +4671,13 @@ export type SessionPromptAdmitted = {
}
}
export type SessionContextUpdated = {
export type SessionInstructionsUpdated = {
id: string
created: number
metadata?: {
[key: string]: unknown
}
type: "session.context.updated"
type: "session.instructions.updated"
durable: {
aggregateID: string
seq: number
@ -6839,9 +6839,9 @@ export type EventSessionExecutionSettled = {
}
}
export type EventSessionContextUpdated = {
export type EventSessionInstructionsUpdated = {
id: string
type: "session.context.updated"
type: "session.instructions.updated"
properties: {
sessionID: string
text: string
@ -8246,12 +8246,12 @@ export type SessionMessage2 =
| SessionMessageCompaction2
/**
* Context entry key (lowercase alphanumerics plus . _ -)
* Instruction entry key (lowercase alphanumerics plus . _ -)
*/
export type SessionContextEntryKey2 = string
export type InstructionEntryKey2 = string
export type SessionContextEntryInfo2 = {
key: SessionContextEntryKey2
export type InstructionEntryInfo2 = {
key: InstructionEntryKey2
value: unknown
}
@ -8392,13 +8392,13 @@ export type SessionPromptAdmitted2 = {
}
}
export type SessionContextUpdated2 = {
export type SessionInstructionsUpdated2 = {
id: string
created: number
metadata?: {
[key: string]: unknown
}
type: "session.context.updated"
type: "session.instructions.updated"
durable: {
aggregateID: string
seq: number
@ -8991,7 +8991,7 @@ export type SessionDurableEventV2 =
| SessionForked2
| SessionPromptPromoted2
| SessionPromptAdmitted2
| SessionContextUpdated2
| SessionInstructionsUpdated2
| SessionSynthetic2
| SessionSkillActivated2
| SessionShellStarted2
@ -11207,7 +11207,7 @@ export type V2EventV2 =
| SessionPromptPromoted2
| SessionPromptAdmitted2
| SessionExecutionSettled2
| SessionContextUpdated2
| SessionInstructionsUpdated2
| SessionSynthetic2
| SessionSkillActivated2
| SessionShellStarted2
@ -16421,16 +16421,16 @@ export type V2SessionContextResponses = {
export type V2SessionContextResponse = V2SessionContextResponses[keyof V2SessionContextResponses]
export type V2SessionContextEntryListData = {
export type V2SessionInstructionsEntryListData = {
body?: never
path: {
sessionID: string
}
query?: never
url: "/api/session/{sessionID}/context-entry"
url: "/api/session/{sessionID}/instructions/entries"
}
export type V2SessionContextEntryListErrors = {
export type V2SessionInstructionsEntryListErrors = {
/**
* InvalidRequestError
*/
@ -16445,31 +16445,32 @@ export type V2SessionContextEntryListErrors = {
404: SessionNotFoundErrorV2
}
export type V2SessionContextEntryListError = V2SessionContextEntryListErrors[keyof V2SessionContextEntryListErrors]
export type V2SessionInstructionsEntryListError =
V2SessionInstructionsEntryListErrors[keyof V2SessionInstructionsEntryListErrors]
export type V2SessionContextEntryListResponses = {
export type V2SessionInstructionsEntryListResponses = {
/**
* Success
*/
200: {
data: Array<SessionContextEntryInfo2>
data: Array<InstructionEntryInfo2>
}
}
export type V2SessionContextEntryListResponse =
V2SessionContextEntryListResponses[keyof V2SessionContextEntryListResponses]
export type V2SessionInstructionsEntryListResponse =
V2SessionInstructionsEntryListResponses[keyof V2SessionInstructionsEntryListResponses]
export type V2SessionContextEntryRemoveData = {
export type V2SessionInstructionsEntryRemoveData = {
body?: never
path: {
sessionID: string
key: SessionContextEntryKey2
key: InstructionEntryKey2
}
query?: never
url: "/api/session/{sessionID}/context-entry/{key}"
url: "/api/session/{sessionID}/instructions/entries/{key}"
}
export type V2SessionContextEntryRemoveErrors = {
export type V2SessionInstructionsEntryRemoveErrors = {
/**
* InvalidRequestError
*/
@ -16484,32 +16485,32 @@ export type V2SessionContextEntryRemoveErrors = {
404: SessionNotFoundErrorV2
}
export type V2SessionContextEntryRemoveError =
V2SessionContextEntryRemoveErrors[keyof V2SessionContextEntryRemoveErrors]
export type V2SessionInstructionsEntryRemoveError =
V2SessionInstructionsEntryRemoveErrors[keyof V2SessionInstructionsEntryRemoveErrors]
export type V2SessionContextEntryRemoveResponses = {
export type V2SessionInstructionsEntryRemoveResponses = {
/**
* <No Content>
*/
204: void
}
export type V2SessionContextEntryRemoveResponse =
V2SessionContextEntryRemoveResponses[keyof V2SessionContextEntryRemoveResponses]
export type V2SessionInstructionsEntryRemoveResponse =
V2SessionInstructionsEntryRemoveResponses[keyof V2SessionInstructionsEntryRemoveResponses]
export type V2SessionContextEntryPutData = {
export type V2SessionInstructionsEntryPutData = {
body: {
value: unknown
}
path: {
sessionID: string
key: SessionContextEntryKey2
key: InstructionEntryKey2
}
query?: never
url: "/api/session/{sessionID}/context-entry/{key}"
url: "/api/session/{sessionID}/instructions/entries/{key}"
}
export type V2SessionContextEntryPutErrors = {
export type V2SessionInstructionsEntryPutErrors = {
/**
* InvalidRequestError
*/
@ -16524,17 +16525,18 @@ export type V2SessionContextEntryPutErrors = {
404: SessionNotFoundErrorV2
}
export type V2SessionContextEntryPutError = V2SessionContextEntryPutErrors[keyof V2SessionContextEntryPutErrors]
export type V2SessionInstructionsEntryPutError =
V2SessionInstructionsEntryPutErrors[keyof V2SessionInstructionsEntryPutErrors]
export type V2SessionContextEntryPutResponses = {
export type V2SessionInstructionsEntryPutResponses = {
/**
* <No Content>
*/
204: void
}
export type V2SessionContextEntryPutResponse =
V2SessionContextEntryPutResponses[keyof V2SessionContextEntryPutResponses]
export type V2SessionInstructionsEntryPutResponse =
V2SessionInstructionsEntryPutResponses[keyof V2SessionInstructionsEntryPutResponses]
export type V2SessionLogData = {
body?: never

View file

@ -1,5 +1,5 @@
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionContextEntry } from "@opencode-ai/core/session/context-entry"
import { InstructionEntry } from "@opencode-ai/core/session/instruction-entry"
import { DateTime, Effect, Stream } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
@ -541,25 +541,25 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
}),
)
.handle(
"session.context.entry.list",
"session.instructions.entry.list",
Effect.fn(function* (ctx) {
const contextEntries = yield* SessionContextEntry.Service
return { data: yield* contextEntries.list(ctx.params.sessionID) }
const instructions = yield* InstructionEntry.Service
return { data: yield* instructions.list(ctx.params.sessionID) }
}),
)
.handle(
"session.context.entry.put",
"session.instructions.entry.put",
Effect.fn(function* (ctx) {
const contextEntries = yield* SessionContextEntry.Service
yield* contextEntries.put({ sessionID: ctx.params.sessionID, key: ctx.params.key, value: ctx.payload.value })
const instructions = yield* InstructionEntry.Service
yield* instructions.put({ sessionID: ctx.params.sessionID, key: ctx.params.key, value: ctx.payload.value })
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"session.context.entry.remove",
"session.instructions.entry.remove",
Effect.fn(function* (ctx) {
const contextEntries = yield* SessionContextEntry.Service
yield* contextEntries.remove({ sessionID: ctx.params.sessionID, key: ctx.params.key })
const instructions = yield* InstructionEntry.Service
yield* instructions.remove({ sessionID: ctx.params.sessionID, key: ctx.params.key })
return HttpApiSchema.NoContent.make()
}),
)

View file

@ -302,7 +302,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
})
break
case "session.context.updated":
case "session.instructions.updated":
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: messageIDFromEvent(event.id),

View file

@ -149,7 +149,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
}
const subscriptions = [
data.on("session.prompt.admitted", input),
data.on("session.context.updated", message),
data.on("session.instructions.updated", message),
data.on("session.synthetic", (event) => {
if (event.data.sessionID === sessionID() && event.data.description?.trim())
appendMessage(event.id.replace(/^evt_/, "msg_"))

View file

@ -1169,7 +1169,7 @@ test("renders admitted prompts immediately with queued marker and clears when pr
}
})
test("projects live context updates with their message ID", async () => {
test("projects live instruction updates with their message ID", async () => {
const events = createEventStream()
const calls = createFetch(undefined, events)
let sync!: ReturnType<typeof useData>
@ -1199,21 +1199,21 @@ test("projects live context updates with their message ID", async () => {
try {
await mounted
emitEvent(events, {
id: "evt_context_1",
id: "evt_instructions_1",
created: 0,
type: "session.context.updated",
type: "session.instructions.updated",
durable: durable("session-1"),
data: {
sessionID: "session-1",
text: "Updated context",
text: "Updated instructions",
},
})
await wait(() => sync.session.message.list("session-1")?.length === 1)
expect(sync.session.message.list("session-1")?.[0]).toMatchObject({
id: SessionMessage.ID.fromEvent(EventV2.ID.make("evt_context_1")),
id: SessionMessage.ID.fromEvent(EventV2.ID.make("evt_instructions_1")),
type: "system",
text: "Updated context",
text: "Updated instructions",
time: { created: 0 },
})
} finally {

View file

@ -1,5 +1,18 @@
# V2 Schema Changelog
## 2026-07-05: Rename Session Context Contracts To Instructions
- Rename the System Context algebra to `Instructions`, API-managed `SessionContextEntry` records to `InstructionEntry`, and the session-owned context checkpoint to `InstructionCheckpoint`.
- Rename the tables `session_context_entry` and `session_context_epoch` to `instruction_entry` and `instruction_checkpoint`.
- Rename the durable update event from `session.context.updated` to `session.instructions.updated`; the migration rewrites existing durable event types in place.
- Rename the API-managed entry routes from `/api/session/:sessionID/context-entry` to `/api/session/:sessionID/instructions/entries` and their operation identifiers from `session.context.entry.*` to `session.instructions.entry.*`, keeping bare `session.instructions.*` free for the composed instruction surface.
Compatibility:
- The V2 contracts remain experimental. The renamed tables, event, endpoints, schemas, and generated client names are intentionally breaking changes for beta consumers.
- Existing changelog entries retain the names that were accurate when those changes occurred.
- Behavior is unchanged: this is a vocabulary and contract rename only.
## 2026-07-03: Require Durable Envelope On Durable Events
- Make the wire `durable` envelope required on durable event definitions.

View file

@ -51,61 +51,71 @@ The local runner issues one explicit `llm.stream(request)` per step, projects ea
Projected hosted tools preserve call-side and settlement-side provider metadata separately so settlement and interruption recovery cannot erase continuation identifiers. Provider-native reasoning and provider metadata replay only while the historical assistant model matches the selected continuation model; after a model switch, visible reasoning text remains ordinary assistant text and provider-native metadata is omitted.
## Context Epochs
## Instruction Checkpoints
V2 Sessions persist the exact privileged System Context shown to the model. A Context Epoch stores one immutable provider-cache baseline and a model-hidden structured snapshot used to compare independently observed Context Sources. Environment facts, the host-local date, ambient global/upward-project `AGENTS.md` files, and selected-agent available-skill guidance are the initial sources. Location-wide sources come from the System Context Registry; selected-agent guidance composes with them immediately before Context Epoch admission.
V2 Sessions persist the exact privileged instructions shown to the model. `InstructionCheckpoint` stores one immutable instruction baseline, its baseline event sequence, and a model-hidden `Instructions.Applied` record used to compare independently observed instruction sources. Instructions are only one part of Model Context: the runner separately assembles agent or provider system text, Session History, tool definitions, and step-local additions for each request.
The first complete observation initializes the epoch before any pending prompt becomes model-visible. If initial context is temporarily unavailable, execution stops while the prompt remains pending and retryable. On later steps, the runner promotes eligible input first, then reconciles current sources at the safe boundary. Changed context becomes one durable chronological System message, and its event commit advances the epoch snapshot atomically.
The runner has no instruction registry. `loadInstructions` explicitly loads these producers concurrently and combines them in this fixed order:
1. Instruction built-ins, currently environment facts and the host-local date.
2. `InstructionDiscovery`, observing ambient `AGENTS.md` files.
3. Selected-agent available-skill guidance.
4. Reference guidance.
5. Selected-agent MCP guidance.
6. API-managed `InstructionEntry` values for the Session.
`Instructions.combine(...)` preserves that caller order and rejects duplicate namespaced source keys. Each source owns its typed observation, JSON codec, and pure baseline, update, and optional removal renderers.
The first complete observation initializes `InstructionCheckpoint` before any pending prompt becomes model-visible. If an initial source is temporarily unavailable, execution stops while the prompt remains pending and retryable. Every later step attempt also prepares instructions before input promotion. Changed instructions publish one durable chronological System message through `session.instructions.updated`, and that event commit advances `Instructions.Applied` atomically.
```text
Client Runner System Context Registry Context Epoch Store Session History LLM
│ │ │ │ │ │
├─ Admit prompt ─────────────────────────────────────────────────────────────────────────────────────────────▶ │
│ │ │ │ │ │
│ ├─ Observe initial context ────────────▶ │ │ │
│ │ │ │ │ │
│ ◀─ Complete baseline or unavailable ───┤ │ │ │
│ │ │ │ │ │
│ ├─ Initialize missing epoch ───────────────────────────────────────▶ │ │
│ │ │ │ │ │
│ ├─ Promote eligible input ─────────────────────────────────────────────────────────────────▶ │
│ │ │ │ │ │
│ ├─ Reconcile at safe boundary ─────────▶ │ │ │
│ │ │ │ │ │
│ ◀─ Unchanged or chronological update ──┤ │ │ │
│ │ │ │ │ │
│ ├─ Advance snapshot atomically with update ────────────────────────▶ │ │
│ │ │ │ │ │
│ ├─ Baseline + chronological history ─────────────────────────────────────────────────────────────────────────▶
Client Runner Explicit producers InstructionCheckpoint Inbox / History LLM
│ │ │ │ │ │
├─ Admit prompt ────────────────────────────────────────────────────────────────────────────────▶ │
│ │ │ │ │ │
│ ├─ Load instructions ───────▶ │ │ │
│ │ │ │ │ │
│ ◀─ Combined sources ─────────┤ │ │ │
│ │ │ │ │ │
│ ├─ Initialize or reconcile ────────────────────────────▶ │ │
│ │ │ │ │ │
│ ├─ Publish update + advance Applied atomically ───────────────────────────────▶ │
│ │ │ │ │ │
│ ├─ Promote eligible input ────────────────────────────────────────────────────▶ │
│ │ │ │ │ │
│ ├─ System text + instruction baseline + history + tools ──────────────────────────────────────▶
```
Agent and model selection are step-scoped. A switch admitted after the current safe step boundary applies to the next step without restarting the current step or replacing the baseline. Agent-specific skill guidance remains a Context Source, so changed guidance is admitted as a chronological System message. A completed compaction causes the next physical attempt to render a fresh baseline directly from current complete context. A Session move clears the epoch so the destination Location initializes a complete baseline on its next run.
Agent and model selection are step-scoped. The runner selects the agent before loading agent-specific guidance; a switch admitted after the current boundary applies to the next step without restarting the current one. Changed guidance is admitted through `session.instructions.updated` while preserving the baseline. Model selection affects Model Context assembly but is not an instruction source and does not itself replace the instruction baseline.
A completed compaction causes the next physical attempt to rebaseline from current instructions. Temporarily unavailable sources are restated from the model's last applied belief where possible. A Session move resets `InstructionCheckpoint` so the destination Location initializes a complete baseline on its next run. Committed revert also resets the checkpoint.
```text
Session Epoch
│ │
├─ initialize complete baseline ──▶
│ │
│ ├─────────────────────────────────╮
│ │ reconcile chronological update │
│ ◀─────────────────────────────────╯
│ │
├─ completed compaction ──────────▶
│ ├─ render fresh baseline
│ │
├─ clear after Location move ─────▶
Session InstructionCheckpoint
│ │
├─ initialize complete baseline ────▶
│ │
│ ├──────────────────────────────╮
│ │ reconcile instruction update │
│ ◀──────────────────────────────╯
│ │
├─ completed compaction ────────────▶ rebaseline
│ │
├─ move or committed revert ────────▶ reset
```
Ambient project discovery canonicalizes and contains traversal within the project root and honors `OPENCODE_DISABLE_PROJECT_CONFIG`. An unavailable observation preserves the previously admitted value. A confirmed partial instruction removal emits the complete remaining aggregate with explicit supersession text; removing the final instruction emits a revocation message.
`InstructionDiscovery` observes ambient instructions as one ordered aggregate source. Ambient discovery canonicalizes traversal within the project root, reads global and upward-project `AGENTS.md` files, and honors `OPENCODE_DISABLE_PROJECT_CONFIG` for project files.
Current Context Epoch follow-ups:
An unavailable observation preserves the previously applied value. A confirmed partial instruction removal emits the complete remaining aggregate with explicit supersession text; removing the final instruction emits a revocation message.
- Add configured, remote, and nested instruction sources with explicit precedence and removal semantics.
Current instruction follow-ups:
- Add configured and remote instruction sources with explicit precedence and removal semantics.
- Add durable post-crash continuation recovery for promoted or provider-dispatched work.
- Add explicit manual compaction on top of automatic request-budget compaction.
- Add operational metrics for observation latency, unavailable sources, contention, baseline size, and chronological-update growth.
- Consider watcher-backed per-file caching only if measurements show direct safe-boundary observation is too expensive.
- Expose plugin-defined Context Sources only after plugin reload and scoped cleanup semantics are designed.
- Consider watcher-backed per-file caching only if measurements show direct step-boundary observation is too expensive.
- Design any plugin-defined instruction contribution as an explicit runner composition boundary; do not reintroduce a registry implicitly.
- Add clustered Session execution ownership and stale-runtime fencing.
## Automatic Compaction
@ -114,25 +124,25 @@ Before each step, the runner estimates the complete model-visible request and co
Compaction keeps the full transcript durable while replacing its active model representation with one hidden checkpoint containing a structured rolling summary and token-bounded serialized recent context. Provider-native assistant, reasoning, and tool messages never survive across the boundary, avoiding signature and encrypted-reasoning failures when the earlier prefix changes.
`session.compaction.started.1` durably identifies the attempt. Compaction deltas are live-only progress. `session.compaction.ended.1` durably stores the final summary and serialized recent context; only this completed event projects a model-visible compaction message. On the next physical attempt, the runner observes that completed compaction and directly renders a fresh Context Epoch baseline. A failed or interrupted attempt therefore leaves the previous history boundary active.
`session.compaction.started.1` durably identifies the attempt. Compaction deltas are live-only progress. `session.compaction.ended.1` durably stores the final summary and serialized recent context; only this completed event projects a model-visible compaction message. On the next physical attempt, the runner observes that completed compaction and directly renders a fresh instruction baseline through `InstructionCheckpoint`. A failed or interrupted attempt therefore leaves the previous history boundary active.
Repeated compactions update the previous structured summary with newly compacted messages. The runner then reloads projected history and executes the original pending step.
When a provider rejects a request as context overflow before durable assistant output or tool execution, the runner attempts one overflow-triggered compaction even when the local estimate did not predict pressure. A completed checkpoint rebuilds the same logical step with one remaining physical attempt. A second overflow, unavailable compaction, or overflow after durable output becomes the ordinary terminal failure; recovery never loops or replays partial side effects. Deterministic old tool-result pruning remains a separate follow-up.
## V1 Runtime Context Parity
## V1 Model Context Parity
This is the canonical checklist for model-visible runtime context still needed before the V2 runner replaces V1. Keep each behavior in its owning boundary rather than treating all model-visible text as a durable Context Source. Update this table in the PR that changes a status.
This is the canonical checklist for Model Context still needed before the V2 runner replaces V1. Keep each behavior in its owning boundary rather than treating all model-visible text as durable Instructions. Update this table in the PR that changes a status.
Status: `complete` is usable in the native V2 path, `partial` covers only part of V1 behavior, and `missing` has no native V2 equivalent.
| Boundary | Behavior | Status | Remaining V2 work |
| -------------------------- | ------------------------------------------------------------------------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| Durable Context Source | Environment facts and host-local date | partial | Add selected provider/model identity without making model selection a stale Location-wide value. |
| Durable Context Source | Global and upward project instructions | partial | Decide whether V2 also discovers legacy `CLAUDE.md` and deprecated `CONTEXT.md`. |
| Durable Context Source | Configured local/glob and remote URL instructions | missing | Add independent sources with explicit precedence, unavailable, and removal semantics. |
| Durable Context Source | Nearby nested instructions discovered after successful reads | missing | Persist discoveries and admit them at the next safe step boundary. |
| Durable Context Source | Selected-agent available skill guidance and skill-body loading | partial | Guidance and body exposure are permission-filtered; remove globally denied skill definitions during request-time tool materialization. |
| Durable Instruction Source | Environment facts and host-local date | partial | Keep selected provider/model identity in step request assembly rather than a stale Location-wide instruction value. |
| Durable Instruction Source | Global and upward project instructions | partial | Decide whether V2 also discovers legacy `CLAUDE.md` and deprecated `CONTEXT.md`. |
| Durable Instruction Source | Configured local/glob and remote URL instructions | missing | Add independent sources with explicit precedence, unavailable, and removal semantics. |
| Durable Instruction Source | Nearby nested instructions discovered after successful reads | missing | Persist discoveries and admit them at the next safe step boundary. |
| Durable Instruction Source | Selected-agent available skill guidance and skill-body loading | partial | Guidance and body exposure are permission-filtered; remove globally denied skill definitions during request-time tool materialization. |
| Step request assembly | Placement, selected model, chronological history, and canonical lowering | complete | None. |
| Step request assembly | Selected agent, agent prompt, and effective permissions | partial | V2 uses selected-agent permissions for skill guidance and tool authorization; still apply the agent system prompt and request policy. |
| Step request assembly | Provider/model-specific base instructions | complete | Native V2 selects the provider-family baseline unless the effective agent overrides it. |