From 91f181573224b41d2fa2daa873d0746a9a822db8 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 6 Jul 2026 14:29:29 -0400 Subject: [PATCH] refactor(core): rename system context to instructions (#35583) --- AGENTS.md | 4 +- CONTEXT.md | 137 +++++---- packages/client/src/effect/api/api.ts | 30 +- .../client/src/effect/generated/client.ts | 16 +- packages/client/src/promise/api.ts | 6 +- .../client/src/promise/generated/client.ts | 84 +++--- .../client/src/promise/generated/types.ts | 16 +- packages/client/test/api.types.ts | 32 +- packages/client/test/effect.test.ts | 51 ++++ packages/client/test/promise.test.ts | 45 +++ .../test/fixtures/opencode-v2-openapi.json | 42 +-- packages/codemode/test/openapi.test.ts | 56 ++-- packages/core/package.json | 2 +- packages/core/schema.json | 284 +++++++++--------- packages/core/src/database/migration.gen.ts | 1 + .../20260705180000_rename_instructions.ts | 17 ++ packages/core/src/database/schema.gen.ts | 40 +-- ...on-context.ts => instruction-discovery.ts} | 28 +- .../builtins.ts | 20 +- .../{system-context => instructions}/index.ts | 66 ++-- packages/core/src/location-services.ts | 12 +- packages/core/src/mcp/guidance.ts | 16 +- packages/core/src/reference/guidance.ts | 12 +- packages/core/src/session/history.ts | 8 +- ...heckpoint.ts => instruction-checkpoint.ts} | 58 ++-- ...{context-entry.ts => instruction-entry.ts} | 48 ++- packages/core/src/session/message-updater.ts | 3 +- packages/core/src/session/projector.ts | 16 +- packages/core/src/session/runner/index.ts | 4 +- packages/core/src/session/runner/llm.ts | 36 +-- packages/core/src/session/sql.ts | 11 +- packages/core/src/skill/guidance.ts | 14 +- packages/core/src/tool/skill.ts | 4 +- packages/core/test/database-migration.test.ts | 51 +++- packages/core/test/event.test.ts | 6 +- ....test.ts => instruction-discovery.test.ts} | 36 +-- .../core/test/instructions/builtins.test.ts | 83 +++++ .../index.test.ts | 88 +++--- packages/core/test/reference-guidance.test.ts | 16 +- packages/core/test/session-projector.test.ts | 6 +- .../core/test/session-runner-recorded.test.ts | 28 +- packages/core/test/session-runner.test.ts | 78 ++--- packages/core/test/skill/guidance.test.ts | 54 ++-- .../core/test/system-context/builtins.test.ts | 133 -------- packages/httpapi-codegen/src/index.ts | 194 +++++++++--- .../httpapi-codegen/test/generate.test.ts | 212 ++++++++++++- packages/protocol/src/client.ts | 6 +- packages/protocol/src/groups/session.ts | 33 +- packages/schema/src/instruction-entry.ts | 20 ++ packages/schema/src/session-context-entry.ts | 20 -- packages/schema/src/session-event.ts | 8 +- packages/schema/test/event-manifest.test.ts | 3 +- packages/sdk-next/test/embedded.test.ts | 12 +- packages/sdk/js/script/build.ts | 4 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 56 ++-- packages/sdk/js/src/v2/gen/types.gen.ts | 96 +++--- packages/server/src/handlers/session.ts | 20 +- packages/tui/src/context/data.tsx | 2 +- packages/tui/src/routes/session/rows.ts | 2 +- packages/tui/test/cli/tui/data.test.tsx | 12 +- specs/v2/schema-changelog.md | 13 + specs/v2/session.md | 106 ++++--- 62 files changed, 1547 insertions(+), 1070 deletions(-) create mode 100644 packages/core/src/database/migration/20260705180000_rename_instructions.ts rename packages/core/src/{instruction-context.ts => instruction-discovery.ts} (77%) rename packages/core/src/{system-context => instructions}/builtins.ts (75%) rename packages/core/src/{system-context => instructions}/index.ts (82%) rename packages/core/src/session/{context-checkpoint.ts => instruction-checkpoint.ts} (61%) rename packages/core/src/session/{context-entry.ts => instruction-entry.ts} (65%) rename packages/core/test/{instruction-context.test.ts => instruction-discovery.test.ts} (89%) create mode 100644 packages/core/test/instructions/builtins.test.ts rename packages/core/test/{system-context => instructions}/index.test.ts (80%) delete mode 100644 packages/core/test/system-context/builtins.test.ts create mode 100644 packages/schema/src/instruction-entry.ts delete mode 100644 packages/schema/src/session-context-entry.ts diff --git a/AGENTS.md b/AGENTS.md index 0ea4055ce2..c89653ba95 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/CONTEXT.md b/CONTEXT.md index 712e91bf10..ce880380d1 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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/` **Instruction Source** key. Entries deliberately render to the model as mechanism-neutral `` 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/` **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. diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 9523d3f8ba..76f243c3dc 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -196,33 +196,35 @@ export type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["param export type Endpoint4_18Output = EffectValue>["data"] export type SessionContextOperation = (input: Endpoint4_18Input) => Effect.Effect -type Endpoint4_19Request = Parameters[0] +type Endpoint4_19Request = Parameters[0] export type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] } export type Endpoint4_19Output = EffectValue< - ReturnType + ReturnType >["data"] -export type SessionListContextEntriesOperation = ( +export type SessionInstructionsEntryListOperation = ( input: Endpoint4_19Input, ) => Effect.Effect -type Endpoint4_20Request = Parameters[0] +type Endpoint4_20Request = Parameters[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> -export type SessionPutContextEntryOperation = ( +export type Endpoint4_20Output = EffectValue> +export type SessionInstructionsEntryPutOperation = ( input: Endpoint4_20Input, ) => Effect.Effect -type Endpoint4_21Request = Parameters[0] +type Endpoint4_21Request = Parameters[0] export type Endpoint4_21Input = { readonly sessionID: Endpoint4_21Request["params"]["sessionID"] readonly key: Endpoint4_21Request["params"]["key"] } -export type Endpoint4_21Output = EffectValue> -export type SessionRemoveContextEntryOperation = ( +export type Endpoint4_21Output = EffectValue< + ReturnType +> +export type SessionInstructionsEntryRemoveOperation = ( input: Endpoint4_21Input, ) => Effect.Effect @@ -273,9 +275,13 @@ export interface SessionApi { readonly revertClear: SessionRevertClearOperation readonly revertCommit: SessionRevertCommitOperation readonly context: SessionContextOperation - readonly listContextEntries: SessionListContextEntriesOperation - readonly putContextEntry: SessionPutContextEntryOperation - readonly removeContextEntry: SessionRemoveContextEntryOperation + readonly instructions: { + readonly entry: { + readonly list: SessionInstructionsEntryListOperation + readonly put: SessionInstructionsEntryPutOperation + readonly remove: SessionInstructionsEntryRemoveOperation + } + } readonly log: SessionLogOperation readonly interrupt: SessionInterruptOperation readonly background: SessionBackgroundOperation diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index b7dc996aba..514f70a923 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -266,33 +266,33 @@ const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18I Effect.map((value) => value.data), ) -type Endpoint4_19Request = Parameters[0] +type Endpoint4_19Request = Parameters[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[0] +type Endpoint4_20Request = Parameters[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[0] +type Endpoint4_21Request = Parameters[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), diff --git a/packages/client/src/promise/api.ts b/packages/client/src/promise/api.ts index e7d1c27d25..d31a1881fa 100644 --- a/packages/client/src/promise/api.ts +++ b/packages/client/src/promise/api.ts @@ -18,7 +18,11 @@ type PromisifyOperation = Operation extends ( ? (...args: Args) => Promise : Operation extends (...args: infer Args) => Stream.Stream ? (...args: Args) => AsyncIterable - : Operation + : Operation extends (...args: infer _Args) => unknown + ? Operation + : Operation extends object + ? PromisifyApi + : Operation type PromisifyApi = { readonly [Name in keyof Api]: PromisifyOperation diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index ad94f07fd3..929bc2ecb6 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -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( - { - 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( - { - 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( + { + 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( + { + 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 => sse( { diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index dc28955f49..0473ce4a5e 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -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 } diff --git a/packages/client/test/api.types.ts b/packages/client/test/api.types.ts index 5b17b23c09..ba95bb4297 100644 --- a/packages/client/test/api.types.ts +++ b/packages/client/test/api.types.ts @@ -2,9 +2,39 @@ import { Effect } from "effect" import { OpenCode as EffectOpenCode, type AppApi as EffectApi } from "../src/effect" type EffectClient = Effect.Success> +type PromiseClient = ReturnType declare const effectClient: EffectClient +declare const promiseClient: PromiseClient const effectApi: EffectApi = effectClient -void effectApi +declare const sessionID: Parameters[0]["sessionID"] + +const effectList: Effect.Effect< + ReadonlyArray<{ readonly key: string; readonly value: unknown }>, + unknown +> = effectApi.session.instructions.entry.list({ sessionID }) +const effectPut: Effect.Effect = effectApi.session.instructions.entry.put({ + sessionID, + key: "review-notes", + value: { text: "Check the diff" }, +}) +const effectRemove: Effect.Effect = effectApi.session.instructions.entry.remove({ + sessionID, + key: "review-notes", +}) + +const promiseList: Promise> = + promiseClient.session.instructions.entry.list({ sessionID: "ses_test" }) +const promisePut: Promise = promiseClient.session.instructions.entry.put({ + sessionID: "ses_test", + key: "review-notes", + value: { text: "Check the diff" }, +}) +const promiseRemove: Promise = promiseClient.session.instructions.entry.remove({ + sessionID: "ses_test", + key: "review-notes", +}) + +void [effectList, effectPut, effectRemove, promiseList, promisePut, promiseRemove] diff --git a/packages/client/test/effect.test.ts b/packages/client/test/effect.test.ts index 2073af42ac..4c5ea512d7 100644 --- a/packages/client/test/effect.test.ts +++ b/packages/client/test/effect.test.ts @@ -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( diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index 04737612e2..21b191fd2a 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -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", diff --git a/packages/codemode/test/fixtures/opencode-v2-openapi.json b/packages/codemode/test/fixtures/opencode-v2-openapi.json index 4d1d3af708..b20a25b25e 100644 --- a/packages/codemode/test/fixtures/opencode-v2-openapi.json +++ b/packages/codemode/test/fixtures/opencode-v2-openapi.json @@ -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" diff --git a/packages/codemode/test/openapi.test.ts b/packages/codemode/test/openapi.test.ts index 7ad3d48624..db799e5894 100644 --- a/packages/codemode/test/openapi.test.ts +++ b/packages/codemode/test/openapi.test.ts @@ -54,7 +54,9 @@ const json = (value: unknown, status = 200) => const singleOperation = (operation: Record, 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>>, schemes: Record) => + const authenticated = ( + security: ReadonlyArray>>, + schemes: Record, + ) => 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( diff --git a/packages/core/package.json b/packages/core/package.json index 84a51e61d2..23771daaa7 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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": { diff --git a/packages/core/schema.json b/packages/core/schema.json index d9e0b20b22..222cc7b399 100644 --- a/packages/core/schema.json +++ b/packages/core/schema.json @@ -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": [] -} \ No newline at end of file +} diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index e6a236f527..9d3e4e673f 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -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[] diff --git a/packages/core/src/database/migration/20260705180000_rename_instructions.ts b/packages/core/src/database/migration/20260705180000_rename_instructions.ts new file mode 100644 index 0000000000..93cb71330e --- /dev/null +++ b/packages/core/src/database/migration/20260705180000_rename_instructions.ts @@ -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 diff --git a/packages/core/src/database/schema.gen.ts b/packages/core/src/database/schema.gen.ts index 17a7d12aed..dfa24f9b94 100644 --- a/packages/core/src/database/schema.gen.ts +++ b/packages/core/src/database/schema.gen.ts @@ -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, diff --git a/packages/core/src/instruction-context.ts b/packages/core/src/instruction-discovery.ts similarity index 77% rename from packages/core/src/instruction-context.ts rename to packages/core/src/instruction-discovery.ts index 66d9e14c01..0ac7b8a12d 100644 --- a/packages/core/src/instruction-context.ts +++ b/packages/core/src/instruction-discovery.ts @@ -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("InstructionContext.File")({ +class File extends Schema.Class("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 + readonly load: () => Effect.Effect } -export class Service extends Context.Service()("@opencode/v2/InstructionContext") {} +export class Service extends Context.Service()("@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 | SystemContext.Unavailable) => - SystemContext.make({ + const source = (value: ReadonlyArray | 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))), ), }) }), diff --git a/packages/core/src/system-context/builtins.ts b/packages/core/src/instructions/builtins.ts similarity index 75% rename from packages/core/src/system-context/builtins.ts rename to packages/core/src/instructions/builtins.ts index 1cf470fb4a..0414cc98c1 100644 --- a/packages/core/src/system-context/builtins.ts +++ b/packages/core/src/instructions/builtins.ts @@ -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 + readonly load: () => Effect.Effect } -export class Service extends Context.Service()("@opencode/v2/SystemContextBuiltIns") {} +export class Service extends Context.Service()("@opencode/v2/InstructionBuiltIns") {} const layer = Layer.effect( Service, @@ -23,17 +23,17 @@ const layer = Layer.effect( ` Platform: ${process.platform}`, "", ].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) }) }), ) diff --git a/packages/core/src/system-context/index.ts b/packages/core/src/instructions/index.ts similarity index 82% rename from packages/core/src/system-context/index.ts rename to packages/core/src/instructions/index.ts index d86efceb91..7fe86cfa16 100644 --- a/packages/core/src/system-context/index.ts +++ b/packages/core/src/instructions/index.ts @@ -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` 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 { 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 +/** Opaque carrier for composable instruction sources. */ +export interface Instructions { + readonly [InstructionsTypeId]: ReadonlyArray } /** 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()( - "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()("SystemContext.DuplicateKeyError", { +export class DuplicateKeyError extends Schema.TaggedErrorClass()("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(source: Source): SystemContext { +/** Closes a typed source into instructions that compose with differently typed sources. */ +export function make(source: Source): 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( } } -/** Combines contexts in order and rejects duplicate source keys immediately. */ -export function combine(values: ReadonlyArray): SystemContext { - const sources = values.flatMap((value) => value[ContextTypeId]) +/** Combines instructions in order and rejects duplicate source keys immediately. */ +export function combine(values: ReadonlyArray): 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 { +export function initialize(value: Instructions): Effect.Effect { 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 { +export function reconcile(value: Instructions, previous: Applied): Effect.Effect { 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 { +export function rebaseline(value: Instructions, previous: Applied): Effect.Effect { 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): SystemContext { - return { [ContextTypeId]: sources } +function instructions(sources: ReadonlyArray): Instructions { + return { [InstructionsTypeId]: sources } } function render(parts: ReadonlyArray) { @@ -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 } diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 21823f583b..fe2301651f 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -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, diff --git a/packages/core/src/mcp/guidance.ts b/packages/core/src/mcp/guidance.ts index 0963a69d23..4f2c13b72c 100644 --- a/packages/core/src/mcp/guidance.ts +++ b/packages/core/src/mcp/guidance.ts @@ -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) => ["", ...entries(servers), ""].join("\n") const update = (previous: ReadonlyArray, current: ReadonlyArray) => { - const diff = SystemContext.diffByKey( + const diff = Instructions.diffByKey( previous, current, (server) => server.server, @@ -56,7 +56,7 @@ const update = (previous: ReadonlyArray, current: ReadonlyArray Effect.Effect + readonly load: (agent: AgentV2.Selection) => Effect.Effect } export class Service extends Context.Service()("@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, diff --git a/packages/core/src/reference/guidance.ts b/packages/core/src/reference/guidance.ts index 7fc4fc486c..1ddc1a3a8b 100644 --- a/packages/core/src/reference/guidance.ts +++ b/packages/core/src/reference/guidance.ts @@ -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) => ].join("\n") const update = (previous: ReadonlyArray, current: ReadonlyArray) => { - const diff = SystemContext.diffByKey( + const diff = Instructions.diffByKey( previous, current, (reference) => reference.name, @@ -54,7 +54,7 @@ const update = (previous: ReadonlyArray, current: ReadonlyA } export interface Interface { - readonly load: () => Effect.Effect + readonly load: () => Effect.Effect } export class Service extends Context.Service()("@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, diff --git a/packages/core/src/session/history.ts b/packages/core/src/session/history.ts index 9a6a75fe2f..b3f0b6c741 100644 --- a/packages/core/src/session/history.ts +++ b/packages/core/src/session/history.ts @@ -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), diff --git a/packages/core/src/session/context-checkpoint.ts b/packages/core/src/session/instruction-checkpoint.ts similarity index 61% rename from packages/core/src/session/context-checkpoint.ts rename to packages/core/src/session/instruction-checkpoint.ts index 977514e8ca..47cd006d52 100644 --- a/packages/core/src/session/context-checkpoint.ts +++ b/packages/core/src/session/instruction-checkpoint.ts @@ -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, + instructions: Effect.Effect, 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")) }) diff --git a/packages/core/src/session/context-entry.ts b/packages/core/src/session/instruction-entry.ts similarity index 65% rename from packages/core/src/session/context-entry.ts rename to packages/core/src/session/instruction-entry.ts index 1f3ffdb053..a74a174d12 100644 --- a/packages/core/src/session/context-entry.ts +++ b/packages/core/src/session/instruction-entry.ts @@ -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 readonly remove: (input: { readonly sessionID: SessionSchema.ID; readonly key: Key }) => Effect.Effect - /** Produces one SystemContext source per stored entry, keyed `api/`. */ - readonly load: (sessionID: SessionSchema.ID) => Effect.Effect + /** Produces one Instructions source per stored entry, keyed `api/`. */ + readonly load: (sessionID: SessionSchema.ID) => Effect.Effect } -export class Service extends Context.Service()("@opencode/v2/SessionContextEntry") {} +export class Service extends Context.Service()("@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 }) diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index 2a8ade7b1a..4bf4e43825 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -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({ diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 3cbd40c296..bbe9ce53b1 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -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) }), ) }), diff --git a/packages/core/src/session/runner/index.ts b/packages/core/src/session/runner/index.ts index 7c2a6f463d..3decb42f05 100644 --- a/packages/core/src/session/runner/index.ts +++ b/packages/core/src/session/runner/index.ts @@ -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 { diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index d4b2c5250c..40d43a7bfd 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -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) => 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() @@ -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, diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts index 739d3f1888..ceef6fe040 100644 --- a/packages/core/src/session/sql.ts +++ b/packages/core/src/session/sql.ts @@ -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() @@ -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() .primaryKey() .references(() => SessionTable.id, { onDelete: "cascade" }), baseline: text().notNull(), - snapshot: text({ mode: "json" }).notNull().$type(), + snapshot: text({ mode: "json" }).notNull().$type(), baseline_seq: integer().notNull(), }) diff --git a/packages/core/src/skill/guidance.ts b/packages/core/src/skill/guidance.ts index a95a1ab0b8..95ebe87eed 100644 --- a/packages/core/src/skill/guidance.ts +++ b/packages/core/src/skill/guidance.ts @@ -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) => ].join("\n") const update = (previous: ReadonlyArray, current: ReadonlyArray) => { - const diff = SystemContext.diffByKey( + const diff = Instructions.diffByKey( previous, current, (skill) => skill.name, @@ -56,7 +56,7 @@ const update = (previous: ReadonlyArray, current: ReadonlyArray Effect.Effect + readonly load: (agent: AgentV2.Selection) => Effect.Effect } export class Service extends Context.Service()("@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, diff --git a/packages/core/src/tool/skill.ts b/packages/core/src/tool/skill.ts index d3a2407f32..95c68cb257 100644 --- a/packages/core/src/tool/skill.ts +++ b/packages/core/src/tool/skill.ts @@ -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) => { diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index b36de1abac..eda14d14b1 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -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", }) diff --git a/packages/core/test/event.test.ts b/packages/core/test/event.test.ts index 4bff6a7abf..f531ea7990 100644 --- a/packages/core/test/event.test.ts +++ b/packages/core/test/event.test.ts @@ -712,8 +712,8 @@ describe("EventV2", () => { Effect.gen(function* () { const events = yield* EventV2.Service const aggregateID = Session.ID.create() - const received = new Array() - yield* events.project(SessionEvent.ContextUpdated, (event) => + const received = new Array() + 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" }, diff --git a/packages/core/test/instruction-context.test.ts b/packages/core/test/instruction-discovery.test.ts similarity index 89% rename from packages/core/test/instruction-context.test.ts rename to packages/core/test/instruction-discovery.test.ts index 8bd04d305a..2477d05124 100644 --- a/packages/core/test/instruction-context.test.ts +++ b/packages/core/test/instruction-discovery.test.ts @@ -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 filesystemLayer?: Layer.Layer }) => - 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({ diff --git a/packages/core/test/instructions/builtins.test.ts b/packages/core/test/instructions/builtins.test.ts new file mode 100644 index 0000000000..f66ebb1b81 --- /dev/null +++ b/packages/core/test/instructions/builtins.test.ts @@ -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:", + "", + ` Working directory: ${directory}`, + ` Workspace root folder: ${projectDirectory}`, + " Is directory a git repo: yes", + ` Platform: ${process.platform}`, + "", + "", + `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" }) + }), + ) +}) diff --git a/packages/core/test/system-context/index.test.ts b/packages/core/test/instructions/index.test.ts similarity index 80% rename from packages/core/test/system-context/index.test.ts rename to packages/core/test/instructions/index.test.ts index f62053b088..2d0a3b801a 100644 --- a/packages/core/test/system-context/index.test.ts +++ b/packages/core/test/instructions/index.test.ts @@ -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() diff --git a/packages/core/test/reference-guidance.test.ts b/packages/core/test/reference-guidance.test.ts index 3db3212c3c..fe34873f96 100644 --- a/packages/core/test/reference-guidance.test.ts +++ b/packages/core/test/reference-guidance.test.ts @@ -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) => 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("") expect(generation.text).toContain("docs") @@ -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.", diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index bb53c13405..da2e494731 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -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() }), ) diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index 35e84fcb6c..15a1b35fd4 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -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], diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index cdf1ae37d4..99ccf338d2 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -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() -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 }) diff --git a/packages/core/test/skill/guidance.test.ts b/packages/core/test/skill/guidance.test.ts index b26b75fe71..704f42b803 100644 --- a/packages/core/test/skill/guidance.test.ts +++ b/packages/core/test/skill/guidance.test.ts @@ -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("effect") }).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]))) }) }) diff --git a/packages/core/test/system-context/builtins.test.ts b/packages/core/test/system-context/builtins.test.ts deleted file mode 100644 index 2d4ac356b9..0000000000 --- a/packages/core/test/system-context/builtins.test.ts +++ /dev/null @@ -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:", - "", - ` Working directory: ${directory}`, - ` Workspace root folder: ${projectDirectory}`, - " Is directory a git repo: yes", - ` Platform: ${process.platform}`, - "", - "", - `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:", - "", - ` Working directory: ${directory}`, - ` Workspace root folder: ${projectDirectory}`, - " Is directory a git repo: yes", - ` Platform: ${process.platform}`, - "", - "", - `Today's date: ${localDate(timestamp)}`, - "", - `Instructions from: ${instructionFile}\nBe precise.`, - ].join("\n"), - ) - }), - ) -}) diff --git a/packages/httpapi-codegen/src/index.ts b/packages/httpapi-codegen/src/index.ts index 8f22b4553f..f6ad6b02d7 100644 --- a/packages/httpapi-codegen/src/index.ts +++ b/packages/httpapi-codegen/src/index.ts @@ -35,6 +35,8 @@ export type Contract = { readonly groups: ReadonlyArray } +export type EndpointName = string | readonly [string, ...Array] + export class GenerationError extends Schema.TaggedErrorClass()("GenerationError", { reason: Schema.String, }) { @@ -53,6 +55,7 @@ export type Endpoint = { readonly headers: Schema.Top | undefined readonly payloads: ReadonlyArray readonly operation: Operation + readonly clientPath: readonly [string, ...Array] readonly input: ReadonlyArray readonly unwrapData: boolean readonly errors: ReadonlyArray<{ readonly status: number; readonly schema: Schema.Top }> @@ -86,7 +89,7 @@ export function compile( api: HttpApi.HttpApi, options?: { readonly groupNames?: Readonly> - readonly endpointNames?: Readonly> + readonly endpointNames?: Readonly> readonly omitEndpoints?: ReadonlySet }, ): Contract { @@ -148,6 +151,9 @@ export function compile( for (const [path, schema] of schemaPaths) assertPortable(schema, path, portable) } + const clientPath = normalizeClientPath( + options?.endpointNames?.[endpoint.name] ?? clientEndpointName(endpoint.name), + ) endpoints.push({ group: groupName, sourceGroup: group.identifier, @@ -158,13 +164,14 @@ export function compile( headers: headers?.schema, payloads: payloads.map((item) => item.schema), input: inputs, + clientPath, unwrapData: isDataEnvelope(success.schema), successes: [success.schema], errors: errorSchemas.map((item) => ({ status: item.status, schema: item.schema })), effectPortable, operation: { group: groupName, - name: options?.endpointNames?.[endpoint.name] ?? clientEndpointName(endpoint.name), + name: clientPath.join("."), input: inputs.map(({ name, source }) => ({ name, source })), inputMode: inputs.length === 0 ? "none" : inputs.every((field) => field.optional) ? "optional" : "required", success: isStreamSchema(success.schema) @@ -200,20 +207,51 @@ export function compile( }, ) const publicNames = new Set() + const groupTypeNames = new Set() + const endpointTypeNames = new Set() + const operationKeys = new Set() for (const group of groups) { - const endpointNames = new Set() - for (const endpoint of group.endpoints) { - if (endpointNames.has(endpoint.operation.name)) { - throw new GenerationError({ - reason: `Client endpoint name collision: ${group.identifier}.${endpoint.operation.name}`, - }) - } - endpointNames.add(endpoint.operation.name) + if (group.identifier === "__proto__") { + throw new GenerationError({ reason: "Client group name cannot be __proto__" }) } - const names = group.endpoints[0]?.topLevel ? group.endpoints.map((item) => item.operation.name) : [group.identifier] - for (const name of names) { - if (publicNames.has(name)) throw new GenerationError({ reason: `Client name collision: ${name}` }) - publicNames.add(name) + const groupTypeName = identifierPart(group.identifier) + if (groupTypeNames.has(groupTypeName)) { + throw new GenerationError({ reason: `Client group type collision: ${groupTypeName}` }) + } + groupTypeNames.add(groupTypeName) + assertUniqueClientPaths( + group.endpoints.map((endpoint) => endpoint.clientPath), + (path) => `Client endpoint name collision: ${group.identifier}.${path.join(".")}`, + ) + const typeNames = new Set() + for (const endpoint of group.endpoints) { + const operationKey = clientOperationKey(group, endpoint) + if (operationKeys.has(operationKey)) { + throw new GenerationError({ reason: `Client operation key collision: ${operationKey}` }) + } + operationKeys.add(operationKey) + const name = endpoint.clientPath.map(identifierPart).join("") + const endpointTypeName = `${groupTypeName}${name}` + if (endpointTypeNames.has(endpointTypeName)) { + throw new GenerationError({ reason: `Client endpoint type collision: ${endpointTypeName}` }) + } + endpointTypeNames.add(endpointTypeName) + if (typeNames.has(name)) { + throw new GenerationError({ reason: `Client endpoint type collision: ${group.identifier}.${name}` }) + } + typeNames.add(name) + } + const paths = group.endpoints[0]?.topLevel + ? [...new Set(group.endpoints.map((item) => item.clientPath[0]))].map((name) => [name]) + : [[group.identifier]] + for (const path of paths) { + const key = path.join("\0") + for (const existing of publicNames) { + if (existing === key || existing.startsWith(`${key}\0`) || key.startsWith(`${existing}\0`)) { + throw new GenerationError({ reason: `Client name collision: ${path.join(".")}` }) + } + } + publicNames.add(key) } } return { @@ -314,19 +352,24 @@ function renderEffectShape(groups: ReadonlyArray, options: { readonly mod .filter(Boolean) .join("\n") }) - const methods = group.endpoints - .map( - (endpoint) => `readonly ${JSON.stringify(endpoint.operation.name)}: ${groupShapeTypeName(group, endpoint)}`, - ) - .join("\n") + const methods = renderClientTree( + group.endpoints, + (endpoint) => `${groupShapeTypeName(group, endpoint)}`, + (name, value) => `readonly ${JSON.stringify(name)}: ${value}`, + "\n", + ) return `${endpoints.join("\n\n")}\n\nexport interface ${groupShapeName(group)} {\n${methods}\n}` }) const clientFields = groups.flatMap((group) => group.endpoints[0]?.topLevel - ? group.endpoints.map( - (endpoint) => - `readonly ${JSON.stringify(endpoint.operation.name)}: ${groupShapeTypeName(group, endpoint)}`, - ) + ? [ + renderClientTree( + group.endpoints, + (endpoint) => `${groupShapeTypeName(group, endpoint)}`, + (name, value) => `readonly ${JSON.stringify(name)}: ${value}`, + "\n", + ), + ] : [`readonly ${JSON.stringify(group.identifier)}: ${groupShapeName(group)}`], ) return `// Generated by @opencode-ai/httpapi-codegen. Do not edit. @@ -351,7 +394,7 @@ function groupShapeName(group: Group) { } function groupShapeTypeName(group: Group, endpoint: Endpoint) { - return `${identifierPart(group.identifier)}${identifierPart(endpoint.operation.name)}Operation` + return `${identifierPart(group.identifier)}${endpoint.clientPath.map(identifierPart).join("")}Operation` } function assertPromiseEndpoint(endpoint: Endpoint) { @@ -453,7 +496,13 @@ function renderImportedEffectFiles( const mapped = `${rawCall}.pipe(Effect.mapError(mapClientError)${item.unwrapData ? ", Effect.map((value) => value.data)" : ""})` return `${item.operation.inputMode === "none" ? "" : `type ${prefix}Request = Parameters<${rawGroup}[${JSON.stringify(item.endpoint.name)}]>[0]\ntype ${prefix}Input = { ${input} }\n`}const ${prefix} = (raw: ${rawGroup}) => (${argument}) => ${item.operation.success === "stream" ? `Stream.unwrap(${rawCall}.pipe(Effect.mapError(mapClientError), Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError)))))` : mapped}` }) - return `${methods.join("\n\n")}\n\nconst adaptGroup${groupIndex} = (raw: ${rawGroup}) => ({ ${group.endpoints.map((item, endpointIndex) => `${JSON.stringify(item.operation.name)}: Endpoint${groupIndex}_${endpointIndex}(raw)`).join(", ")} })` + const fields = renderClientTree( + group.endpoints, + (_item, endpointIndex) => `Endpoint${groupIndex}_${endpointIndex}(raw)`, + (name, value) => `${JSON.stringify(name)}: ${value}`, + ", ", + ) + return `${methods.join("\n\n")}\n\nconst adaptGroup${groupIndex} = (raw: ${rawGroup}) => ({ ${fields} })` }) const fields = groups.flatMap((group, index) => group.endpoints[0]?.topLevel @@ -548,7 +597,7 @@ function renderPromiseTypes( const operations = groups .flatMap((group) => group.endpoints.flatMap((endpoint) => { - const prefix = promiseTypePrefix(group.identifier, endpoint.operation.name) + const prefix = promiseTypePrefix(group.identifier, endpoint.clientPath) const schemas = { params: endpoint.params, query: endpoint.query, @@ -566,7 +615,7 @@ function renderPromiseTypes( .join("; ") const successSchema = endpoint.successes[0] const success = - outputTypes?.[`${group.identifier}.${endpoint.operation.name}`]?.name ?? + outputTypes?.[clientOperationKey(group, endpoint)]?.name ?? typeOf( isStreamSchema(successSchema) && successSchema._tag === "StreamSse" ? successSchema.sseMode === "data" @@ -591,13 +640,13 @@ function renderPromiseTypes( function renderPromiseClient(groups: ReadonlyArray) { const imports = groups.flatMap((group) => group.endpoints.flatMap((endpoint) => { - const prefix = promiseTypePrefix(group.identifier, endpoint.operation.name) + const prefix = promiseTypePrefix(group.identifier, endpoint.clientPath) return [...(promiseInputMode(endpoint) === "none" ? [] : [`${prefix}Input`]), `${prefix}Output`] }), ) const fields = groups.map((group) => { const methods = group.endpoints.map((endpoint) => { - const prefix = promiseTypePrefix(group.identifier, endpoint.operation.name) + const prefix = promiseTypePrefix(group.identifier, endpoint.clientPath) const inputMode = promiseInputMode(endpoint) const argument = inputMode === "none" @@ -625,25 +674,91 @@ function renderPromiseClient(groups: ReadonlyArray) { reason: `Promise stream emission is not implemented: ${group.identifier}.${endpoint.endpoint.name}`, }) } - return `${JSON.stringify(endpoint.operation.name)}: (${argument}): AsyncIterable<${prefix}Output> => sse<${prefix}Output>(${descriptor}, requestOptions)` + return `(${argument}): AsyncIterable<${prefix}Output> => sse<${prefix}Output>(${descriptor}, requestOptions)` } const unwrap = endpoint.unwrapData ? ".then((value) => value.data)" : "" - return `${JSON.stringify(endpoint.operation.name)}: (${argument}) => request<${endpoint.unwrapData ? `{ readonly data: ${prefix}Output }` : `${prefix}Output`}>(${descriptor}, requestOptions)${unwrap}` + return `(${argument}) => request<${endpoint.unwrapData ? `{ readonly data: ${prefix}Output }` : `${prefix}Output`}>(${descriptor}, requestOptions)${unwrap}` }) - if (group.endpoints[0]?.topLevel) return methods.join(", ") - return `${JSON.stringify(group.identifier)}: { ${methods.join(", ")} }` + const fields = renderClientTree( + group.endpoints, + (_endpoint, endpointIndex) => methods[endpointIndex], + (name, value) => `${JSON.stringify(name)}: ${value}`, + ", ", + ) + if (group.endpoints[0]?.topLevel) return fields + return `${JSON.stringify(group.identifier)}: { ${fields} }` }) return `import type { ${imports.join(", ")} } from "./types"\nimport { ClientError } from "./client-error"\n\nexport interface ClientOptions {\n readonly baseUrl: string\n readonly fetch?: typeof globalThis.fetch\n readonly headers?: HeadersInit\n}\n\nexport interface RequestOptions {\n readonly signal?: AbortSignal\n readonly headers?: HeadersInit\n}\n\ninterface RequestDescriptor {\n readonly method: string\n readonly path: string\n readonly query?: Record\n readonly headers?: Record\n readonly body?: unknown\n readonly successStatus: number\n readonly declaredStatuses: ReadonlyArray\n readonly empty: boolean\n}\n\nexport function make(options: ClientOptions) {\n const fetch = options.fetch ?? globalThis.fetch\n\n const prepare = (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {\n const url = new URL(descriptor.path, options.baseUrl)\n for (const [key, value] of Object.entries(descriptor.query ?? {})) appendQuery(url.searchParams, key, value)\n const headers = new Headers(options.headers)\n for (const [key, value] of Object.entries(descriptor.headers ?? {})) {\n if (value !== undefined && value !== null) headers.set(key, String(value))\n }\n for (const [key, value] of new Headers(requestOptions?.headers)) headers.set(key, value)\n if (descriptor.body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json")\n return {\n url,\n init: {\n method: descriptor.method,\n signal: requestOptions?.signal,\n headers,\n body: descriptor.body === undefined ? undefined : JSON.stringify(descriptor.body),\n } satisfies RequestInit,\n }\n }\n\n const execute = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {\n try {\n const prepared = prepare(descriptor, requestOptions)\n return await fetch(prepared.url, prepared.init)\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n }\n\n const responseError = async (response: Response, descriptor: RequestDescriptor): Promise => {\n if (descriptor.declaredStatuses.includes(response.status)) throw await json(response)\n try {\n await response.body?.cancel()\n } catch {}\n throw new ClientError("UnexpectedStatus", { cause: { status: response.status } })\n }\n\n const request = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions): Promise => {\n const response = await execute(descriptor, requestOptions)\n if (response.status !== descriptor.successStatus) return responseError(response, descriptor)\n if (descriptor.empty) {\n try {\n await response.body?.cancel()\n } catch {}\n return undefined as A\n }\n return await json(response) as A\n }\n\n const sse = (descriptor: RequestDescriptor, requestOptions?: RequestOptions): AsyncIterable => ({\n async *[Symbol.asyncIterator]() {\n const response = await execute(descriptor, requestOptions)\n if (response.status !== descriptor.successStatus) await responseError(response, descriptor)\n if (!isContentType(response, "text/event-stream")) {\n try {\n await response.body?.cancel()\n } catch {}\n throw new ClientError("UnsupportedContentType")\n }\n if (response.body === null) throw new ClientError("MalformedResponse")\n const reader = response.body.getReader()\n const decoder = new TextDecoder()\n let buffer = ""\n try {\n while (true) {\n let next: ReadableStreamReadResult\n try {\n next = await reader.read()\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n buffer += decoder.decode(next.value, { stream: !next.done })\n if (buffer.length > 1_048_576) throw new ClientError("MalformedResponse")\n const trailingCarriageReturn = !next.done && buffer.endsWith("\\r")\n if (trailingCarriageReturn) buffer = buffer.slice(0, -1)\n buffer = buffer.replaceAll("\\r\\n", "\\n").replaceAll("\\r", "\\n")\n if (trailingCarriageReturn) buffer += "\\r"\n if (next.done && buffer !== "") buffer += "\\n\\n"\n let boundary = buffer.indexOf("\\n\\n")\n while (boundary >= 0) {\n const block = buffer.slice(0, boundary)\n buffer = buffer.slice(boundary + 2)\n const data = block.split("\\n").flatMap((line) => line.startsWith("data:") ? [line.slice(5).trimStart()] : []).join("\\n")\n if (data !== "") {\n try {\n yield JSON.parse(data) as A\n } catch (cause) {\n throw new ClientError("MalformedResponse", { cause })\n }\n }\n boundary = buffer.indexOf("\\n\\n")\n }\n if (next.done) return\n }\n } finally {\n try {\n await reader.cancel()\n } catch {}\n reader.releaseLock()\n }\n },\n })\n\n return { ${fields.join(", ")} }\n}\n\nfunction appendQuery(params: URLSearchParams, key: string, value: unknown): void {\n if (value === undefined) return\n if (value === null) {\n params.append(key, "null")\n return\n }\n if (Array.isArray(value)) {\n for (const item of value) appendQuery(params, key, item)\n return\n }\n if (typeof value === "object") {\n for (const [child, item] of Object.entries(value)) appendQuery(params, \`\${key}[\${child}]\`, item)\n return\n }\n params.append(key, String(value))\n}\n\nasync function json(response: Response): Promise {\n if (!isContentType(response, "application/json") && !response.headers.get("content-type")?.includes("+json")) {\n try {\n await response.body?.cancel()\n } catch {}\n throw new ClientError("UnsupportedContentType")\n }\n let text: string\n try {\n text = await response.text()\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n if (text === "") throw new ClientError("MalformedResponse")\n try {\n return JSON.parse(text)\n } catch (cause) {\n throw new ClientError("MalformedResponse", { cause })\n }\n}\n\nfunction isContentType(response: Response, expected: string) {\n return response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() === expected\n}\n` } -function promiseTypePrefix(group: string, endpoint: string) { - return `${identifierPart(group)}${identifierPart(endpoint)}` +function promiseTypePrefix(group: string, path: ReadonlyArray) { + return `${identifierPart(group)}${path.map(identifierPart).join("")}` +} + +function clientOperationKey(group: Group, endpoint: Endpoint) { + return [group.identifier, ...endpoint.clientPath].join(".") } function clientEndpointName(name: string) { return name.slice(name.lastIndexOf(".") + 1) } +function normalizeClientPath(path: EndpointName) { + const result = typeof path === "string" ? [path] : [...path] + if (result.length === 0 || result.some((part) => part.length === 0)) { + throw new GenerationError({ reason: "Client endpoint path must contain non-empty names" }) + } + if (result.some((part) => part === "__proto__")) { + throw new GenerationError({ reason: "Client endpoint path cannot contain __proto__" }) + } + if (typeof path !== "string" && result.some((part) => part.includes("."))) { + throw new GenerationError({ reason: "Nested client endpoint path segments cannot contain dots" }) + } + return result as [string, ...Array] +} + +function assertUniqueClientPaths( + paths: ReadonlyArray>, + reason: (path: ReadonlyArray) => string, +) { + const seen = new Set() + for (const path of paths) { + const key = path.join("\0") + for (const existing of seen) { + if (existing === key || existing.startsWith(`${key}\0`) || key.startsWith(`${existing}\0`)) { + throw new GenerationError({ reason: reason(path) }) + } + } + seen.add(key) + } +} + +function renderClientTree( + endpoints: ReadonlyArray, + leaf: (endpoint: Endpoint, index: number) => string, + field: (name: string, value: string) => string, + separator: string, +) { + type Node = { endpoint?: { readonly value: Endpoint; readonly index: number }; readonly children: Map } + const root: Node = { children: new Map() } + endpoints.forEach((endpoint, index) => { + const node = endpoint.clientPath.reduce((parent, name) => { + const child: Node = parent.children.get(name) ?? { children: new Map() } + parent.children.set(name, child) + return child + }, root) + node.endpoint = { value: endpoint, index } + }) + const render = (node: Node): string => + Array.from(node.children, ([name, child]) => + field( + name, + child.endpoint === undefined ? `{ ${render(child)} }` : leaf(child.endpoint.value, child.endpoint.index), + ), + ).join(separator) + return render(root) +} + function identifierPart(value: string) { return value .split(/[^A-Za-z0-9]+/) @@ -1266,9 +1381,12 @@ function renderGroup(group: Group, groupIndex: number) { const declarations = renderSchemas(slots) const groupSource = `HttpApiGroup.make(${JSON.stringify(group.identifier)}, { topLevel: ${group.endpoints[0]?.topLevel ?? false} })${endpointSources.map((endpoint) => `.add(${endpoint})`).join("")}` const usesHttpApiSchema = endpointSources.some((source) => source.includes("HttpApiSchema.")) - const methods = group.endpoints - .map((item, index) => `${JSON.stringify(item.operation.name)}: Endpoint${index}(raw)`) - .join(", ") + const methods = renderClientTree( + group.endpoints, + (_item, index) => `Endpoint${index}(raw)`, + (name, value) => `${JSON.stringify(name)}: ${value}`, + ", ", + ) const rawGroup = group.endpoints[0]?.topLevel ? `HttpApiClient.Client` : `HttpApiClient.Client.Group` diff --git a/packages/httpapi-codegen/test/generate.test.ts b/packages/httpapi-codegen/test/generate.test.ts index 4ee5749b1d..f6a55e73a0 100644 --- a/packages/httpapi-codegen/test/generate.test.ts +++ b/packages/httpapi-codegen/test/generate.test.ts @@ -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') + expect(apiShape).toContain('readonly "put": SessionInstructionsPutOperation') + expect(apiShape).toContain('readonly "remove": SessionInstructionsRemoveOperation') + expect(apiShape).toContain('readonly "instructions.flat": SessionInstructionsFlatOperation') + }) + + 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 = [] + + 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( diff --git a/packages/protocol/src/client.ts b/packages/protocol/src/client.ts index 3d2993a02b..1abf666796 100644 --- a/packages/protocol/src/client.ts +++ b/packages/protocol/src/client.ts @@ -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", diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index 3d82cd3297..4e2cc5dd9b 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -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 = (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 = (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.", }), ), ) diff --git a/packages/schema/src/instruction-entry.ts b/packages/schema/src/instruction-entry.ts new file mode 100644 index 0000000000..09c7e39de1 --- /dev/null +++ b/packages/schema/src/instruction-entry.ts @@ -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/`, 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 {} diff --git a/packages/schema/src/session-context-entry.ts b/packages/schema/src/session-context-entry.ts deleted file mode 100644 index fbaeb53062..0000000000 --- a/packages/schema/src/session-context-entry.ts +++ /dev/null @@ -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/`, 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 {} diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index 824b1690db..c79b0da746 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -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, diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts index da344ba528..51400b2e39 100644 --- a/packages/schema/test/event-manifest.test.ts +++ b/packages/schema/test/event-manifest.test.ts @@ -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", diff --git a/packages/sdk-next/test/embedded.test.ts b/packages/sdk-next/test/embedded.test.ts index 72edfdbc91..cd9cd6a50d 100644 --- a/packages/sdk-next/test/embedded.test.ts +++ b/packages/sdk-next/test/embedded.test.ts @@ -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" }, ) diff --git a/packages/sdk/js/script/build.ts b/packages/sdk/js/script/build.ts index 220bf43da3..fdc1d76bc6 100755 --- a/packages/sdk/js/script/build.ts +++ b/packages/sdk/js/script/build.ts @@ -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, ) ) { diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 2243f8e89d..67fd49d378 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -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( 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( parameters: { sessionID: string - key: SessionContextEntryKey2 + key: InstructionEntryKey2 }, options?: Options, ) { @@ -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( parameters: { sessionID: string - key: SessionContextEntryKey2 + key: InstructionEntryKey2 value?: unknown }, options?: Options, @@ -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 diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index de17b34bb6..443d4d4cce 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -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 + data: Array } } -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 = { /** * */ 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 = { /** * */ 204: void } -export type V2SessionContextEntryPutResponse = - V2SessionContextEntryPutResponses[keyof V2SessionContextEntryPutResponses] +export type V2SessionInstructionsEntryPutResponse = + V2SessionInstructionsEntryPutResponses[keyof V2SessionInstructionsEntryPutResponses] export type V2SessionLogData = { body?: never diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index 72d238e684..2bb45d52ed 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -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() }), ) diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index ae6626be39..7fdde0d49a 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -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), diff --git a/packages/tui/src/routes/session/rows.ts b/packages/tui/src/routes/session/rows.ts index fc0b38b1ad..c0d44c3d80 100644 --- a/packages/tui/src/routes/session/rows.ts +++ b/packages/tui/src/routes/session/rows.ts @@ -149,7 +149,7 @@ export function createSessionRows(sessionID: Accessor) { } 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_")) diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 0e43377657..b9beb0e536 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -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 @@ -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 { diff --git a/specs/v2/schema-changelog.md b/specs/v2/schema-changelog.md index 3fd4fb4a17..62c85ef3e8 100644 --- a/specs/v2/schema-changelog.md +++ b/specs/v2/schema-changelog.md @@ -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. diff --git a/specs/v2/session.md b/specs/v2/session.md index c4307b6897..7ba277139b 100644 --- a/specs/v2/session.md +++ b/specs/v2/session.md @@ -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. |