feat(core): persist v2 session context epochs (#30789)
This commit is contained in:
parent
c47cb28781
commit
1af8dafd3e
65
CONTEXT.md
65
CONTEXT.md
@ -8,12 +8,15 @@ OpenCode sessions preserve durable conversational history while assembling the r
|
|||||||
The structured collection of contextual facts presented to the model as initial instructions and chronological updates.
|
The structured collection of contextual facts presented to the model as initial instructions and chronological updates.
|
||||||
_Avoid_: System prompt
|
_Avoid_: System prompt
|
||||||
|
|
||||||
**Context Component**:
|
**Context Source**:
|
||||||
One independently loaded fact within the **System Context**, represented by a stable key and one effectfully loaded baseline/update rendering.
|
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.
|
||||||
_Avoid_: Prompt fragment
|
_Avoid_: Prompt fragment
|
||||||
|
|
||||||
|
**System Context Registry**:
|
||||||
|
The Location-scoped registry of ordered, scoped producers that contribute to the current **System Context**.
|
||||||
|
|
||||||
**Mid-Conversation System Message**:
|
**Mid-Conversation System Message**:
|
||||||
A durable chronological instruction that tells the model the newly effective state of a changed **Context Component**.
|
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
|
_Avoid_: System update, system notification, raw text diff
|
||||||
|
|
||||||
**Context Epoch**:
|
**Context Epoch**:
|
||||||
@ -23,48 +26,57 @@ The span during which one initially rendered **System Context** remains immutabl
|
|||||||
The full **System Context** rendered at the start of a **Context Epoch**.
|
The full **System Context** rendered at the start of a **Context Epoch**.
|
||||||
_Avoid_: Live system prompt
|
_Avoid_: Live system prompt
|
||||||
|
|
||||||
**Context Checkpoint**:
|
**Context Snapshot**:
|
||||||
The durable model-hidden comparison state used to detect which **Context Components** changed since context was last admitted to a provider turn.
|
The overwriteable model-hidden JSON state used to compare each **Context Source** with the value last admitted to a provider turn.
|
||||||
|
|
||||||
**Unavailable Context**:
|
**Unavailable Context**:
|
||||||
An expected temporary inability to load a **Context Component** value; the runtime retains its prior effective state and emits no update, or omits it until first successfully loaded.
|
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.
|
||||||
|
|
||||||
**Safe Provider-Turn Boundary**:
|
**Safe Provider-Turn Boundary**:
|
||||||
The point immediately before a provider call, after durable input promotion and any required tool settlement, where context changes may be admitted chronologically.
|
The point immediately before a provider call, after durable input promotion and any required tool settlement, where context changes may be admitted chronologically.
|
||||||
|
|
||||||
## Relationships
|
## Relationships
|
||||||
|
|
||||||
- A **System Context** contains one or more **Context Components**.
|
- A **System Context** is an opaque carrier composed from zero or more **Context Sources**.
|
||||||
- A changed **Context Component** may produce one **Mid-Conversation System Message** containing its newly effective 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 Provider-Turn Boundary**.
|
||||||
- A **Mid-Conversation System Message** persists its originating **Context Component** key and the exact rendered text sent to the model.
|
- A changed **Context Source** may produce one **Mid-Conversation System Message** containing its newly effective state.
|
||||||
- A **Context Checkpoint** advances atomically with the corresponding durable **Mid-Conversation System Message**.
|
- A **Mid-Conversation System Message** persists the exact combined rendered text sent to the model.
|
||||||
- A **Context Checkpoint** stores one rendered-content hash per stable **Context Component** key so core and plugin-defined components can evolve independently.
|
- The current **Context Snapshot** advances atomically with the corresponding durable **Mid-Conversation System Message**.
|
||||||
- Changes from multiple **Context Components** admitted at one safe boundary combine into one **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 Provider-Turn Boundary**, never pushed asynchronously when their source changes.
|
- Context changes are sampled and admitted lazily at a **Safe Provider-Turn Boundary**, never pushed asynchronously when their source changes.
|
||||||
- At a **Safe Provider-Turn Boundary**, newly promoted user input or settled tool results precede any combined **Mid-Conversation System Message**.
|
- At a **Safe Provider-Turn Boundary**, newly promoted user input or settled tool results precede any combined **Mid-Conversation System Message**.
|
||||||
- The first provider turn renders the latest **Baseline System Context** and initializes its **Context Checkpoint** without emitting a redundant **Mid-Conversation System Message**.
|
- The first provider turn 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 turn instead of persisting an incomplete baseline.
|
||||||
- Compaction starts a new **Context Epoch** with a freshly rendered **Baseline System Context** and **Context Checkpoint**; prior **Mid-Conversation System Messages** remain durable audit history but leave projected model history.
|
- 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.
|
||||||
- A **Context Checkpoint** is an evolvable component map; a newly registered core or plugin-defined **Context Component** absent from an existing checkpoint emits its current state once at the next **Safe Provider-Turn Boundary**.
|
- 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.
|
||||||
- **Context Component** keys are stable and namespaced; duplicate keys fail assembly. Built-in components preserve declaration order and plugin-defined components append in lexicographic key order so rendered context is deterministic.
|
- A newly registered core or plugin-defined **Context Source** absent from the current snapshot emits its baseline rendering once at the next **Safe Provider-Turn Boundary**.
|
||||||
- Each **Context Component** loader returns its model-visible baseline string and absolute current-state update string from one coherent sample; the update string is hashed for change detection.
|
- **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(...)` represents an explicit baseline-replacing transition such as compaction or model/provider switch; it either produces a fresh generation or reports that replacement is blocked by unavailable admitted context.
|
||||||
|
- Context Epoch preparation retries until stable after optimistic revision mismatches so concurrent replacement requests cannot terminate an otherwise valid safe-boundary run.
|
||||||
- **Unavailable Context** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text.
|
- **Unavailable Context** uses stale-while-revalidate semantics and is distinct from a successfully loaded absence, which may emit removal text.
|
||||||
- Ordinary **Context Component** loaders return values directly; loaders that intentionally use stale-while-revalidate may explicitly return **Unavailable Context**.
|
- Ordinary **Context Source** loaders return values directly; loaders that intentionally use stale-while-revalidate may explicitly return **Unavailable Context**.
|
||||||
- Nested project instruction files discovered while reading join the effective instructions returned by the instruction service and are admitted durably at the next **Safe Provider-Turn Boundary**.
|
- Nested project instruction discovery after successful reads remains a follow-up; when implemented, discovered instructions must be admitted durably at the next **Safe Provider-Turn Boundary**.
|
||||||
- A discovered nested project instruction remains active for the session while it stays in the same location and is folded into later **Baseline System Contexts** after compaction.
|
|
||||||
- Location-scoped services naturally re-resolve effective context when a moved session next runs in its destination location.
|
- 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.
|
||||||
|
- Context Epoch initialization is fenced against the authoritative Session Location, so an old-Location runner cannot recreate source context after a concurrent move.
|
||||||
- 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.
|
- 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.
|
||||||
- Plugin-defined **Context Components** register through a scoped replayable registry so plugin hot reload adds and removes components predictably.
|
- The first instruction-service slice observes global and upward project `AGENTS.md` files as one ordered aggregate **Context Source** at each **Safe Provider-Turn 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.
|
||||||
- Context source changes never wake idle sessions; the next naturally scheduled **Safe Provider-Turn Boundary** loads and compares current values lazily.
|
- Context source changes never wake idle sessions; the next naturally scheduled **Safe Provider-Turn Boundary** loads and compares current values lazily.
|
||||||
- Once admitted, a **Mid-Conversation System Message** remains durable even if the following provider attempt fails and is replayed unchanged on retry.
|
- Once admitted, a **Mid-Conversation System Message** remains durable even if the following provider attempt fails and is replayed unchanged on retry.
|
||||||
- **Mid-Conversation System Messages** remain durable model-projection history but are hidden from normal user-facing transcript surfaces.
|
- **Mid-Conversation System Messages** remain durable Session-message history; normal user-facing transcript surfaces may hide them.
|
||||||
- The date **Context Component** initially preserves host-local calendar-date behavior; a configured user timezone may replace that default later.
|
- 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 **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** is stored durably and reused verbatim across process restarts within its **Context Epoch**.
|
||||||
- A **Baseline System Context** durably preserves deterministic keyed top-level component strings rather than eagerly joining all text; request assembly lowers them into canonical LLM system parts.
|
- A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix.
|
||||||
- Compaction or a model/provider switch starts a new **Context Epoch** because the baseline can be replaced without preserving the prior provider cache.
|
- Compaction or a model/provider switch starts a new **Context Epoch** because the baseline can be replaced without preserving the prior provider cache.
|
||||||
- A model/provider switch always starts a new **Context Epoch** while preserving chronological conversation history.
|
- A model/provider switch always starts a new **Context Epoch** while preserving chronological conversation history.
|
||||||
- A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise.
|
- A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise.
|
||||||
- When an effective instruction file changes, its **Mid-Conversation System Message** includes the complete current contents and supersedes the prior version from that source; when it is removed, the message states that it no longer applies.
|
- 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.
|
||||||
|
- Ambient project instruction discovery honors `OPENCODE_DISABLE_PROJECT_CONFIG`; global instructions remain eligible.
|
||||||
|
|
||||||
## Example dialogue
|
## Example dialogue
|
||||||
|
|
||||||
@ -73,5 +85,4 @@ The point immediately before a provider call, after durable input promotion and
|
|||||||
|
|
||||||
## Flagged ambiguities
|
## 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 Components**, or narrow its semantics.
|
- 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.
|
||||||
- A location change likely starts a new **Context Epoch** so location-dependent instructions and discovery can be rebuilt cleanly, but implementation should verify whether an append-only update is sufficient and meaningfully preserves cache.
|
|
||||||
|
|||||||
@ -0,0 +1,9 @@
|
|||||||
|
CREATE TABLE `session_context_epoch` (
|
||||||
|
`session_id` text PRIMARY KEY,
|
||||||
|
`baseline` text NOT NULL,
|
||||||
|
`snapshot` text NOT NULL,
|
||||||
|
`baseline_seq` integer NOT NULL,
|
||||||
|
`replacement_seq` integer,
|
||||||
|
`revision` integer DEFAULT 0 NOT NULL,
|
||||||
|
CONSTRAINT `fk_session_context_epoch_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE
|
||||||
|
);
|
||||||
1980
packages/core/migration/20260605003541_add_session_context_snapshot/snapshot.json
generated
Normal file
1980
packages/core/migration/20260605003541_add_session_context_snapshot/snapshot.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
1
packages/core/src/database/migration.gen.ts
generated
1
packages/core/src/database/migration.gen.ts
generated
@ -32,5 +32,6 @@ export const migrations = (
|
|||||||
import("./migration/20260603141458_session_input_inbox"),
|
import("./migration/20260603141458_session_input_inbox"),
|
||||||
import("./migration/20260603160727_jittery_ezekiel_stane"),
|
import("./migration/20260603160727_jittery_ezekiel_stane"),
|
||||||
import("./migration/20260604172448_event_sourced_session_input"),
|
import("./migration/20260604172448_event_sourced_session_input"),
|
||||||
|
import("./migration/20260605003541_add_session_context_snapshot"),
|
||||||
])
|
])
|
||||||
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
||||||
|
|||||||
@ -0,0 +1,21 @@
|
|||||||
|
import { Effect } from "effect"
|
||||||
|
import type { DatabaseMigration } from "../migration"
|
||||||
|
|
||||||
|
export default {
|
||||||
|
id: "20260605003541_add_session_context_snapshot",
|
||||||
|
up(tx) {
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
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,
|
||||||
|
\`replacement_seq\` integer,
|
||||||
|
\`revision\` integer DEFAULT 0 NOT NULL,
|
||||||
|
CONSTRAINT \`fk_session_context_epoch_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
} satisfies DatabaseMigration.Migration
|
||||||
@ -45,6 +45,8 @@ export type Payload<D extends Definition = Definition> = {
|
|||||||
readonly version?: number
|
readonly version?: number
|
||||||
readonly location?: Location.Ref
|
readonly location?: Location.Ref
|
||||||
readonly metadata?: Record<string, unknown>
|
readonly metadata?: Record<string, unknown>
|
||||||
|
/** Internal replay marker for projectors that own non-replicated operational state. */
|
||||||
|
readonly replay?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Projector<D extends Definition = Definition> = (event: Payload<D>) => Effect.Effect<void>
|
export type Projector<D extends Definition = Definition> = (event: Payload<D>) => Effect.Effect<void>
|
||||||
@ -137,6 +139,8 @@ export interface PublishOptions {
|
|||||||
readonly id?: ID
|
readonly id?: ID
|
||||||
readonly metadata?: Record<string, unknown>
|
readonly metadata?: Record<string, unknown>
|
||||||
readonly location?: Location.Ref
|
readonly location?: Location.Ref
|
||||||
|
/** Local operational projection committed atomically with a new synchronized event. Not replayed or serialized. */
|
||||||
|
readonly commit?: (seq: number) => Effect.Effect<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
@ -215,6 +219,7 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
readonly ownerID?: string
|
readonly ownerID?: string
|
||||||
readonly strictOwner?: boolean
|
readonly strictOwner?: boolean
|
||||||
},
|
},
|
||||||
|
commit?: (seq: number) => Effect.Effect<void>,
|
||||||
) {
|
) {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const definition = registry.get(event.type)
|
const definition = registry.get(event.type)
|
||||||
@ -330,6 +335,7 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
for (const projector of list) {
|
for (const projector of list) {
|
||||||
yield* projector({ ...event, seq } as Payload)
|
yield* projector({ ...event, seq } as Payload)
|
||||||
}
|
}
|
||||||
|
if (commit) yield* commit(seq)
|
||||||
yield* db
|
yield* db
|
||||||
.insert(EventSequenceTable)
|
.insert(EventSequenceTable)
|
||||||
.values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }])
|
.values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }])
|
||||||
@ -375,11 +381,18 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function publishEvent<D extends Definition>(event: Payload<D>) {
|
function publishEvent<D extends Definition>(event: Payload<D>, commit?: PublishOptions["commit"]) {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const durable = registry.get(event.type)?.sync !== undefined
|
const durable = registry.get(event.type)?.sync !== undefined
|
||||||
|
if (!durable && commit)
|
||||||
|
return yield* Effect.die(
|
||||||
|
new InvalidSyncEventError({
|
||||||
|
type: event.type,
|
||||||
|
message: "Local commit hooks require a synchronized event",
|
||||||
|
}),
|
||||||
|
)
|
||||||
if (durable) {
|
if (durable) {
|
||||||
const committed = yield* commitSyncEvent(event as Payload)
|
const committed = yield* commitSyncEvent(event as Payload, undefined, commit)
|
||||||
if (committed) {
|
if (committed) {
|
||||||
event = { ...event, seq: committed.seq }
|
event = { ...event, seq: committed.seq }
|
||||||
yield* Effect.forEach(syncHandlers, (sync) => observe(event as Payload, "sync", sync), { discard: true })
|
yield* Effect.forEach(syncHandlers, (sync) => observe(event as Payload, "sync", sync), { discard: true })
|
||||||
@ -424,14 +437,17 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
(serviceLocation
|
(serviceLocation
|
||||||
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
|
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
|
||||||
: undefined)
|
: undefined)
|
||||||
return yield* publishEvent({
|
return yield* publishEvent(
|
||||||
id: options?.id ?? ID.create(),
|
{
|
||||||
...(options?.metadata ? { metadata: options.metadata } : {}),
|
id: options?.id ?? ID.create(),
|
||||||
type: definition.type,
|
...(options?.metadata ? { metadata: options.metadata } : {}),
|
||||||
...(definition.sync === undefined ? {} : { version: definition.sync.version }),
|
type: definition.type,
|
||||||
...(location ? { location } : {}),
|
...(definition.sync === undefined ? {} : { version: definition.sync.version }),
|
||||||
data,
|
...(location ? { location } : {}),
|
||||||
} as Payload<D>)
|
data,
|
||||||
|
} as Payload<D>,
|
||||||
|
options?.commit,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -451,6 +467,7 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
type: definition.type,
|
type: definition.type,
|
||||||
version: definition.sync.version,
|
version: definition.sync.version,
|
||||||
data: definition.decode(event.data),
|
data: definition.decode(event.data),
|
||||||
|
replay: true,
|
||||||
} as Payload
|
} as Payload
|
||||||
const committed = yield* commitSyncEvent(payload, {
|
const committed = yield* commitSyncEvent(payload, {
|
||||||
seq: event.seq,
|
seq: event.seq,
|
||||||
|
|||||||
91
packages/core/src/instruction-context.ts
Normal file
91
packages/core/src/instruction-context.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
export * as InstructionContext from "./instruction-context"
|
||||||
|
|
||||||
|
import { Array, Effect, Layer, Schema } from "effect"
|
||||||
|
import { isAbsolute, join, relative, sep } from "path"
|
||||||
|
import { FSUtil } from "./fs-util"
|
||||||
|
import { Flag } from "./flag/flag"
|
||||||
|
import { Global } from "./global"
|
||||||
|
import { Location } from "./location"
|
||||||
|
import { AbsolutePath } from "./schema"
|
||||||
|
import { SystemContext } from "./system-context"
|
||||||
|
import { SystemContextRegistry } from "./system-context-registry"
|
||||||
|
|
||||||
|
class File extends Schema.Class<File>("InstructionContext.File")({
|
||||||
|
path: AbsolutePath,
|
||||||
|
content: Schema.String,
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
const Files = Schema.Array(File)
|
||||||
|
const key = SystemContext.Key.make("core/instructions")
|
||||||
|
|
||||||
|
export const layer = Layer.effectDiscard(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const fs = yield* FSUtil.Service
|
||||||
|
const global = yield* Global.Service
|
||||||
|
const location = yield* Location.Service
|
||||||
|
const registry = yield* SystemContextRegistry.Service
|
||||||
|
|
||||||
|
const source = (value: ReadonlyArray<File> | SystemContext.Unavailable) =>
|
||||||
|
SystemContext.make({
|
||||||
|
key,
|
||||||
|
codec: Schema.toCodecJson(Files),
|
||||||
|
load: Effect.succeed(value),
|
||||||
|
baseline: render,
|
||||||
|
update: (_previous, current) => `These instructions replace all previously loaded ambient instructions.\n\n${render(current)}`,
|
||||||
|
removed: () => "Previously loaded instructions no longer apply.",
|
||||||
|
})
|
||||||
|
|
||||||
|
const observe = Effect.fn("InstructionContext.observe")(function* () {
|
||||||
|
const start = FSUtil.resolve(location.directory)
|
||||||
|
const stop = FSUtil.resolve(location.project.directory)
|
||||||
|
const fromProject = relative(stop, start)
|
||||||
|
const insideProject =
|
||||||
|
fromProject === "" || (fromProject !== ".." && !fromProject.startsWith(`..${sep}`) && !isAbsolute(fromProject))
|
||||||
|
const discovered = new Set(
|
||||||
|
(Flag.OPENCODE_DISABLE_PROJECT_CONFIG || !insideProject
|
||||||
|
? []
|
||||||
|
: yield* fs.up({
|
||||||
|
targets: ["AGENTS.md"],
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
})
|
||||||
|
).map(FSUtil.resolve),
|
||||||
|
)
|
||||||
|
const paths = Array.dedupe([FSUtil.resolve(join(global.config, "AGENTS.md")), ...discovered])
|
||||||
|
const files = yield* Effect.forEach(
|
||||||
|
paths,
|
||||||
|
(path) =>
|
||||||
|
fs
|
||||||
|
.readFileStringSafe(path)
|
||||||
|
.pipe(
|
||||||
|
Effect.map((content) =>
|
||||||
|
content === undefined ? undefined : new File({ path: AbsolutePath.make(path), content }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
{ concurrency: "unbounded" },
|
||||||
|
)
|
||||||
|
if (files.some((file, index) => file === undefined && discovered.has(paths[index])))
|
||||||
|
return SystemContext.unavailable
|
||||||
|
return files.filter((file): file is File => file !== undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* registry.contribute({
|
||||||
|
key,
|
||||||
|
load: observe().pipe(
|
||||||
|
Effect.map((files) =>
|
||||||
|
files === SystemContext.unavailable
|
||||||
|
? source(files)
|
||||||
|
: files.length === 0
|
||||||
|
? SystemContext.empty
|
||||||
|
: source(files),
|
||||||
|
),
|
||||||
|
Effect.catch(() => Effect.succeed(source(SystemContext.unavailable))),
|
||||||
|
Effect.catchDefect(() => Effect.succeed(source(SystemContext.unavailable))),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
function render(files: ReadonlyArray<File>) {
|
||||||
|
return files.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n")
|
||||||
|
}
|
||||||
@ -40,12 +40,14 @@ import { RequestExecutor } from "@opencode-ai/llm/route"
|
|||||||
import * as SessionRunnerLLM from "./session/runner/llm"
|
import * as SessionRunnerLLM from "./session/runner/llm"
|
||||||
import { SessionRunnerModel } from "./session/runner/model"
|
import { SessionRunnerModel } from "./session/runner/model"
|
||||||
import { SessionRunCoordinator } from "./session/run-coordinator"
|
import { SessionRunCoordinator } from "./session/run-coordinator"
|
||||||
|
import { SystemContextBuiltIns } from "./system-context-builtins"
|
||||||
import { FetchHttpClient } from "effect/unstable/http"
|
import { FetchHttpClient } from "effect/unstable/http"
|
||||||
|
|
||||||
export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
|
export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
|
||||||
lookup: (ref: Location.Ref) => {
|
lookup: (ref: Location.Ref) => {
|
||||||
const location = Location.layer(ref)
|
const location = Location.layer(ref)
|
||||||
const permissionsAndTools = ToolRegistry.layer.pipe(Layer.provideMerge(PermissionV2.locationLayer))
|
const permissionsAndTools = ToolRegistry.layer.pipe(Layer.provideMerge(PermissionV2.locationLayer))
|
||||||
|
const systemContext = SystemContextBuiltIns.locationLayer
|
||||||
const services = Layer.mergeAll(
|
const services = Layer.mergeAll(
|
||||||
location,
|
location,
|
||||||
Policy.locationLayer,
|
Policy.locationLayer,
|
||||||
@ -60,6 +62,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
|
|||||||
Watcher.locationLayer,
|
Watcher.locationLayer,
|
||||||
Pty.locationLayer,
|
Pty.locationLayer,
|
||||||
SkillV2.locationLayer,
|
SkillV2.locationLayer,
|
||||||
|
systemContext,
|
||||||
permissionsAndTools,
|
permissionsAndTools,
|
||||||
LocationMutation.locationLayer.pipe(Layer.orDie),
|
LocationMutation.locationLayer.pipe(Layer.orDie),
|
||||||
).pipe(Layer.provideMerge(location))
|
).pipe(Layer.provideMerge(location))
|
||||||
|
|||||||
@ -1,54 +0,0 @@
|
|||||||
export * as SessionSystemContext from "./session-system-context"
|
|
||||||
|
|
||||||
import { Context, DateTime, Effect, Layer } from "effect"
|
|
||||||
import { Location } from "./location"
|
|
||||||
import { SystemContext } from "./system-context"
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly load: () => Effect.Effect<SystemContext.Snapshot>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionSystemContext") {}
|
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const location = yield* Location.Service
|
|
||||||
const environment = [
|
|
||||||
"<env>",
|
|
||||||
` Working directory: ${location.directory}`,
|
|
||||||
` Workspace root folder: ${location.project.directory}`,
|
|
||||||
` Is directory a git repo: ${location.vcs?.type === "git" ? "yes" : "no"}`,
|
|
||||||
` Platform: ${process.platform}`,
|
|
||||||
"</env>",
|
|
||||||
].join("\n")
|
|
||||||
const context = SystemContext.struct({
|
|
||||||
environment: SystemContext.value({
|
|
||||||
key: SystemContext.Key.make("core/environment"),
|
|
||||||
load: Effect.succeed({
|
|
||||||
baseline: ["Here is some useful information about the environment you are running in:", environment].join(
|
|
||||||
"\n",
|
|
||||||
),
|
|
||||||
update: ["The environment you are running in is now:", environment].join("\n"),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
date: SystemContext.value({
|
|
||||||
key: SystemContext.Key.make("core/date"),
|
|
||||||
load: DateTime.nowAsDate.pipe(
|
|
||||||
Effect.map((date) => ({
|
|
||||||
baseline: `Today's date: ${date.toDateString()}`,
|
|
||||||
update: `Today's date is now: ${date.toDateString()}`,
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({
|
|
||||||
load: Effect.fn("SessionSystemContext.load")(function* () {
|
|
||||||
return yield* SystemContext.load(context)
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const locationLayer = layer
|
|
||||||
242
packages/core/src/session/context-epoch.ts
Normal file
242
packages/core/src/session/context-epoch.ts
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
export * as SessionContextEpoch from "./context-epoch"
|
||||||
|
|
||||||
|
import { and, eq, isNull, lt, or, sql } from "drizzle-orm"
|
||||||
|
import { DateTime, Effect, Schema } from "effect"
|
||||||
|
import type { Database } from "../database/database"
|
||||||
|
import { EventV2 } from "../event"
|
||||||
|
import { Location } from "../location"
|
||||||
|
import { SystemContext } from "../system-context"
|
||||||
|
import { SystemContextRegistry } from "../system-context-registry"
|
||||||
|
import { SessionEvent } from "./event"
|
||||||
|
import { SessionInput } from "./input"
|
||||||
|
import { SessionMessageID } from "./message-id"
|
||||||
|
import { SessionSchema } from "./schema"
|
||||||
|
import { SessionContextEpochTable, SessionTable } from "./sql"
|
||||||
|
|
||||||
|
type DatabaseService = Database.Interface["db"]
|
||||||
|
|
||||||
|
class RevisionMismatch extends Error {}
|
||||||
|
class LocationMismatch extends Error {}
|
||||||
|
|
||||||
|
const retryRevisionMismatch = <A, E>(attempt: () => Effect.Effect<A, E>): Effect.Effect<A, E> =>
|
||||||
|
attempt().pipe(
|
||||||
|
Effect.catchDefect((defect) =>
|
||||||
|
defect instanceof RevisionMismatch
|
||||||
|
? Effect.yieldNow.pipe(Effect.andThen(retryRevisionMismatch(attempt)))
|
||||||
|
: Effect.die(defect),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
interface Prepared {
|
||||||
|
readonly baseline: string
|
||||||
|
readonly baselineSeq: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initialize(
|
||||||
|
db: DatabaseService,
|
||||||
|
context: SystemContextRegistry.Interface,
|
||||||
|
sessionID: SessionSchema.ID,
|
||||||
|
location: Location.Ref,
|
||||||
|
): Effect.Effect<Prepared | undefined, SystemContext.InitializationBlocked> {
|
||||||
|
return retryRevisionMismatch(() => initializeOnce(db, context, sessionID, location)).pipe(
|
||||||
|
Effect.withSpan("SessionContextEpoch.initialize"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prepare(
|
||||||
|
db: DatabaseService,
|
||||||
|
events: EventV2.Interface,
|
||||||
|
context: SystemContextRegistry.Interface,
|
||||||
|
sessionID: SessionSchema.ID,
|
||||||
|
location: Location.Ref,
|
||||||
|
): Effect.Effect<Prepared, SystemContext.InitializationBlocked> {
|
||||||
|
return retryRevisionMismatch(() => prepareOnce(db, events, context, sessionID, location)).pipe(
|
||||||
|
Effect.withSpan("SessionContextEpoch.prepare"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const prepareOnce = Effect.fnUntraced(function* (
|
||||||
|
db: DatabaseService,
|
||||||
|
events: EventV2.Interface,
|
||||||
|
context: SystemContextRegistry.Interface,
|
||||||
|
sessionID: SessionSchema.ID,
|
||||||
|
location: Location.Ref,
|
||||||
|
) {
|
||||||
|
const [value, stored] = yield* Effect.all([context.load(), find(db, sessionID)], { concurrency: "unbounded" })
|
||||||
|
if (!stored) {
|
||||||
|
const generation = yield* SystemContext.initialize(value)
|
||||||
|
const baselineSeq = yield* insert(db, sessionID, location, generation)
|
||||||
|
return { baseline: generation.baseline, baselineSeq }
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = yield* Schema.decodeUnknownEffect(SystemContext.Snapshot)(stored.snapshot).pipe(Effect.orDie)
|
||||||
|
const result =
|
||||||
|
stored.replacement_seq === null
|
||||||
|
? yield* SystemContext.reconcile(value, snapshot)
|
||||||
|
: yield* SystemContext.replace(value, snapshot)
|
||||||
|
if (result._tag === "Unchanged" || result._tag === "ReplacementBlocked")
|
||||||
|
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
|
||||||
|
if (result._tag === "ReplacementReady") {
|
||||||
|
const replacementSeq = stored.replacement_seq ?? (yield* SessionInput.latestSeq(db, sessionID))
|
||||||
|
yield* replace(db, sessionID, stored.revision, replacementSeq, result.generation)
|
||||||
|
return { baseline: result.generation.baseline, baselineSeq: replacementSeq }
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* events.publish(
|
||||||
|
SessionEvent.ContextUpdated,
|
||||||
|
{ sessionID, messageID: SessionMessageID.ID.create(), timestamp: yield* DateTime.now, text: result.text },
|
||||||
|
{ commit: () => advance(db, sessionID, stored.revision, result.snapshot).pipe(Effect.orDie) },
|
||||||
|
)
|
||||||
|
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
|
||||||
|
})
|
||||||
|
|
||||||
|
const initializeOnce = Effect.fnUntraced(function* (
|
||||||
|
db: DatabaseService,
|
||||||
|
context: SystemContextRegistry.Interface,
|
||||||
|
sessionID: SessionSchema.ID,
|
||||||
|
location: Location.Ref,
|
||||||
|
) {
|
||||||
|
if (yield* exists(db, sessionID)) return
|
||||||
|
const generation = yield* context.load().pipe(Effect.flatMap(SystemContext.initialize))
|
||||||
|
const baselineSeq = yield* insert(db, sessionID, location, generation)
|
||||||
|
return { baseline: generation.baseline, baselineSeq }
|
||||||
|
})
|
||||||
|
|
||||||
|
const exists = Effect.fn("SessionContextEpoch.exists")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||||
|
return (
|
||||||
|
(yield* db
|
||||||
|
.select({ sessionID: SessionContextEpochTable.session_id })
|
||||||
|
.from(SessionContextEpochTable)
|
||||||
|
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||||
|
.get()
|
||||||
|
.pipe(Effect.orDie)) !== undefined
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const find = Effect.fn("SessionContextEpoch.find")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||||
|
return yield* db
|
||||||
|
.select()
|
||||||
|
.from(SessionContextEpochTable)
|
||||||
|
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||||
|
.get()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const requestReplacement = Effect.fn("SessionContextEpoch.requestReplacement")(function* (
|
||||||
|
db: DatabaseService,
|
||||||
|
sessionID: SessionSchema.ID,
|
||||||
|
seq: number,
|
||||||
|
) {
|
||||||
|
return yield* db
|
||||||
|
.update(SessionContextEpochTable)
|
||||||
|
.set({ replacement_seq: seq, revision: sql`${SessionContextEpochTable.revision} + 1` })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(SessionContextEpochTable.session_id, sessionID),
|
||||||
|
lt(SessionContextEpochTable.baseline_seq, seq),
|
||||||
|
or(isNull(SessionContextEpochTable.replacement_seq), lt(SessionContextEpochTable.replacement_seq, seq)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const reset = Effect.fn("SessionContextEpoch.reset")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||||
|
yield* db.delete(SessionContextEpochTable).where(eq(SessionContextEpochTable.session_id, sessionID)).run().pipe(Effect.orDie)
|
||||||
|
})
|
||||||
|
|
||||||
|
const insert = Effect.fnUntraced(function* (
|
||||||
|
db: DatabaseService,
|
||||||
|
sessionID: SessionSchema.ID,
|
||||||
|
location: Location.Ref,
|
||||||
|
generation: SystemContext.Generation,
|
||||||
|
) {
|
||||||
|
return yield* db
|
||||||
|
.transaction(
|
||||||
|
() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const placed = yield* db
|
||||||
|
.select({ sessionID: SessionTable.id })
|
||||||
|
.from(SessionTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(SessionTable.id, sessionID),
|
||||||
|
eq(SessionTable.directory, location.directory),
|
||||||
|
location.workspaceID === undefined
|
||||||
|
? isNull(SessionTable.workspace_id)
|
||||||
|
: eq(SessionTable.workspace_id, location.workspaceID),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.get()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
if (!placed) return yield* Effect.die(new LocationMismatch())
|
||||||
|
const baselineSeq = yield* SessionInput.latestSeq(db, sessionID)
|
||||||
|
yield* db
|
||||||
|
.insert(SessionContextEpochTable)
|
||||||
|
.values({
|
||||||
|
session_id: sessionID,
|
||||||
|
baseline: generation.baseline,
|
||||||
|
snapshot: generation.snapshot,
|
||||||
|
baseline_seq: baselineSeq,
|
||||||
|
revision: 0,
|
||||||
|
})
|
||||||
|
.onConflictDoNothing()
|
||||||
|
.returning({ sessionID: SessionContextEpochTable.session_id })
|
||||||
|
.get()
|
||||||
|
.pipe(
|
||||||
|
Effect.orDie,
|
||||||
|
Effect.flatMap((inserted) => (inserted ? Effect.void : Effect.die(new RevisionMismatch()))),
|
||||||
|
)
|
||||||
|
return baselineSeq
|
||||||
|
}),
|
||||||
|
{ behavior: "immediate" },
|
||||||
|
)
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
})
|
||||||
|
|
||||||
|
const replace = Effect.fnUntraced(function* (
|
||||||
|
db: DatabaseService,
|
||||||
|
sessionID: SessionSchema.ID,
|
||||||
|
expectedRevision: number,
|
||||||
|
baselineSeq: number,
|
||||||
|
generation: SystemContext.Generation,
|
||||||
|
) {
|
||||||
|
const updated = yield* db
|
||||||
|
.update(SessionContextEpochTable)
|
||||||
|
.set({
|
||||||
|
baseline: generation.baseline,
|
||||||
|
snapshot: generation.snapshot,
|
||||||
|
baseline_seq: baselineSeq,
|
||||||
|
replacement_seq: null,
|
||||||
|
revision: expectedRevision + 1,
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(eq(SessionContextEpochTable.session_id, sessionID), eq(SessionContextEpochTable.revision, expectedRevision)),
|
||||||
|
)
|
||||||
|
.returning({ revision: SessionContextEpochTable.revision })
|
||||||
|
.get()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
if (!updated) return yield* Effect.die(new RevisionMismatch())
|
||||||
|
})
|
||||||
|
|
||||||
|
const advance = Effect.fnUntraced(function* (
|
||||||
|
db: DatabaseService,
|
||||||
|
sessionID: SessionSchema.ID,
|
||||||
|
expectedRevision: number,
|
||||||
|
snapshot: SystemContext.Snapshot,
|
||||||
|
) {
|
||||||
|
const updated = yield* db
|
||||||
|
.update(SessionContextEpochTable)
|
||||||
|
.set({ snapshot, revision: expectedRevision + 1 })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(SessionContextEpochTable.session_id, sessionID),
|
||||||
|
eq(SessionContextEpochTable.revision, expectedRevision),
|
||||||
|
isNull(SessionContextEpochTable.replacement_seq),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning({ revision: SessionContextEpochTable.revision })
|
||||||
|
.get()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
if (!updated) return yield* Effect.die(new RevisionMismatch())
|
||||||
|
})
|
||||||
@ -1,47 +1,92 @@
|
|||||||
import { and, asc, desc, eq, gt, gte, or } from "drizzle-orm"
|
import { and, asc, desc, eq, gt, gte, ne, or } from "drizzle-orm"
|
||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import { Database } from "../database/database"
|
import { Database } from "../database/database"
|
||||||
import { MessageDecodeError } from "./error"
|
import { MessageDecodeError } from "./error"
|
||||||
import { SessionMessage } from "./message"
|
import { SessionMessage } from "./message"
|
||||||
import { SessionSchema } from "./schema"
|
import { SessionSchema } from "./schema"
|
||||||
import { SessionMessageTable } from "./sql"
|
import { SessionContextEpochTable, SessionMessageTable } from "./sql"
|
||||||
|
|
||||||
type DatabaseService = Database.Interface["db"]
|
type DatabaseService = Database.Interface["db"]
|
||||||
|
|
||||||
const decode = Schema.decodeUnknownEffect(SessionMessage.Message)
|
const decode = Schema.decodeUnknownEffect(SessionMessage.Message)
|
||||||
|
|
||||||
export const load = Effect.fn("SessionContext.load")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||||
const compaction = yield* db
|
return yield* db
|
||||||
.select()
|
.select({ seq: SessionMessageTable.seq })
|
||||||
.from(SessionMessageTable)
|
.from(SessionMessageTable)
|
||||||
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction")))
|
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction")))
|
||||||
.orderBy(desc(SessionMessageTable.seq))
|
.orderBy(desc(SessionMessageTable.seq))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.get()
|
.get()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
const rows = yield* db
|
})
|
||||||
|
|
||||||
|
const messageRows = Effect.fnUntraced(function* (
|
||||||
|
db: DatabaseService,
|
||||||
|
sessionID: SessionSchema.ID,
|
||||||
|
compaction: { readonly seq: number } | undefined,
|
||||||
|
baselineSeq?: number,
|
||||||
|
) {
|
||||||
|
return yield* db
|
||||||
.select()
|
.select()
|
||||||
.from(SessionMessageTable)
|
.from(SessionMessageTable)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(SessionMessageTable.session_id, sessionID),
|
eq(SessionMessageTable.session_id, sessionID),
|
||||||
compaction ? or(gte(SessionMessageTable.seq, compaction.seq)) : undefined,
|
compaction
|
||||||
|
? or(
|
||||||
|
gte(SessionMessageTable.seq, compaction.seq),
|
||||||
|
baselineSeq === undefined
|
||||||
|
? undefined
|
||||||
|
: and(eq(SessionMessageTable.type, "system"), gt(SessionMessageTable.seq, baselineSeq)),
|
||||||
|
)
|
||||||
|
: undefined,
|
||||||
|
baselineSeq === undefined
|
||||||
|
? undefined
|
||||||
|
: or(ne(SessionMessageTable.type, "system"), gt(SessionMessageTable.seq, baselineSeq)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.orderBy(asc(SessionMessageTable.seq))
|
.orderBy(asc(SessionMessageTable.seq))
|
||||||
.all()
|
.all()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
return yield* Effect.forEach(rows, (row) =>
|
})
|
||||||
decode({ ...row.data, id: row.id, type: row.type }).pipe(
|
|
||||||
Effect.mapError(
|
const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||||
() =>
|
decode({ ...row.data, id: row.id, type: row.type }).pipe(
|
||||||
new MessageDecodeError({
|
Effect.mapError(
|
||||||
sessionID: SessionSchema.ID.make(row.session_id),
|
() =>
|
||||||
messageID: SessionMessage.ID.make(row.id),
|
new MessageDecodeError({
|
||||||
}),
|
sessionID: SessionSchema.ID.make(row.session_id),
|
||||||
),
|
messageID: SessionMessage.ID.make(row.id),
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const load = Effect.fn("SessionContext.load")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||||
|
const [epoch, compaction] = yield* Effect.all(
|
||||||
|
[
|
||||||
|
db
|
||||||
|
.select({ baselineSeq: SessionContextEpochTable.baseline_seq })
|
||||||
|
.from(SessionContextEpochTable)
|
||||||
|
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||||
|
.get()
|
||||||
|
.pipe(Effect.orDie),
|
||||||
|
latestCompaction(db, sessionID),
|
||||||
|
],
|
||||||
|
{ concurrency: "unbounded" },
|
||||||
|
)
|
||||||
|
return yield* Effect.forEach(yield* messageRows(db, sessionID, compaction, epoch?.baselineSeq), decodeMessageRow)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const loadForRunner = Effect.fn("SessionContext.loadForRunner")(function* (
|
||||||
|
db: DatabaseService,
|
||||||
|
sessionID: SessionSchema.ID,
|
||||||
|
baselineSeq: number,
|
||||||
|
) {
|
||||||
|
return yield* Effect.forEach(
|
||||||
|
yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID), baselineSeq),
|
||||||
|
decodeMessageRow,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export * as SessionContext from "./context"
|
export * as SessionContext from "./context"
|
||||||
|
|||||||
@ -119,6 +119,17 @@ export namespace PromptLifecycle {
|
|||||||
export type Promoted = typeof Promoted.Type
|
export type Promoted = typeof Promoted.Type
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const ContextUpdated = EventV2.define({
|
||||||
|
type: "session.next.context.updated",
|
||||||
|
...options,
|
||||||
|
schema: {
|
||||||
|
...Base,
|
||||||
|
messageID: SessionMessageID.ID,
|
||||||
|
text: Schema.String,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export type ContextUpdated = typeof ContextUpdated.Type
|
||||||
|
|
||||||
export const Synthetic = EventV2.define({
|
export const Synthetic = EventV2.define({
|
||||||
type: "session.next.synthetic",
|
type: "session.next.synthetic",
|
||||||
...options,
|
...options,
|
||||||
@ -444,6 +455,7 @@ const DurableDefinitions = [
|
|||||||
Prompted,
|
Prompted,
|
||||||
PromptLifecycle.Admitted,
|
PromptLifecycle.Admitted,
|
||||||
PromptLifecycle.Promoted,
|
PromptLifecycle.Promoted,
|
||||||
|
ContextUpdated,
|
||||||
Synthetic,
|
Synthetic,
|
||||||
Shell.Started,
|
Shell.Started,
|
||||||
Shell.Ended,
|
Shell.Ended,
|
||||||
|
|||||||
@ -159,6 +159,15 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||||||
},
|
},
|
||||||
"session.next.prompt.admitted": () => Effect.void,
|
"session.next.prompt.admitted": () => Effect.void,
|
||||||
"session.next.prompt.promoted": () => Effect.void,
|
"session.next.prompt.promoted": () => Effect.void,
|
||||||
|
"session.next.context.updated": (event) =>
|
||||||
|
adapter.appendMessage(
|
||||||
|
new SessionMessage.System({
|
||||||
|
id: event.data.messageID,
|
||||||
|
type: "system",
|
||||||
|
text: event.data.text,
|
||||||
|
time: { created: event.data.timestamp },
|
||||||
|
}),
|
||||||
|
),
|
||||||
"session.next.synthetic": (event) => {
|
"session.next.synthetic": (event) => {
|
||||||
return adapter.appendMessage(
|
return adapter.appendMessage(
|
||||||
new SessionMessage.Synthetic({
|
new SessionMessage.Synthetic({
|
||||||
|
|||||||
@ -51,6 +51,12 @@ export class Synthetic extends Schema.Class<Synthetic>("Session.Message.Syntheti
|
|||||||
type: Schema.Literal("synthetic"),
|
type: Schema.Literal("synthetic"),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
|
export class System extends Schema.Class<System>("Session.Message.System")({
|
||||||
|
...Base,
|
||||||
|
type: Schema.Literal("system"),
|
||||||
|
text: SessionEvent.ContextUpdated.data.fields.text,
|
||||||
|
}) {}
|
||||||
|
|
||||||
export class Shell extends Schema.Class<Shell>("Session.Message.Shell")({
|
export class Shell extends Schema.Class<Shell>("Session.Message.Shell")({
|
||||||
...Base,
|
...Base,
|
||||||
type: Schema.Literal("shell"),
|
type: Schema.Literal("shell"),
|
||||||
@ -170,7 +176,16 @@ export class Compaction extends Schema.Class<Compaction>("Session.Message.Compac
|
|||||||
...Base,
|
...Base,
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export const Message = Schema.Union([AgentSwitched, ModelSwitched, User, Synthetic, Shell, Assistant, Compaction])
|
export const Message = Schema.Union([
|
||||||
|
AgentSwitched,
|
||||||
|
ModelSwitched,
|
||||||
|
User,
|
||||||
|
Synthetic,
|
||||||
|
System,
|
||||||
|
Shell,
|
||||||
|
Assistant,
|
||||||
|
Compaction,
|
||||||
|
])
|
||||||
.pipe(Schema.toTaggedUnion("type"))
|
.pipe(Schema.toTaggedUnion("type"))
|
||||||
.annotate({ identifier: "Session.Message" })
|
.annotate({ identifier: "Session.Message" })
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import { SessionMessage } from "./message"
|
|||||||
import { SessionMessageUpdater } from "./message-updater"
|
import { SessionMessageUpdater } from "./message-updater"
|
||||||
import { SessionInput } from "./input"
|
import { SessionInput } from "./input"
|
||||||
import { WorkspaceV2 } from "../workspace"
|
import { WorkspaceV2 } from "../workspace"
|
||||||
|
import { SessionContextEpoch } from "./context-epoch"
|
||||||
import { MessageTable, PartTable, SessionMessageTable, SessionTable } from "./sql"
|
import { MessageTable, PartTable, SessionMessageTable, SessionTable } from "./sql"
|
||||||
import type { DeepMutable } from "../schema"
|
import type { DeepMutable } from "../schema"
|
||||||
|
|
||||||
@ -259,17 +260,20 @@ export const layer = Layer.effectDiscard(
|
|||||||
.pipe(Effect.orDie),
|
.pipe(Effect.orDie),
|
||||||
)
|
)
|
||||||
yield* events.project(SessionEvent.Moved, (event) =>
|
yield* events.project(SessionEvent.Moved, (event) =>
|
||||||
db
|
Effect.gen(function* () {
|
||||||
.update(SessionTable)
|
yield* db
|
||||||
.set({
|
.update(SessionTable)
|
||||||
directory: event.data.location.directory,
|
.set({
|
||||||
path: event.data.subdirectory,
|
directory: event.data.location.directory,
|
||||||
workspace_id: event.data.location.workspaceID ? WorkspaceV2.ID.make(event.data.location.workspaceID) : null,
|
path: event.data.subdirectory,
|
||||||
time_updated: DateTime.toEpochMillis(event.data.timestamp),
|
workspace_id: event.data.location.workspaceID ? WorkspaceV2.ID.make(event.data.location.workspaceID) : null,
|
||||||
})
|
time_updated: DateTime.toEpochMillis(event.data.timestamp),
|
||||||
.where(eq(SessionTable.id, event.data.sessionID))
|
})
|
||||||
.run()
|
.where(eq(SessionTable.id, event.data.sessionID))
|
||||||
.pipe(Effect.orDie),
|
.run()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
yield* SessionContextEpoch.reset(db, event.data.sessionID)
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
yield* events.project(SessionV1.Event.Deleted, (event) =>
|
yield* events.project(SessionV1.Event.Deleted, (event) =>
|
||||||
db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie),
|
db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie),
|
||||||
@ -352,12 +356,18 @@ export const layer = Layer.effectDiscard(
|
|||||||
.pipe(Effect.orDie, Effect.andThen(run(db, event))),
|
.pipe(Effect.orDie, Effect.andThen(run(db, event))),
|
||||||
)
|
)
|
||||||
yield* events.project(SessionEvent.ModelSwitched, (event) =>
|
yield* events.project(SessionEvent.ModelSwitched, (event) =>
|
||||||
db
|
Effect.gen(function* () {
|
||||||
.update(SessionTable)
|
yield* db
|
||||||
.set({ model: event.data.model, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
|
.update(SessionTable)
|
||||||
.where(eq(SessionTable.id, event.data.sessionID))
|
.set({ model: event.data.model, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
|
||||||
.run()
|
.where(eq(SessionTable.id, event.data.sessionID))
|
||||||
.pipe(Effect.orDie, Effect.andThen(run(db, event))),
|
.run()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
yield* run(db, event)
|
||||||
|
if (event.seq === undefined)
|
||||||
|
return yield* Effect.die("Synchronized Session event is missing aggregate sequence")
|
||||||
|
yield* SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.seq)
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
yield* events.project(SessionEvent.Prompted, (event) =>
|
yield* events.project(SessionEvent.Prompted, (event) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@ -413,6 +423,12 @@ export const layer = Layer.effectDiscard(
|
|||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
yield* events.project(SessionEvent.ContextUpdated, (event) => {
|
||||||
|
if (!event.replay || event.seq === undefined) return run(db, event)
|
||||||
|
return run(db, event).pipe(
|
||||||
|
Effect.andThen(SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.seq)),
|
||||||
|
)
|
||||||
|
})
|
||||||
yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
|
yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
|
||||||
yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
|
yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
|
||||||
yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event))
|
yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event))
|
||||||
@ -432,7 +448,12 @@ export const layer = Layer.effectDiscard(
|
|||||||
// yield* events.project(SessionEvent.Retried, (event) => run(db, event))
|
// yield* events.project(SessionEvent.Retried, (event) => run(db, event))
|
||||||
yield* events.project(SessionEvent.Compaction.Started, (event) => run(db, event))
|
yield* events.project(SessionEvent.Compaction.Started, (event) => run(db, event))
|
||||||
yield* events.project(SessionEvent.Compaction.Delta, (event) => run(db, event))
|
yield* events.project(SessionEvent.Compaction.Delta, (event) => run(db, event))
|
||||||
yield* events.project(SessionEvent.Compaction.Ended, (event) => run(db, event))
|
yield* events.project(SessionEvent.Compaction.Ended, (event) => {
|
||||||
|
if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence")
|
||||||
|
return run(db, event).pipe(
|
||||||
|
Effect.andThen(SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.seq)),
|
||||||
|
)
|
||||||
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { Context, Effect, Schema } from "effect"
|
|||||||
import { SessionSchema } from "../schema"
|
import { SessionSchema } from "../schema"
|
||||||
import type { MessageDecodeError } from "../error"
|
import type { MessageDecodeError } from "../error"
|
||||||
import { SessionRunnerModel } from "./model"
|
import { SessionRunnerModel } from "./model"
|
||||||
|
import type { SystemContext } from "../../system-context"
|
||||||
|
|
||||||
export class StepLimitExceededError extends Schema.TaggedErrorClass<StepLimitExceededError>()(
|
export class StepLimitExceededError extends Schema.TaggedErrorClass<StepLimitExceededError>()(
|
||||||
"SessionRunner.StepLimitExceededError",
|
"SessionRunner.StepLimitExceededError",
|
||||||
@ -14,7 +15,12 @@ export class StepLimitExceededError extends Schema.TaggedErrorClass<StepLimitExc
|
|||||||
},
|
},
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
export type RunError = LLMError | SessionRunnerModel.Error | MessageDecodeError | StepLimitExceededError
|
export type RunError =
|
||||||
|
| LLMError
|
||||||
|
| SessionRunnerModel.Error
|
||||||
|
| MessageDecodeError
|
||||||
|
| StepLimitExceededError
|
||||||
|
| SystemContext.InitializationBlocked
|
||||||
|
|
||||||
/** Runs one local continuation from already-recorded Session history. */
|
/** Runs one local continuation from already-recorded Session history. */
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { LLM, LLMClient, LLMError, LLMEvent } from "@opencode-ai/llm"
|
import { LLM, LLMClient, LLMError, LLMEvent, SystemPart } from "@opencode-ai/llm"
|
||||||
import { Cause, DateTime, Effect, FiberSet, Layer, Semaphore, Stream } from "effect"
|
import { Cause, DateTime, Effect, FiberSet, Layer, Semaphore, Stream } from "effect"
|
||||||
import { EventV2 } from "../../event"
|
import { EventV2 } from "../../event"
|
||||||
import { ModelV2 } from "../../model"
|
import { ModelV2 } from "../../model"
|
||||||
@ -14,6 +14,8 @@ import { SessionRunnerModel } from "./model"
|
|||||||
import { Database } from "../../database/database"
|
import { Database } from "../../database/database"
|
||||||
import { SessionInput } from "../input"
|
import { SessionInput } from "../input"
|
||||||
import { QuestionV2 } from "../../question"
|
import { QuestionV2 } from "../../question"
|
||||||
|
import { SystemContextRegistry } from "../../system-context-registry"
|
||||||
|
import { SessionContextEpoch } from "../context-epoch"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs one durable coding-agent Session until it settles.
|
* Runs one durable coding-agent Session until it settles.
|
||||||
@ -34,8 +36,8 @@ import { QuestionV2 } from "../../question"
|
|||||||
* - [x] Resolve the selected model through the location-scoped runner environment.
|
* - [x] Resolve the selected model through the location-scoped runner environment.
|
||||||
* - [ ] Load the selected agent and effective permissions.
|
* - [ ] Load the selected agent and effective permissions.
|
||||||
* - [ ] Build provider/model-specific base instructions and environment facts.
|
* - [ ] Build provider/model-specific base instructions and environment facts.
|
||||||
* - [ ] Load configured project instructions such as `AGENTS.md`, remote instructions, and
|
* - [x] Load global and upward project `AGENTS.md` instructions.
|
||||||
* nearby nested instructions discovered while files are read.
|
* - [ ] Load configured and remote instructions plus nearby nested instructions discovered while files are read.
|
||||||
* - [ ] List available skills in the system prompt and expose a tool for loading skill bodies.
|
* - [ ] List available skills in the system prompt and expose a tool for loading skill bodies.
|
||||||
* - [ ] Resolve referenced files, directories, agents, repositories, MCP resources, and media.
|
* - [ ] Resolve referenced files, directories, agents, repositories, MCP resources, and media.
|
||||||
* - [ ] Apply steering reminders, plugin transforms, and structured-output policy.
|
* - [ ] Apply steering reminders, plugin transforms, and structured-output policy.
|
||||||
@ -85,6 +87,7 @@ export const layer = Layer.effect(
|
|||||||
const tools = yield* ToolRegistry.Service
|
const tools = yield* ToolRegistry.Service
|
||||||
const models = yield* SessionRunnerModel.Service
|
const models = yield* SessionRunnerModel.Service
|
||||||
const store = yield* SessionStore.Service
|
const store = yield* SessionStore.Service
|
||||||
|
const systemContext = yield* SystemContextRegistry.Service
|
||||||
const db = (yield* Database.Service).db
|
const db = (yield* Database.Service).db
|
||||||
const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) {
|
const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) {
|
||||||
const session = yield* store.get(sessionID)
|
const session = yield* store.get(sessionID)
|
||||||
@ -95,7 +98,6 @@ export const layer = Layer.effect(
|
|||||||
const getContext = Effect.fn("SessionRunner.getContext")(function* (sessionID: SessionSchema.ID) {
|
const getContext = Effect.fn("SessionRunner.getContext")(function* (sessionID: SessionSchema.ID) {
|
||||||
return yield* store.context(sessionID)
|
return yield* store.context(sessionID)
|
||||||
})
|
})
|
||||||
|
|
||||||
const failInterruptedTools = Effect.fn("SessionRunner.failInterruptedTools")(function* (
|
const failInterruptedTools = Effect.fn("SessionRunner.failInterruptedTools")(function* (
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
) {
|
) {
|
||||||
@ -126,9 +128,11 @@ export const layer = Layer.effect(
|
|||||||
cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof QuestionV2.RejectedError)
|
cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof QuestionV2.RejectedError)
|
||||||
|
|
||||||
const runTurn = Effect.fn("SessionRunner.runTurn")(function* (
|
const runTurn = Effect.fn("SessionRunner.runTurn")(function* (
|
||||||
session: SessionSchema.Info,
|
sessionID: SessionSchema.ID,
|
||||||
promotion: "steer" | "queue" | undefined,
|
promotion: "steer" | "queue" | undefined,
|
||||||
) {
|
) {
|
||||||
|
const session = yield* getSession(sessionID)
|
||||||
|
const initialized = yield* SessionContextEpoch.initialize(db, systemContext, session.id, session.location)
|
||||||
const model = yield* models.resolve(session)
|
const model = yield* models.resolve(session)
|
||||||
const toolFibers = yield* FiberSet.make<void, never>()
|
const toolFibers = yield* FiberSet.make<void, never>()
|
||||||
let needsContinuation = false
|
let needsContinuation = false
|
||||||
@ -140,9 +144,14 @@ export const layer = Layer.effect(
|
|||||||
yield* SessionInput.promoteSteers(db, events, session.id, cutoff)
|
yield* SessionInput.promoteSteers(db, events, session.id, cutoff)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
yield* failInterruptedTools(session.id)
|
const system = initialized ?? (yield* SessionContextEpoch.prepare(db, events, systemContext, session.id, session.location))
|
||||||
const context = yield* getContext(session.id)
|
const context = yield* store.runnerContext(session.id, system.baselineSeq)
|
||||||
const request = LLM.request({ model, messages: toLLMMessages(context, model), tools: yield* tools.definitions() })
|
const request = LLM.request({
|
||||||
|
model,
|
||||||
|
system: system.baseline.length > 0 ? [SystemPart.make(system.baseline)] : [],
|
||||||
|
messages: toLLMMessages(context, model),
|
||||||
|
tools: yield* tools.definitions(),
|
||||||
|
})
|
||||||
const publisher = createLLMEventPublisher(events, {
|
const publisher = createLLMEventPublisher(events, {
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
agent: session.agent ?? "build",
|
agent: session.agent ?? "build",
|
||||||
@ -235,16 +244,16 @@ export const layer = Layer.effect(
|
|||||||
readonly sessionID: SessionSchema.ID
|
readonly sessionID: SessionSchema.ID
|
||||||
readonly force?: boolean
|
readonly force?: boolean
|
||||||
}) {
|
}) {
|
||||||
const session = yield* getSession(input.sessionID)
|
|
||||||
const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer")
|
const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer")
|
||||||
const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue")
|
const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue")
|
||||||
if (input.force !== true && !hasSteer && !hasQueue) return
|
if (input.force !== true && !hasSteer && !hasQueue) return
|
||||||
|
yield* failInterruptedTools(input.sessionID)
|
||||||
let promotion: "steer" | "queue" | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined
|
let promotion: "steer" | "queue" | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined
|
||||||
let openActivity = input.force === true || hasSteer || hasQueue
|
let openActivity = input.force === true || hasSteer || hasQueue
|
||||||
while (openActivity) {
|
while (openActivity) {
|
||||||
let needsContinuation = true
|
let needsContinuation = true
|
||||||
for (let step = 0; step < MAX_STEPS; step++) {
|
for (let step = 0; step < MAX_STEPS; step++) {
|
||||||
needsContinuation = yield* runTurn(session, promotion)
|
needsContinuation = yield* runTurn(input.sessionID, promotion)
|
||||||
promotion = "steer"
|
promotion = "steer"
|
||||||
if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer")
|
if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer")
|
||||||
if (!needsContinuation) break
|
if (!needsContinuation) break
|
||||||
|
|||||||
@ -111,6 +111,8 @@ function toLLMMessage(message: SessionMessage.Message, model: Model): Message[]
|
|||||||
]
|
]
|
||||||
case "synthetic":
|
case "synthetic":
|
||||||
return [Message.make({ id: message.id, role: "user", content: message.text, metadata: message.metadata })]
|
return [Message.make({ id: message.id, role: "user", content: message.text, metadata: message.metadata })]
|
||||||
|
case "system":
|
||||||
|
return [Message.system(message.text)]
|
||||||
case "shell":
|
case "shell":
|
||||||
return [
|
return [
|
||||||
Message.make({
|
Message.make({
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import type { SessionSchema } from "./schema"
|
|||||||
import type { MessageID, PartID, SessionV1 } from "../v1/session"
|
import type { MessageID, PartID, SessionV1 } from "../v1/session"
|
||||||
import { WorkspaceV2 } from "../workspace"
|
import { WorkspaceV2 } from "../workspace"
|
||||||
import { Timestamps } from "../database/schema.sql"
|
import { Timestamps } from "../database/schema.sql"
|
||||||
|
import type { SystemContext } from "../system-context"
|
||||||
|
|
||||||
type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id">
|
type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id">
|
||||||
type V1MessageData = Omit<SessionV1.Info, "id" | "sessionID">
|
type V1MessageData = Omit<SessionV1.Info, "id" | "sessionID">
|
||||||
@ -161,3 +162,15 @@ export const SessionInputTable = sqliteTable(
|
|||||||
uniqueIndex("session_input_session_promoted_seq_idx").on(table.session_id, table.promoted_seq),
|
uniqueIndex("session_input_session_promoted_seq_idx").on(table.session_id, table.promoted_seq),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const SessionContextEpochTable = sqliteTable("session_context_epoch", {
|
||||||
|
session_id: text()
|
||||||
|
.$type<SessionSchema.ID>()
|
||||||
|
.primaryKey()
|
||||||
|
.references(() => SessionTable.id, { onDelete: "cascade" }),
|
||||||
|
baseline: text().notNull(),
|
||||||
|
snapshot: text({ mode: "json" }).notNull().$type<SystemContext.Snapshot>(),
|
||||||
|
baseline_seq: integer().notNull(),
|
||||||
|
replacement_seq: integer(),
|
||||||
|
revision: integer().notNull().default(0),
|
||||||
|
})
|
||||||
|
|||||||
@ -13,6 +13,10 @@ import { fromRow } from "./info"
|
|||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info | undefined>
|
readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info | undefined>
|
||||||
readonly context: (sessionID: SessionSchema.ID) => Effect.Effect<SessionMessage.Message[], MessageDecodeError>
|
readonly context: (sessionID: SessionSchema.ID) => Effect.Effect<SessionMessage.Message[], MessageDecodeError>
|
||||||
|
readonly runnerContext: (
|
||||||
|
sessionID: SessionSchema.ID,
|
||||||
|
baselineSeq: number,
|
||||||
|
) => Effect.Effect<SessionMessage.Message[], MessageDecodeError>
|
||||||
readonly message: (
|
readonly message: (
|
||||||
messageID: SessionMessage.ID,
|
messageID: SessionMessage.ID,
|
||||||
) => Effect.Effect<{ readonly sessionID: SessionSchema.ID; readonly message: SessionMessage.Message } | undefined>
|
) => Effect.Effect<{ readonly sessionID: SessionSchema.ID; readonly message: SessionMessage.Message } | undefined>
|
||||||
@ -34,6 +38,9 @@ export const layer = Layer.effect(
|
|||||||
context: Effect.fn("SessionStore.context")(function* (sessionID) {
|
context: Effect.fn("SessionStore.context")(function* (sessionID) {
|
||||||
return yield* SessionContext.load(db, sessionID)
|
return yield* SessionContext.load(db, sessionID)
|
||||||
}),
|
}),
|
||||||
|
runnerContext: Effect.fn("SessionStore.runnerContext")(function* (sessionID, baselineSeq) {
|
||||||
|
return yield* SessionContext.loadForRunner(db, sessionID, baselineSeq)
|
||||||
|
}),
|
||||||
message: Effect.fn("SessionStore.message")(function* (messageID) {
|
message: Effect.fn("SessionStore.message")(function* (messageID) {
|
||||||
const row = yield* db
|
const row = yield* db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
47
packages/core/src/system-context-builtins.ts
Normal file
47
packages/core/src/system-context-builtins.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
export * as SystemContextBuiltIns from "./system-context-builtins"
|
||||||
|
|
||||||
|
import { DateTime, Effect, Layer, Schema } from "effect"
|
||||||
|
import { InstructionContext } from "./instruction-context"
|
||||||
|
import { Location } from "./location"
|
||||||
|
import { SystemContext } from "./system-context"
|
||||||
|
import { SystemContextRegistry } from "./system-context-registry"
|
||||||
|
|
||||||
|
const builtIns = Layer.effectDiscard(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const location = yield* Location.Service
|
||||||
|
const registry = yield* SystemContextRegistry.Service
|
||||||
|
const environment = [
|
||||||
|
"<env>",
|
||||||
|
` Working directory: ${location.directory}`,
|
||||||
|
` Workspace root folder: ${location.project.directory}`,
|
||||||
|
` Is directory a git repo: ${location.vcs?.type === "git" ? "yes" : "no"}`,
|
||||||
|
` Platform: ${process.platform}`,
|
||||||
|
"</env>",
|
||||||
|
].join("\n")
|
||||||
|
const context = SystemContext.combine([
|
||||||
|
SystemContext.make({
|
||||||
|
key: SystemContext.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"),
|
||||||
|
codec: Schema.toCodecJson(Schema.String),
|
||||||
|
load: DateTime.nowAsDate.pipe(Effect.map((date) => date.toDateString())),
|
||||||
|
baseline: (date) => `Today's date: ${date}`,
|
||||||
|
update: (_previous, date) => `Today's date is now: ${date}`,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
yield* registry.contribute({ key: SystemContext.Key.make("core/builtins"), load: Effect.succeed(context) })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const layer = Layer.mergeAll(builtIns, InstructionContext.layer).pipe(
|
||||||
|
Layer.provideMerge(SystemContextRegistry.layer),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const locationLayer = layer
|
||||||
46
packages/core/src/system-context-registry.ts
Normal file
46
packages/core/src/system-context-registry.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
export * as SystemContextRegistry from "./system-context-registry"
|
||||||
|
|
||||||
|
import { Context, Effect, Layer, Ref, Scope } from "effect"
|
||||||
|
import { SystemContext } from "./system-context"
|
||||||
|
|
||||||
|
export interface Contribution {
|
||||||
|
readonly key: SystemContext.Key
|
||||||
|
readonly load: Effect.Effect<SystemContext.SystemContext>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly contribute: (contribution: Contribution) => Effect.Effect<void, never, Scope.Scope>
|
||||||
|
readonly load: () => Effect.Effect<SystemContext.SystemContext>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SystemContextRegistry") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const contributions = yield* Ref.make<ReadonlyArray<Contribution>>([])
|
||||||
|
|
||||||
|
return Service.of({
|
||||||
|
contribute: Effect.fn("SystemContextRegistry.contribute")(function* (contribution) {
|
||||||
|
yield* Effect.acquireRelease(
|
||||||
|
Ref.modify(contributions, (current) => {
|
||||||
|
if (current.some((item) => item.key === contribution.key)) return [false, current]
|
||||||
|
return [true, [...current, contribution]]
|
||||||
|
}).pipe(
|
||||||
|
Effect.flatMap((added) =>
|
||||||
|
added ? Effect.void : Effect.die(`Duplicate system context contribution key: ${contribution.key}`),
|
||||||
|
),
|
||||||
|
Effect.as(contribution),
|
||||||
|
),
|
||||||
|
(entry) => Ref.update(contributions, (current) => current.filter((item) => item !== entry)),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
load: Effect.fn("SystemContextRegistry.load")(function* () {
|
||||||
|
const current = (yield* Ref.get(contributions)).toSorted((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
|
||||||
|
return SystemContext.combine(
|
||||||
|
yield* Effect.forEach(current, (contribution) => contribution.load, { concurrency: "unbounded" }),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
@ -1,66 +1,89 @@
|
|||||||
export * as SystemContext from "./system-context"
|
export * as SystemContext from "./system-context"
|
||||||
|
|
||||||
import { Effect, Schema } from "effect"
|
import { Effect, Option, Schema } from "effect"
|
||||||
import { Hash } from "./util/hash"
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Models privileged system context as independently refreshable typed sources.
|
||||||
|
*
|
||||||
|
* `Source<A>` describes how to observe, compare, and render one value. `make`
|
||||||
|
* closes over `A`, producing an opaque `SystemContext` that composes uniformly
|
||||||
|
* with contexts built from other value types. Interpreters observe the composed
|
||||||
|
* context once, then produce a durable structured
|
||||||
|
* `Snapshot` alongside the exact model-visible baseline or update text.
|
||||||
|
*
|
||||||
|
* Returning `unavailable` means observation failed temporarily. It differs from
|
||||||
|
* removing a source from the context: refresh preserves the admitted snapshot,
|
||||||
|
* and replacement waits rather than silently constructing an incomplete baseline.
|
||||||
|
*
|
||||||
|
* @module
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Stable namespaced identity for one independently refreshable context source. */
|
||||||
export const Key = Schema.String.check(Schema.isPattern(/^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._/-]*$/)).pipe(
|
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("SystemContext.Key"),
|
||||||
)
|
)
|
||||||
export type Key = typeof Key.Type
|
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/SystemContext.Unavailable")
|
||||||
export type Unavailable = typeof unavailable
|
export type Unavailable = typeof unavailable
|
||||||
|
|
||||||
export interface Value {
|
/** Defines one typed source before its value type is hidden by `make`. */
|
||||||
/** Full component text rendered into a new epoch baseline. */
|
export interface Source<A> {
|
||||||
|
readonly key: Key
|
||||||
|
readonly codec: Schema.Codec<A, Schema.Json, never, never>
|
||||||
|
readonly load: Effect.Effect<A | Unavailable>
|
||||||
|
readonly baseline: (current: A) => string
|
||||||
|
readonly update: (previous: A, current: A) => string
|
||||||
|
readonly removed?: (previous: A) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ContextTypeId: unique symbol = Symbol.for("@opencode/SystemContext")
|
||||||
|
|
||||||
|
/** Opaque carrier for composable system context sources. */
|
||||||
|
export interface SystemContext {
|
||||||
|
readonly [ContextTypeId]: ReadonlyArray<PackedSource>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Durable comparison state for one admitted source. */
|
||||||
|
export const SourceSnapshot = Schema.Struct({
|
||||||
|
value: Schema.Json,
|
||||||
|
removed: Schema.optional(Schema.NonEmptyString),
|
||||||
|
})
|
||||||
|
export type SourceSnapshot = typeof SourceSnapshot.Type
|
||||||
|
|
||||||
|
/** Durable structured comparison state for one active context generation. */
|
||||||
|
export const Snapshot = Schema.Record(Key, SourceSnapshot)
|
||||||
|
export type Snapshot = Readonly<Record<string, SourceSnapshot>>
|
||||||
|
|
||||||
|
export interface Generation {
|
||||||
readonly baseline: string
|
readonly baseline: string
|
||||||
/** Absolute current-state text emitted when this component changes. */
|
readonly snapshot: Snapshot
|
||||||
readonly update: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Component<out E = never, out R = never> {
|
export interface Updated {
|
||||||
readonly key: Key
|
readonly _tag: "Updated"
|
||||||
readonly load: Effect.Effect<Value | Unavailable, E, R>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SystemContext<out E = never, out R = never> {
|
|
||||||
readonly components: ReadonlyArray<Component<E, R>>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AvailableEntry extends Value {
|
|
||||||
readonly _tag: "Available"
|
|
||||||
readonly key: Key
|
|
||||||
readonly hash: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UnavailableEntry {
|
|
||||||
readonly _tag: "Unavailable"
|
|
||||||
readonly key: Key
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Entry = AvailableEntry | UnavailableEntry
|
|
||||||
|
|
||||||
export interface Snapshot {
|
|
||||||
readonly entries: ReadonlyArray<Entry>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Part {
|
|
||||||
readonly key: Key
|
|
||||||
readonly text: string
|
readonly text: string
|
||||||
|
readonly snapshot: Snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Checkpoint = Readonly<Record<string, string>>
|
export interface ReplacementReady {
|
||||||
|
readonly _tag: "ReplacementReady"
|
||||||
export interface Initialized {
|
readonly generation: Generation
|
||||||
readonly baseline: ReadonlyArray<Part>
|
|
||||||
readonly checkpoint: Checkpoint
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Refreshed {
|
export interface ReplacementBlocked {
|
||||||
readonly changes: ReadonlyArray<Part>
|
readonly _tag: "ReplacementBlocked"
|
||||||
readonly checkpoint: Checkpoint
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ReplacementResult = ReplacementReady | ReplacementBlocked
|
||||||
|
export type ReconcileResult = { readonly _tag: "Unchanged" } | Updated | ReplacementResult
|
||||||
|
|
||||||
|
export class InitializationBlocked extends Schema.TaggedErrorClass<InitializationBlocked>()(
|
||||||
|
"SystemContext.InitializationBlocked",
|
||||||
|
{ keys: Schema.Array(Key) },
|
||||||
|
) {}
|
||||||
|
|
||||||
export class DuplicateKeyError extends Schema.TaggedErrorClass<DuplicateKeyError>()("SystemContext.DuplicateKeyError", {
|
export class DuplicateKeyError extends Schema.TaggedErrorClass<DuplicateKeyError>()("SystemContext.DuplicateKeyError", {
|
||||||
key: Key,
|
key: Key,
|
||||||
}) {
|
}) {
|
||||||
@ -69,73 +92,225 @@ export class DuplicateKeyError extends Schema.TaggedErrorClass<DuplicateKeyError
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const value = <E, R>(component: Component<E, R>): Component<E, R> => component
|
interface PackedSource {
|
||||||
|
readonly key: Key
|
||||||
export function struct<E, R>(components: Readonly<Record<string, Component<E, R>>>): SystemContext<E, R> {
|
readonly load: Effect.Effect<Loaded | Unavailable>
|
||||||
const values = Object.values(components)
|
|
||||||
assertUniqueKeys(values)
|
|
||||||
return { components: values }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const load = <E, R>(context: SystemContext<E, R>) =>
|
interface Loaded {
|
||||||
Effect.sync(() => assertUniqueKeys(context.components)).pipe(
|
readonly baseline: () => Rendered
|
||||||
Effect.andThen(
|
readonly compare: (previous: Schema.Json) => Compared
|
||||||
Effect.forEach(context.components, (component) =>
|
}
|
||||||
component.load.pipe(
|
|
||||||
Effect.map(
|
interface Rendered {
|
||||||
(result): Entry =>
|
readonly text: string
|
||||||
result === unavailable
|
readonly snapshot: SourceSnapshot
|
||||||
? { _tag: "Unavailable", key: component.key }
|
}
|
||||||
: { _tag: "Available", key: component.key, ...result, hash: Hash.sha256(result.update) },
|
|
||||||
),
|
type Compared =
|
||||||
|
| { readonly _tag: "Incompatible" }
|
||||||
|
| { readonly _tag: "Unchanged" }
|
||||||
|
| { readonly _tag: "Updated"; readonly render: () => Rendered }
|
||||||
|
|
||||||
|
interface AvailableEntry extends Loaded {
|
||||||
|
readonly _tag: "Available"
|
||||||
|
readonly key: Key
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UnavailableEntry {
|
||||||
|
readonly _tag: "Unavailable"
|
||||||
|
readonly key: Key
|
||||||
|
}
|
||||||
|
|
||||||
|
type Entry = AvailableEntry | UnavailableEntry
|
||||||
|
|
||||||
|
/** The identity context. */
|
||||||
|
export const empty = context([])
|
||||||
|
|
||||||
|
/** Closes a typed source into a context that composes with differently typed sources. */
|
||||||
|
export function make<A>(source: Source<A>): SystemContext {
|
||||||
|
const decode = Schema.decodeUnknownOption(source.codec)
|
||||||
|
const encode = Schema.encodeSync(source.codec)
|
||||||
|
const equivalent = Schema.toEquivalence(source.codec)
|
||||||
|
return context([
|
||||||
|
{
|
||||||
|
key: source.key,
|
||||||
|
load: source.load.pipe(
|
||||||
|
Effect.map((value) => {
|
||||||
|
if (isUnavailable(value)) return value
|
||||||
|
const snapshot = (): SourceSnapshot => ({
|
||||||
|
value: encode(value),
|
||||||
|
...(source.removed ? { removed: requireText(source.key, "removal", source.removed(value)) } : {}),
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
baseline: (): Rendered => ({
|
||||||
|
text: requireText(source.key, "baseline", source.baseline(value)),
|
||||||
|
snapshot: snapshot(),
|
||||||
|
}),
|
||||||
|
compare: (previous): Compared =>
|
||||||
|
Option.match(decode(previous), {
|
||||||
|
onNone: (): Compared => ({ _tag: "Incompatible" }),
|
||||||
|
onSome: (decoded): Compared =>
|
||||||
|
equivalent(decoded, value)
|
||||||
|
? { _tag: "Unchanged" }
|
||||||
|
: {
|
||||||
|
_tag: "Updated",
|
||||||
|
render: () => ({
|
||||||
|
text: requireText(source.key, "update", source.update(decoded, value)),
|
||||||
|
snapshot: snapshot(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Combines contexts in order and rejects duplicate source keys immediately. */
|
||||||
|
export function combine(values: ReadonlyArray<SystemContext>): SystemContext {
|
||||||
|
const sources = values.flatMap((value) => value[ContextTypeId])
|
||||||
|
assertUniqueKeys(sources)
|
||||||
|
return context(sources)
|
||||||
|
}
|
||||||
|
|
||||||
|
const observe = (value: SystemContext) =>
|
||||||
|
Effect.forEach(
|
||||||
|
value[ContextTypeId],
|
||||||
|
(source) =>
|
||||||
|
source.load.pipe(
|
||||||
|
Effect.map(
|
||||||
|
(result): Entry =>
|
||||||
|
result === unavailable
|
||||||
|
? { _tag: "Unavailable", key: source.key }
|
||||||
|
: { _tag: "Available", key: source.key, ...result },
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
{ concurrency: "unbounded" },
|
||||||
Effect.map((entries): Snapshot => ({ entries })),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
export function initialize(snapshot: Snapshot): Initialized {
|
/** Creates the immutable baseline and durable snapshot for a new generation. */
|
||||||
return {
|
export function initialize(value: SystemContext): Effect.Effect<Generation, InitializationBlocked> {
|
||||||
baseline: snapshot.entries.flatMap((entry) =>
|
return observe(value).pipe(
|
||||||
entry._tag === "Available" ? [{ key: entry.key, text: entry.baseline }] : [],
|
Effect.flatMap((entries) => {
|
||||||
),
|
const unavailable = entries.flatMap((entry) => (entry._tag === "Unavailable" ? [entry.key] : []))
|
||||||
checkpoint: nextCheckpoint(snapshot, {}),
|
if (unavailable.length > 0) return new InitializationBlocked({ keys: unavailable })
|
||||||
}
|
return Effect.succeed(initializeObservation(entries))
|
||||||
}
|
|
||||||
|
|
||||||
export function refresh(snapshot: Snapshot, previous: Checkpoint): Refreshed {
|
|
||||||
return {
|
|
||||||
changes: snapshot.entries.flatMap((entry) =>
|
|
||||||
entry._tag === "Available" && getCheckpoint(previous, entry.key) !== entry.hash
|
|
||||||
? [{ key: entry.key, text: entry.update }]
|
|
||||||
: [],
|
|
||||||
),
|
|
||||||
checkpoint: nextCheckpoint(snapshot, previous),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function render(parts: ReadonlyArray<Part>) {
|
|
||||||
return parts.map((part) => part.text).join("\n\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextCheckpoint(snapshot: Snapshot, previous: Checkpoint) {
|
|
||||||
return Object.fromEntries(
|
|
||||||
snapshot.entries.flatMap((entry) => {
|
|
||||||
if (entry._tag === "Available") return [[entry.key, entry.hash]]
|
|
||||||
const hash = getCheckpoint(previous, entry.key)
|
|
||||||
return hash === undefined ? [] : [[entry.key, hash]]
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCheckpoint(checkpoint: Checkpoint, key: Key) {
|
function initializeObservation(entries: ReadonlyArray<Entry>): Generation {
|
||||||
return Object.hasOwn(checkpoint, key) ? checkpoint[key] : undefined
|
const available = entries.filter((entry): entry is AvailableEntry => entry._tag === "Available")
|
||||||
}
|
const rendered = available.map((entry) => [entry.key, entry.baseline()] as const)
|
||||||
|
return {
|
||||||
function assertUniqueKeys(components: ReadonlyArray<Component<unknown, unknown>>) {
|
baseline: render(rendered.map(([, result]) => result.text)),
|
||||||
const keys = new Set<Key>()
|
snapshot: Object.fromEntries(rendered.map(([key, result]) => [key, result.snapshot])),
|
||||||
for (const component of components) {
|
}
|
||||||
if (keys.has(component.key)) throw new DuplicateKeyError({ key: component.key })
|
}
|
||||||
keys.add(component.key)
|
|
||||||
|
/** Reconciles current source values with one active generation. */
|
||||||
|
export function reconcile(value: SystemContext, previous: Snapshot): Effect.Effect<ReconcileResult> {
|
||||||
|
return observe(value).pipe(
|
||||||
|
Effect.map((entries): ReconcileResult => {
|
||||||
|
const result = reconcileObservation(entries, previous)
|
||||||
|
if (result._tag === "Unchanged" || result._tag === "Updated") return result
|
||||||
|
return replaceObservation(entries, previous)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function reconcileObservation(
|
||||||
|
entries: ReadonlyArray<Entry>,
|
||||||
|
previous: Snapshot,
|
||||||
|
): { readonly _tag: "Unchanged" } | Updated | { readonly _tag: "Replace" } {
|
||||||
|
const keys = new Set(entries.map((entry) => entry.key))
|
||||||
|
const comparisons = new Map<Key, Compared>()
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry._tag === "Unavailable") continue
|
||||||
|
const stored = getSnapshot(previous, entry.key)
|
||||||
|
if (!stored) continue
|
||||||
|
const compared = entry.compare(stored.value)
|
||||||
|
if (compared._tag === "Incompatible") return { _tag: "Replace" }
|
||||||
|
comparisons.set(entry.key, compared)
|
||||||
|
}
|
||||||
|
for (const key of Object.keys(previous).sort()) {
|
||||||
|
if (keys.has(Key.make(key))) continue
|
||||||
|
if (previous[key].removed === undefined) return { _tag: "Replace" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot: Record<string, SourceSnapshot> = {}
|
||||||
|
const updates: string[] = []
|
||||||
|
for (const entry of entries) {
|
||||||
|
const stored = getSnapshot(previous, entry.key)
|
||||||
|
if (entry._tag === "Unavailable") {
|
||||||
|
if (stored) snapshot[entry.key] = stored
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!stored) {
|
||||||
|
const rendered = entry.baseline()
|
||||||
|
updates.push(rendered.text)
|
||||||
|
snapshot[entry.key] = rendered.snapshot
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const compared = comparisons.get(entry.key)
|
||||||
|
if (!compared || compared._tag === "Incompatible")
|
||||||
|
throw new Error(`Missing comparison for system context source ${entry.key}`)
|
||||||
|
if (compared._tag === "Unchanged") {
|
||||||
|
snapshot[entry.key] = stored
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const rendered = compared.render()
|
||||||
|
updates.push(rendered.text)
|
||||||
|
snapshot[entry.key] = rendered.snapshot
|
||||||
|
}
|
||||||
|
for (const key of Object.keys(previous).sort()) {
|
||||||
|
if (keys.has(Key.make(key))) continue
|
||||||
|
const removed = previous[key].removed
|
||||||
|
if (removed === undefined) throw new Error(`Missing removal rendering for system context source ${key}`)
|
||||||
|
updates.push(removed)
|
||||||
|
}
|
||||||
|
if (updates.length === 0) return { _tag: "Unchanged" }
|
||||||
|
return { _tag: "Updated", text: render(updates), snapshot }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates a complete replacement generation or blocks while admitted context is unavailable. */
|
||||||
|
export function replace(value: SystemContext, previous: Snapshot): Effect.Effect<ReplacementResult> {
|
||||||
|
return observe(value).pipe(Effect.map((entries) => replaceObservation(entries, previous)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceObservation(entries: ReadonlyArray<Entry>, previous: Snapshot): ReplacementResult {
|
||||||
|
if (entries.some((entry) => entry._tag === "Unavailable" && getSnapshot(previous, entry.key) !== undefined))
|
||||||
|
return { _tag: "ReplacementBlocked" }
|
||||||
|
return { _tag: "ReplacementReady", generation: initializeObservation(entries) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function context(sources: ReadonlyArray<PackedSource>): SystemContext {
|
||||||
|
return { [ContextTypeId]: sources }
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(parts: ReadonlyArray<string>) {
|
||||||
|
return parts.join("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSnapshot(snapshot: Snapshot, key: Key) {
|
||||||
|
return Object.hasOwn(snapshot, key) ? snapshot[key] : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUnavailable(value: unknown): value is Unavailable {
|
||||||
|
return value === unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireText(key: Key, kind: string, text: string) {
|
||||||
|
if (text.length === 0) throw new Error(`System context source ${key} rendered an empty ${kind}`)
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertUniqueKeys(sources: ReadonlyArray<PackedSource>) {
|
||||||
|
const keys = new Set<Key>()
|
||||||
|
for (const source of sources) {
|
||||||
|
if (keys.has(source.key)) throw new DuplicateKeyError({ key: source.key })
|
||||||
|
keys.add(source.key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
|
|||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { eq, inArray, sql } from "drizzle-orm"
|
import { eq, inArray, sql } from "drizzle-orm"
|
||||||
import { DatabaseMigration } from "@opencode-ai/core/database/migration"
|
import { DatabaseMigration } from "@opencode-ai/core/database/migration"
|
||||||
|
import { migrations } from "@opencode-ai/core/database/migration.gen"
|
||||||
import sessionUsageMigration from "@opencode-ai/core/database/migration/20260510033149_session_usage"
|
import sessionUsageMigration from "@opencode-ai/core/database/migration/20260510033149_session_usage"
|
||||||
import normalizeStoragePathsMigration from "@opencode-ai/core/database/migration/20260601010001_normalize_storage_paths"
|
import normalizeStoragePathsMigration from "@opencode-ai/core/database/migration/20260601010001_normalize_storage_paths"
|
||||||
import sessionMessageProjectionOrderMigration from "@opencode-ai/core/database/migration/20260603040000_session_message_projection_order"
|
import sessionMessageProjectionOrderMigration from "@opencode-ai/core/database/migration/20260603040000_session_message_projection_order"
|
||||||
@ -63,7 +64,10 @@ describe("DatabaseMigration", () => {
|
|||||||
expect(
|
expect(
|
||||||
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_input'`),
|
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_input'`),
|
||||||
).toEqual({ name: "session_input" })
|
).toEqual({ name: "session_input" })
|
||||||
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 30 })
|
expect(
|
||||||
|
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_context_epoch'`),
|
||||||
|
).toEqual({ name: "session_context_epoch" })
|
||||||
|
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: migrations.length })
|
||||||
expect(
|
expect(
|
||||||
yield* db.all(
|
yield* db.all(
|
||||||
sql`SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('event_aggregate_seq_idx', 'event_aggregate_type_seq_idx', 'session_input_session_pending_seq_idx', 'session_input_session_pending_delivery_seq_idx', 'session_input_session_admitted_seq_idx', 'session_input_session_promoted_seq_idx', 'session_message_session_idx', 'session_message_session_type_idx', 'session_message_session_seq_idx', 'session_message_session_type_seq_idx', 'session_message_session_time_created_id_idx') ORDER BY name`,
|
sql`SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('event_aggregate_seq_idx', 'event_aggregate_type_seq_idx', 'session_input_session_pending_seq_idx', 'session_input_session_pending_delivery_seq_idx', 'session_input_session_admitted_seq_idx', 'session_input_session_promoted_seq_idx', 'session_message_session_idx', 'session_message_session_type_idx', 'session_message_session_seq_idx', 'session_message_session_type_seq_idx', 'session_message_session_time_created_id_idx') ORDER BY name`,
|
||||||
|
|||||||
@ -190,6 +190,56 @@ describe("EventV2", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("commits local operational state inside a new synchronized event transaction", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
const received = new Array<string>()
|
||||||
|
const aggregateID = EventV2.ID.create()
|
||||||
|
yield* events.project(SyncMessage, () => Effect.sync(() => received.push("projector")))
|
||||||
|
|
||||||
|
yield* events.publish(
|
||||||
|
SyncMessage,
|
||||||
|
{ id: aggregateID, text: "hello" },
|
||||||
|
{ commit: (seq) => Effect.sync(() => received.push(`commit:${seq}`)) },
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(received).toEqual(["projector", "commit:0"])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("rolls back the synchronized event and projector when the local commit fails", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
const { db } = yield* Database.Service
|
||||||
|
const aggregateID = EventV2.ID.create()
|
||||||
|
yield* db.run("CREATE TABLE IF NOT EXISTS event_commit_probe (value text NOT NULL)")
|
||||||
|
yield* db.run("DELETE FROM event_commit_probe")
|
||||||
|
yield* events.project(SyncMessage, () =>
|
||||||
|
db.run("INSERT INTO event_commit_probe (value) VALUES ('projected')").pipe(Effect.orDie, Effect.asVoid),
|
||||||
|
)
|
||||||
|
|
||||||
|
const exit = yield* events
|
||||||
|
.publish(SyncMessage, { id: aggregateID, text: "hello" }, { commit: () => Effect.die("commit failed") })
|
||||||
|
.pipe(Effect.exit)
|
||||||
|
|
||||||
|
expect(String(exit)).toContain("commit failed")
|
||||||
|
expect(yield* db.all("SELECT value FROM event_commit_probe")).toEqual([])
|
||||||
|
expect(yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).all()).toEqual([])
|
||||||
|
expect(
|
||||||
|
yield* db.select().from(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).all(),
|
||||||
|
).toEqual([])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("rejects local commit hooks on live-only events", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
const exit = yield* events.publish(Message, { text: "hello" }, { commit: () => Effect.void }).pipe(Effect.exit)
|
||||||
|
|
||||||
|
expect(String(exit)).toContain("Local commit hooks require a synchronized event")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("runs projectors before publishing to streams", () =>
|
it.effect("runs projectors before publishing to streams", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
|
|||||||
299
packages/core/test/instruction-context.test.ts
Normal file
299
packages/core/test/instruction-context.test.ts
Normal file
@ -0,0 +1,299 @@
|
|||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Effect, Layer } from "effect"
|
||||||
|
import fs from "fs/promises"
|
||||||
|
import path from "path"
|
||||||
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
|
import { Global } from "@opencode-ai/core/global"
|
||||||
|
import { InstructionContext } from "@opencode-ai/core/instruction-context"
|
||||||
|
import { Location } from "@opencode-ai/core/location"
|
||||||
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
|
import { SystemContext } from "@opencode-ai/core/system-context"
|
||||||
|
import { SystemContextRegistry } from "@opencode-ai/core/system-context-registry"
|
||||||
|
import { location } from "./fixture/location"
|
||||||
|
import { tmpdir } from "./fixture/tmpdir"
|
||||||
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
|
const it = testEffect(Layer.empty)
|
||||||
|
|
||||||
|
describe("InstructionContext", () => {
|
||||||
|
it.live("loads global and upward project AGENTS.md files as one aggregate context", () =>
|
||||||
|
Effect.acquireRelease(
|
||||||
|
Effect.promise(() => tmpdir()),
|
||||||
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||||
|
).pipe(
|
||||||
|
Effect.flatMap((tmp) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const global = path.join(tmp.path, "global")
|
||||||
|
const project = path.join(tmp.path, "project")
|
||||||
|
const directory = path.join(project, "packages", "core")
|
||||||
|
const outside = path.join(tmp.path, "AGENTS.md")
|
||||||
|
const globalFile = path.join(global, "AGENTS.md")
|
||||||
|
const projectFile = path.join(project, "AGENTS.md")
|
||||||
|
const packageFile = path.join(directory, "AGENTS.md")
|
||||||
|
yield* Effect.promise(async () => {
|
||||||
|
await fs.mkdir(global, { recursive: true })
|
||||||
|
await fs.mkdir(directory, { recursive: true })
|
||||||
|
await fs.writeFile(outside, "outside")
|
||||||
|
await fs.writeFile(globalFile, "global")
|
||||||
|
await fs.writeFile(projectFile, "project")
|
||||||
|
await fs.writeFile(packageFile, "package")
|
||||||
|
})
|
||||||
|
|
||||||
|
const load = SystemContextRegistry.Service.pipe(
|
||||||
|
Effect.flatMap((service) => service.load()),
|
||||||
|
Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
|
||||||
|
Effect.provide(FSUtil.defaultLayer),
|
||||||
|
Effect.provide(Global.layerWith({ config: global })),
|
||||||
|
Effect.provide(
|
||||||
|
Layer.succeed(
|
||||||
|
Location.Service,
|
||||||
|
Location.Service.of(
|
||||||
|
location(
|
||||||
|
{ directory: AbsolutePath.make(directory) },
|
||||||
|
{ projectDirectory: AbsolutePath.make(project) },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const initialized = yield* SystemContext.initialize(yield* load)
|
||||||
|
expect(initialized.baseline).toBe(
|
||||||
|
[
|
||||||
|
`Instructions from: ${globalFile}\nglobal`,
|
||||||
|
`Instructions from: ${packageFile}\npackage`,
|
||||||
|
`Instructions from: ${projectFile}\nproject`,
|
||||||
|
].join("\n\n"),
|
||||||
|
)
|
||||||
|
expect(initialized.baseline).not.toContain("outside")
|
||||||
|
|
||||||
|
yield* Effect.promise(() => fs.writeFile(packageFile, "changed"))
|
||||||
|
expect(yield* SystemContext.reconcile(yield* load, initialized.snapshot)).toMatchObject({
|
||||||
|
_tag: "Updated",
|
||||||
|
text: expect.stringContaining(`Instructions from: ${packageFile}\nchanged`),
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* Effect.promise(() => fs.rm(packageFile))
|
||||||
|
const partial = yield* SystemContext.reconcile(yield* load, initialized.snapshot)
|
||||||
|
expect(partial).toEqual({
|
||||||
|
_tag: "Updated",
|
||||||
|
text: [
|
||||||
|
"These instructions replace all previously loaded ambient instructions.",
|
||||||
|
`Instructions from: ${globalFile}\nglobal`,
|
||||||
|
`Instructions from: ${projectFile}\nproject`,
|
||||||
|
].join("\n\n"),
|
||||||
|
snapshot: expect.any(Object),
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* Effect.promise(() => Promise.all([fs.rm(globalFile), fs.rm(projectFile)]))
|
||||||
|
expect(yield* SystemContext.reconcile(yield* load, initialized.snapshot)).toEqual({
|
||||||
|
_tag: "Updated",
|
||||||
|
text: "Previously loaded instructions no longer apply.",
|
||||||
|
snapshot: {},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("keeps an empty AGENTS.md as available context", () =>
|
||||||
|
Effect.acquireRelease(
|
||||||
|
Effect.promise(() => tmpdir()),
|
||||||
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||||
|
).pipe(
|
||||||
|
Effect.flatMap((tmp) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const file = path.join(tmp.path, "AGENTS.md")
|
||||||
|
yield* Effect.promise(() => fs.writeFile(file, ""))
|
||||||
|
const context = yield* SystemContextRegistry.Service.pipe(
|
||||||
|
Effect.flatMap((service) => service.load()),
|
||||||
|
Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
|
||||||
|
Effect.provide(FSUtil.defaultLayer),
|
||||||
|
Effect.provide(Global.layerWith({ config: path.join(tmp.path, "global") })),
|
||||||
|
Effect.provide(
|
||||||
|
Layer.succeed(
|
||||||
|
Location.Service,
|
||||||
|
Location.Service.of(location({ directory: AbsolutePath.make(tmp.path) })),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect((yield* SystemContext.initialize(context)).baseline).toBe(`Instructions from: ${file}\n`)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("preserves admitted instructions while observation is unavailable", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const failingFS = Layer.effect(
|
||||||
|
FSUtil.Service,
|
||||||
|
FSUtil.Service.pipe(
|
||||||
|
Effect.map((fs) =>
|
||||||
|
FSUtil.Service.of({ ...fs, up: () => Effect.fail(new FSUtil.FileSystemError({ method: "up" })) }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).pipe(Layer.provide(FSUtil.defaultLayer))
|
||||||
|
const context = yield* SystemContextRegistry.Service.pipe(
|
||||||
|
Effect.flatMap((service) => service.load()),
|
||||||
|
Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
|
||||||
|
Effect.provide(failingFS),
|
||||||
|
Effect.provide(Global.layerWith({ config: "/global" })),
|
||||||
|
Effect.provide(
|
||||||
|
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/repo") }))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
yield* SystemContext.reconcile(context, {
|
||||||
|
"core/instructions": {
|
||||||
|
value: [{ path: "/repo/AGENTS.md", content: "old" }],
|
||||||
|
removed: "Previously loaded instructions no longer apply.",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({ _tag: "Unchanged" })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("preserves admitted instructions when a discovered file disappears before read", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const file = AbsolutePath.make("/repo/AGENTS.md")
|
||||||
|
const racingFS = Layer.effect(
|
||||||
|
FSUtil.Service,
|
||||||
|
FSUtil.Service.pipe(
|
||||||
|
Effect.map((fs) =>
|
||||||
|
FSUtil.Service.of({
|
||||||
|
...fs,
|
||||||
|
up: () => Effect.succeed([file]),
|
||||||
|
readFileStringSafe: () => Effect.succeed(undefined),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).pipe(Layer.provide(FSUtil.defaultLayer))
|
||||||
|
const context = yield* SystemContextRegistry.Service.pipe(
|
||||||
|
Effect.flatMap((service) => service.load()),
|
||||||
|
Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
|
||||||
|
Effect.provide(racingFS),
|
||||||
|
Effect.provide(Global.layerWith({ config: "/global" })),
|
||||||
|
Effect.provide(
|
||||||
|
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/repo") }))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
yield* SystemContext.reconcile(context, {
|
||||||
|
"core/instructions": {
|
||||||
|
value: [{ path: file, content: "old" }],
|
||||||
|
removed: "Previously loaded instructions no longer apply.",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({ _tag: "Unchanged" })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("canonicalizes upward discovery boundaries", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
let observed: { targets: string[]; start: string; stop?: string } | undefined
|
||||||
|
const observingFS = Layer.effect(
|
||||||
|
FSUtil.Service,
|
||||||
|
FSUtil.Service.pipe(
|
||||||
|
Effect.map((fs) =>
|
||||||
|
FSUtil.Service.of({
|
||||||
|
...fs,
|
||||||
|
up: (options) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
observed = options
|
||||||
|
return []
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).pipe(Layer.provide(FSUtil.defaultLayer))
|
||||||
|
|
||||||
|
yield* SystemContextRegistry.Service.pipe(
|
||||||
|
Effect.flatMap((service) => service.load()),
|
||||||
|
Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
|
||||||
|
Effect.provide(observingFS),
|
||||||
|
Effect.provide(Global.layerWith({ config: "/global" })),
|
||||||
|
Effect.provide(
|
||||||
|
Layer.succeed(
|
||||||
|
Location.Service,
|
||||||
|
Location.Service.of(
|
||||||
|
location(
|
||||||
|
{ directory: AbsolutePath.make("/repo/") },
|
||||||
|
{ projectDirectory: AbsolutePath.make("/repo") },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(observed).toEqual({ targets: ["AGENTS.md"], start: FSUtil.resolve("/repo"), stop: FSUtil.resolve("/repo") })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("honors the project instruction opt-out", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const previous = process.env.OPENCODE_DISABLE_PROJECT_CONFIG
|
||||||
|
let scanned = false
|
||||||
|
process.env.OPENCODE_DISABLE_PROJECT_CONFIG = "1"
|
||||||
|
|
||||||
|
yield* SystemContextRegistry.Service.pipe(
|
||||||
|
Effect.flatMap((service) => service.load()),
|
||||||
|
Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
|
||||||
|
Effect.provide(
|
||||||
|
Layer.effect(
|
||||||
|
FSUtil.Service,
|
||||||
|
FSUtil.Service.pipe(
|
||||||
|
Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })),
|
||||||
|
),
|
||||||
|
).pipe(Layer.provide(FSUtil.defaultLayer)),
|
||||||
|
),
|
||||||
|
Effect.provide(Global.layerWith({ config: "/global" })),
|
||||||
|
Effect.provide(
|
||||||
|
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/repo") }))),
|
||||||
|
),
|
||||||
|
Effect.ensuring(
|
||||||
|
Effect.sync(() => {
|
||||||
|
if (previous === undefined) delete process.env.OPENCODE_DISABLE_PROJECT_CONFIG
|
||||||
|
else process.env.OPENCODE_DISABLE_PROJECT_CONFIG = previous
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(scanned).toBe(false)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("does not discover project instructions outside the canonical project root", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
let scanned = false
|
||||||
|
yield* SystemContextRegistry.Service.pipe(
|
||||||
|
Effect.flatMap((service) => service.load()),
|
||||||
|
Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
|
||||||
|
Effect.provide(
|
||||||
|
Layer.effect(
|
||||||
|
FSUtil.Service,
|
||||||
|
FSUtil.Service.pipe(
|
||||||
|
Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })),
|
||||||
|
),
|
||||||
|
).pipe(Layer.provide(FSUtil.defaultLayer)),
|
||||||
|
),
|
||||||
|
Effect.provide(Global.layerWith({ config: "/global" })),
|
||||||
|
Effect.provide(
|
||||||
|
Layer.succeed(
|
||||||
|
Location.Service,
|
||||||
|
Location.Service.of(
|
||||||
|
location(
|
||||||
|
{ directory: AbsolutePath.make("/outside") },
|
||||||
|
{ projectDirectory: AbsolutePath.make("/repo") },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(scanned).toBe(false)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
@ -32,6 +32,12 @@ describe("toLLMMessages", () => {
|
|||||||
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
|
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
|
||||||
time: { created },
|
time: { created },
|
||||||
}),
|
}),
|
||||||
|
new SessionMessage.System({
|
||||||
|
id: id("system"),
|
||||||
|
type: "system",
|
||||||
|
text: "Updated context\n\nOther context",
|
||||||
|
time: { created },
|
||||||
|
}),
|
||||||
new SessionMessage.User({
|
new SessionMessage.User({
|
||||||
id: id("user"),
|
id: id("user"),
|
||||||
type: "user",
|
type: "user",
|
||||||
@ -67,8 +73,9 @@ describe("toLLMMessages", () => {
|
|||||||
model,
|
model,
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(messages.map((message) => message.role)).toEqual(["user", "user", "user", "user"])
|
expect(messages.map((message) => message.role)).toEqual(["system", "user", "user", "user", "user"])
|
||||||
expect(messages[0]).toEqual(
|
expect(messages[0]).toEqual(Message.system("Updated context\n\nOther context"))
|
||||||
|
expect(messages[1]).toEqual(
|
||||||
Message.make({
|
Message.make({
|
||||||
id: id("user"),
|
id: id("user"),
|
||||||
role: "user",
|
role: "user",
|
||||||
@ -79,7 +86,7 @@ describe("toLLMMessages", () => {
|
|||||||
metadata: { agents: [{ name: "build" }], references: [reference] },
|
metadata: { agents: [{ name: "build" }], references: [reference] },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
expect(messages.slice(1).map((message) => message.content)).toEqual([
|
expect(messages.slice(2).map((message) => message.content)).toEqual([
|
||||||
[{ type: "text", text: "Synthetic context" }],
|
[{ type: "text", text: "Synthetic context" }],
|
||||||
[{ type: "text", text: "Shell command: pwd\n\n/project" }],
|
[{ type: "text", text: "Shell command: pwd\n\n/project" }],
|
||||||
[{ type: "text", text: "Summary of earlier conversation:\nEarlier work" }],
|
[{ type: "text", text: "Summary of earlier conversation:\nEarlier work" }],
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
|||||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||||
|
import { SystemContextRegistry } from "@opencode-ai/core/system-context-registry"
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { eq } from "drizzle-orm"
|
import { eq } from "drizzle-orm"
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
@ -55,6 +56,7 @@ const model = OpenAIChat.route
|
|||||||
})
|
})
|
||||||
.model({ id: "gpt-4o-mini" })
|
.model({ id: "gpt-4o-mini" })
|
||||||
const models = SessionRunnerModel.layerWith(() => Effect.succeed(model))
|
const models = SessionRunnerModel.layerWith(() => Effect.succeed(model))
|
||||||
|
const systemContext = SystemContextRegistry.layer
|
||||||
const runner = SessionRunnerLLM.defaultLayer.pipe(
|
const runner = SessionRunnerLLM.defaultLayer.pipe(
|
||||||
Layer.provide(database),
|
Layer.provide(database),
|
||||||
Layer.provide(store),
|
Layer.provide(store),
|
||||||
@ -62,6 +64,7 @@ const runner = SessionRunnerLLM.defaultLayer.pipe(
|
|||||||
Layer.provide(client),
|
Layer.provide(client),
|
||||||
Layer.provide(registry),
|
Layer.provide(registry),
|
||||||
Layer.provide(models),
|
Layer.provide(models),
|
||||||
|
Layer.provide(systemContext),
|
||||||
)
|
)
|
||||||
const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner))
|
const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner))
|
||||||
const execution = Layer.effect(
|
const execution = Layer.effect(
|
||||||
@ -88,6 +91,7 @@ const it = testEffect(
|
|||||||
permission,
|
permission,
|
||||||
registry,
|
registry,
|
||||||
models,
|
models,
|
||||||
|
systemContext,
|
||||||
runner,
|
runner,
|
||||||
coordinator,
|
coordinator,
|
||||||
execution,
|
execution,
|
||||||
|
|||||||
@ -32,11 +32,18 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
|||||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||||
import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
|
import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
|
||||||
import { NativeTool } from "@opencode-ai/core/tool/native"
|
import { NativeTool } from "@opencode-ai/core/tool/native"
|
||||||
import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
|
import {
|
||||||
|
SessionContextEpochTable,
|
||||||
|
SessionInputTable,
|
||||||
|
SessionMessageTable,
|
||||||
|
SessionTable,
|
||||||
|
} from "@opencode-ai/core/session/sql"
|
||||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||||
|
import { SystemContext } from "@opencode-ai/core/system-context"
|
||||||
|
import { SystemContextRegistry } from "@opencode-ai/core/system-context-registry"
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import { Cause, DateTime, Deferred, Effect, Fiber, Layer, Schema, Stream } from "effect"
|
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect"
|
||||||
import { asc, eq } from "drizzle-orm"
|
import { asc, eq } from "drizzle-orm"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
@ -54,6 +61,7 @@ let streamStarted: Deferred.Deferred<void> | undefined
|
|||||||
let streamFailure: LLMError | undefined
|
let streamFailure: LLMError | undefined
|
||||||
let toolExecutionGate: Deferred.Deferred<void> | undefined
|
let toolExecutionGate: Deferred.Deferred<void> | undefined
|
||||||
let toolExecutionsStarted: Deferred.Deferred<void> | undefined
|
let toolExecutionsStarted: Deferred.Deferred<void> | undefined
|
||||||
|
let toolExecutionsReady = 5
|
||||||
let activeToolExecutions = 0
|
let activeToolExecutions = 0
|
||||||
let maxActiveToolExecutions = 0
|
let maxActiveToolExecutions = 0
|
||||||
const client = Layer.succeed(
|
const client = Layer.succeed(
|
||||||
@ -82,6 +90,7 @@ const client = Layer.succeed(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const model = Model.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route })
|
const model = Model.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route })
|
||||||
|
const replacementModel = Model.make({ id: "replacement", provider: "fake", route: OpenAIChat.route })
|
||||||
const authorizations: ToolRegistry.AuthorizeInput[] = []
|
const authorizations: ToolRegistry.AuthorizeInput[] = []
|
||||||
const executions: string[] = []
|
const executions: string[] = []
|
||||||
const permission = Layer.succeed(
|
const permission = Layer.succeed(
|
||||||
@ -115,7 +124,7 @@ const echo = Layer.effectDiscard(
|
|||||||
executions.push(text)
|
executions.push(text)
|
||||||
activeToolExecutions++
|
activeToolExecutions++
|
||||||
maxActiveToolExecutions = Math.max(maxActiveToolExecutions, activeToolExecutions)
|
maxActiveToolExecutions = Math.max(maxActiveToolExecutions, activeToolExecutions)
|
||||||
if (activeToolExecutions === 5 && toolExecutionsStarted) {
|
if (activeToolExecutions === toolExecutionsReady && toolExecutionsStarted) {
|
||||||
yield* Deferred.succeed(toolExecutionsStarted, undefined)
|
yield* Deferred.succeed(toolExecutionsStarted, undefined)
|
||||||
}
|
}
|
||||||
if (toolExecutionGate) yield* Deferred.await(toolExecutionGate)
|
if (toolExecutionGate) yield* Deferred.await(toolExecutionGate)
|
||||||
@ -134,7 +143,43 @@ const echo = Layer.effectDiscard(
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
).pipe(Layer.provide(registry))
|
).pipe(Layer.provide(registry))
|
||||||
const models = SessionRunnerModel.layerWith(() => Effect.succeed(model))
|
const models = SessionRunnerModel.layerWith((session) =>
|
||||||
|
Effect.succeed(session.model?.id === "replacement" ? replacementModel : model),
|
||||||
|
)
|
||||||
|
const systemContextKey = SystemContext.Key.make("test/context")
|
||||||
|
let systemBaseline = "Initial context"
|
||||||
|
let systemRemoved = false
|
||||||
|
let systemUnavailable = false
|
||||||
|
let systemLoadHook = Effect.void
|
||||||
|
const systemContext = Layer.effectDiscard(
|
||||||
|
SystemContextRegistry.Service.pipe(
|
||||||
|
Effect.flatMap((registry) =>
|
||||||
|
registry.contribute({
|
||||||
|
key: systemContextKey,
|
||||||
|
load: Effect.sync(() =>
|
||||||
|
SystemContext.combine(
|
||||||
|
systemRemoved
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
SystemContext.make({
|
||||||
|
key: systemContextKey,
|
||||||
|
codec: Schema.toCodecJson(Schema.String),
|
||||||
|
load: systemLoadHook.pipe(
|
||||||
|
Effect.andThen(
|
||||||
|
Effect.sync(() => (systemUnavailable ? SystemContext.unavailable : systemBaseline)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
baseline: String,
|
||||||
|
update: (_previous, current) => current,
|
||||||
|
removed: () => "System context source removed: test/context",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).pipe(Layer.provideMerge(SystemContextRegistry.layer))
|
||||||
const runner = SessionRunnerLLM.layer.pipe(
|
const runner = SessionRunnerLLM.layer.pipe(
|
||||||
Layer.provide(database),
|
Layer.provide(database),
|
||||||
Layer.provide(store),
|
Layer.provide(store),
|
||||||
@ -142,6 +187,7 @@ const runner = SessionRunnerLLM.layer.pipe(
|
|||||||
Layer.provide(client),
|
Layer.provide(client),
|
||||||
Layer.provide(registry),
|
Layer.provide(registry),
|
||||||
Layer.provide(models),
|
Layer.provide(models),
|
||||||
|
Layer.provide(systemContext),
|
||||||
)
|
)
|
||||||
const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner))
|
const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner))
|
||||||
const execution = Layer.effect(
|
const execution = Layer.effect(
|
||||||
@ -170,6 +216,7 @@ const it = testEffect(
|
|||||||
registry,
|
registry,
|
||||||
echo,
|
echo,
|
||||||
models,
|
models,
|
||||||
|
systemContext,
|
||||||
runner,
|
runner,
|
||||||
coordinator,
|
coordinator,
|
||||||
execution,
|
execution,
|
||||||
@ -200,6 +247,10 @@ const insertSession = (id: SessionV2.ID) =>
|
|||||||
const setup = Effect.gen(function* () {
|
const setup = Effect.gen(function* () {
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
response = []
|
response = []
|
||||||
|
systemBaseline = "Initial context"
|
||||||
|
systemRemoved = false
|
||||||
|
systemUnavailable = false
|
||||||
|
systemLoadHook = Effect.void
|
||||||
responses = undefined
|
responses = undefined
|
||||||
streamFailure = undefined
|
streamFailure = undefined
|
||||||
responseStream = undefined
|
responseStream = undefined
|
||||||
@ -207,6 +258,7 @@ const setup = Effect.gen(function* () {
|
|||||||
streamStarted = undefined
|
streamStarted = undefined
|
||||||
toolExecutionGate = undefined
|
toolExecutionGate = undefined
|
||||||
toolExecutionsStarted = undefined
|
toolExecutionsStarted = undefined
|
||||||
|
toolExecutionsReady = 5
|
||||||
activeToolExecutions = 0
|
activeToolExecutions = 0
|
||||||
maxActiveToolExecutions = 0
|
maxActiveToolExecutions = 0
|
||||||
yield* db
|
yield* db
|
||||||
@ -511,6 +563,411 @@ describe("SessionRunnerLLM", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("retries the first provider turn after system context becomes available", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* setup
|
||||||
|
const session = yield* SessionV2.Service
|
||||||
|
const { db } = yield* Database.Service
|
||||||
|
const messageID = SessionMessage.ID.create()
|
||||||
|
systemUnavailable = true
|
||||||
|
yield* session.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "First" }), resume: false })
|
||||||
|
requests.length = 0
|
||||||
|
|
||||||
|
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)
|
||||||
|
expect(requests).toHaveLength(0)
|
||||||
|
expect(yield* SessionInput.hasPending(db, sessionID, "steer")).toBe(true)
|
||||||
|
expect(
|
||||||
|
yield* db
|
||||||
|
.select()
|
||||||
|
.from(SessionContextEpochTable)
|
||||||
|
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||||
|
.get(),
|
||||||
|
).toBeUndefined()
|
||||||
|
|
||||||
|
systemUnavailable = false
|
||||||
|
yield* session.prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "First" }) })
|
||||||
|
yield* (yield* SessionRunCoordinator.Service).awaitIdle(sessionID)
|
||||||
|
|
||||||
|
expect(requests).toHaveLength(1)
|
||||||
|
expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user"])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("requires a complete new baseline after a Session moves", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* setup
|
||||||
|
const session = yield* SessionV2.Service
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
const { db } = yield* Database.Service
|
||||||
|
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
|
||||||
|
requests.length = 0
|
||||||
|
response = []
|
||||||
|
yield* session.resume(sessionID)
|
||||||
|
|
||||||
|
yield* events.publish(SessionEvent.Moved, {
|
||||||
|
sessionID,
|
||||||
|
timestamp: DateTime.makeUnsafe(1),
|
||||||
|
location: { directory: AbsolutePath.make("/moved") },
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
yield* db
|
||||||
|
.select()
|
||||||
|
.from(SessionContextEpochTable)
|
||||||
|
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||||
|
.get(),
|
||||||
|
).toBeUndefined()
|
||||||
|
|
||||||
|
systemUnavailable = true
|
||||||
|
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
|
||||||
|
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)
|
||||||
|
expect(requests).toHaveLength(1)
|
||||||
|
expect(yield* SessionInput.hasPending(db, sessionID, "steer")).toBe(true)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("does not create a source Location epoch after a concurrent Session move", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* setup
|
||||||
|
const session = yield* SessionV2.Service
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
const { db } = yield* Database.Service
|
||||||
|
let moved = false
|
||||||
|
systemLoadHook = Effect.suspend(() => {
|
||||||
|
if (moved) return Effect.void
|
||||||
|
moved = true
|
||||||
|
return events
|
||||||
|
.publish(SessionEvent.Moved, {
|
||||||
|
sessionID,
|
||||||
|
timestamp: DateTime.makeUnsafe(1),
|
||||||
|
location: { directory: AbsolutePath.make("/moved") },
|
||||||
|
})
|
||||||
|
.pipe(Effect.asVoid)
|
||||||
|
})
|
||||||
|
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
|
||||||
|
|
||||||
|
expect(Exit.isFailure(yield* session.resume(sessionID).pipe(Effect.exit))).toBe(true)
|
||||||
|
expect(yield* SessionInput.hasPending(db, sessionID, "steer")).toBe(true)
|
||||||
|
expect(
|
||||||
|
yield* db
|
||||||
|
.select()
|
||||||
|
.from(SessionContextEpochTable)
|
||||||
|
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||||
|
.get(),
|
||||||
|
).toBeUndefined()
|
||||||
|
expect((yield* session.get(sessionID)).location.directory).toBe(AbsolutePath.make("/moved"))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("reuses one durable baseline after the context producer changes", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* setup
|
||||||
|
const session = yield* SessionV2.Service
|
||||||
|
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
|
||||||
|
|
||||||
|
requests.length = 0
|
||||||
|
response = []
|
||||||
|
yield* session.resume(sessionID)
|
||||||
|
systemBaseline = "Changed context"
|
||||||
|
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
|
||||||
|
yield* session.resume(sessionID)
|
||||||
|
|
||||||
|
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
|
||||||
|
["Initial context"],
|
||||||
|
["Initial context"],
|
||||||
|
])
|
||||||
|
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"])
|
||||||
|
expect(requests[1]?.messages.at(-1)?.content).toEqual([{ type: "text", text: "Changed context" }])
|
||||||
|
expect(yield* session.messages({ sessionID })).toHaveLength(3)
|
||||||
|
const { db } = yield* Database.Service
|
||||||
|
expect(
|
||||||
|
yield* db
|
||||||
|
.select({ id: EventTable.id })
|
||||||
|
.from(EventTable)
|
||||||
|
.where(eq(EventTable.type, "session.next.context.updated.1"))
|
||||||
|
.all()
|
||||||
|
.pipe(Effect.orDie),
|
||||||
|
).toHaveLength(1)
|
||||||
|
yield* replaySessionProjection(sessionID)
|
||||||
|
expect(yield* session.messages({ sessionID })).toHaveLength(3)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("admits removed context as a chronological System message", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* setup
|
||||||
|
const session = yield* SessionV2.Service
|
||||||
|
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
|
||||||
|
|
||||||
|
requests.length = 0
|
||||||
|
response = []
|
||||||
|
yield* session.resume(sessionID)
|
||||||
|
systemRemoved = true
|
||||||
|
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
|
||||||
|
yield* session.resume(sessionID)
|
||||||
|
|
||||||
|
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"])
|
||||||
|
expect(requests[1]?.messages.at(-1)?.content).toEqual([
|
||||||
|
{ type: "text", text: "System context source removed: test/context" },
|
||||||
|
])
|
||||||
|
expect(yield* session.messages({ sessionID })).toHaveLength(3)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("replaces the baseline lazily after a model switch and drops prior System updates", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* setup
|
||||||
|
const session = yield* SessionV2.Service
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
|
||||||
|
|
||||||
|
requests.length = 0
|
||||||
|
response = []
|
||||||
|
yield* session.resume(sessionID)
|
||||||
|
systemBaseline = "Changed context"
|
||||||
|
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
|
||||||
|
yield* session.resume(sessionID)
|
||||||
|
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||||
|
sessionID,
|
||||||
|
messageID: SessionMessage.ID.create(),
|
||||||
|
timestamp: DateTime.makeUnsafe(1),
|
||||||
|
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
|
||||||
|
})
|
||||||
|
systemBaseline = "Replacement context"
|
||||||
|
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false })
|
||||||
|
yield* session.resume(sessionID)
|
||||||
|
|
||||||
|
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
|
||||||
|
["Initial context"],
|
||||||
|
["Initial context"],
|
||||||
|
["Replacement context"],
|
||||||
|
])
|
||||||
|
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"])
|
||||||
|
expect(requests[2]?.messages.map((message) => message.role)).toEqual(["user", "user", "user"])
|
||||||
|
expect((yield* session.context(sessionID)).map((message) => message.type)).toEqual([
|
||||||
|
"user",
|
||||||
|
"user",
|
||||||
|
"model-switched",
|
||||||
|
"user",
|
||||||
|
])
|
||||||
|
yield* replaySessionProjection(sessionID)
|
||||||
|
expect(yield* session.messages({ sessionID })).toHaveLength(5)
|
||||||
|
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fourth" }), resume: false })
|
||||||
|
yield* session.resume(sessionID)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("defers replacement while admitted context is temporarily unavailable", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* setup
|
||||||
|
const session = yield* SessionV2.Service
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
|
||||||
|
|
||||||
|
requests.length = 0
|
||||||
|
response = []
|
||||||
|
yield* session.resume(sessionID)
|
||||||
|
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||||
|
sessionID,
|
||||||
|
messageID: SessionMessage.ID.create(),
|
||||||
|
timestamp: DateTime.makeUnsafe(1),
|
||||||
|
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
|
||||||
|
})
|
||||||
|
systemUnavailable = true
|
||||||
|
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
|
||||||
|
yield* session.resume(sessionID)
|
||||||
|
systemUnavailable = false
|
||||||
|
systemBaseline = "Replacement context"
|
||||||
|
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false })
|
||||||
|
yield* session.resume(sessionID)
|
||||||
|
|
||||||
|
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
|
||||||
|
["Initial context"],
|
||||||
|
["Initial context"],
|
||||||
|
["Replacement context"],
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("advances a pending replacement to the latest invalidation boundary", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* setup
|
||||||
|
const session = yield* SessionV2.Service
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
const { db } = yield* Database.Service
|
||||||
|
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
|
||||||
|
response = []
|
||||||
|
yield* session.resume(sessionID)
|
||||||
|
|
||||||
|
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||||
|
sessionID,
|
||||||
|
messageID: SessionMessage.ID.create(),
|
||||||
|
timestamp: DateTime.makeUnsafe(1),
|
||||||
|
model: { id: ModelV2.ID.make("replacement-1"), providerID: ProviderV2.ID.make("fake") },
|
||||||
|
})
|
||||||
|
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||||
|
sessionID,
|
||||||
|
messageID: SessionMessage.ID.create(),
|
||||||
|
timestamp: DateTime.makeUnsafe(2),
|
||||||
|
model: { id: ModelV2.ID.make("replacement-2"), providerID: ProviderV2.ID.make("fake") },
|
||||||
|
})
|
||||||
|
const latest = yield* SessionInput.latestSeq(db, sessionID)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
yield* db
|
||||||
|
.select({ replacementSeq: SessionContextEpochTable.replacement_seq })
|
||||||
|
.from(SessionContextEpochTable)
|
||||||
|
.where(eq(SessionContextEpochTable.session_id, sessionID))
|
||||||
|
.get()
|
||||||
|
.pipe(Effect.orDie),
|
||||||
|
).toEqual({ replacementSeq: latest })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("retries epoch preparation until observation-time invalidations settle", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* setup
|
||||||
|
const session = yield* SessionV2.Service
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
|
||||||
|
response = []
|
||||||
|
yield* session.resume(sessionID)
|
||||||
|
|
||||||
|
requests.length = 0
|
||||||
|
systemBaseline = "Changed context"
|
||||||
|
let invalidations = 0
|
||||||
|
systemLoadHook = Effect.suspend(() => {
|
||||||
|
if (invalidations === 4) return Effect.void
|
||||||
|
invalidations++
|
||||||
|
return events
|
||||||
|
.publish(SessionEvent.ModelSwitched, {
|
||||||
|
sessionID,
|
||||||
|
messageID: SessionMessage.ID.create(),
|
||||||
|
timestamp: DateTime.makeUnsafe(invalidations),
|
||||||
|
model: { id: ModelV2.ID.make(`replacement-${invalidations}`), providerID: ProviderV2.ID.make("fake") },
|
||||||
|
})
|
||||||
|
.pipe(Effect.asVoid)
|
||||||
|
})
|
||||||
|
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
|
||||||
|
|
||||||
|
yield* session.resume(sessionID)
|
||||||
|
|
||||||
|
expect(invalidations).toBe(4)
|
||||||
|
expect(requests).toHaveLength(1)
|
||||||
|
expect(requests[0]?.system.map((part) => part.text)).toEqual(["Changed context"])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("replays retained context projections while replacement is pending", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* setup
|
||||||
|
const session = yield* SessionV2.Service
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
|
||||||
|
|
||||||
|
requests.length = 0
|
||||||
|
response = []
|
||||||
|
yield* session.resume(sessionID)
|
||||||
|
systemBaseline = "Changed context"
|
||||||
|
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
|
||||||
|
yield* session.resume(sessionID)
|
||||||
|
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||||
|
sessionID,
|
||||||
|
messageID: SessionMessage.ID.create(),
|
||||||
|
timestamp: DateTime.makeUnsafe(1),
|
||||||
|
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* replaySessionProjection(sessionID)
|
||||||
|
systemBaseline = "Replacement context"
|
||||||
|
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false })
|
||||||
|
yield* session.resume(sessionID)
|
||||||
|
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Replacement context"])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("replaces the baseline lazily after completed compaction without reopening replacement on replay", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* setup
|
||||||
|
const session = yield* SessionV2.Service
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
|
||||||
|
|
||||||
|
requests.length = 0
|
||||||
|
response = []
|
||||||
|
yield* session.resume(sessionID)
|
||||||
|
yield* events.publish(SessionEvent.Compaction.Started, {
|
||||||
|
sessionID,
|
||||||
|
messageID: SessionMessage.ID.create(),
|
||||||
|
timestamp: DateTime.makeUnsafe(1),
|
||||||
|
reason: "manual",
|
||||||
|
})
|
||||||
|
yield* events.publish(SessionEvent.Compaction.Ended, {
|
||||||
|
sessionID,
|
||||||
|
timestamp: DateTime.makeUnsafe(2),
|
||||||
|
text: "summary",
|
||||||
|
})
|
||||||
|
systemBaseline = "Replacement context"
|
||||||
|
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
|
||||||
|
yield* session.resume(sessionID)
|
||||||
|
|
||||||
|
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
|
||||||
|
["Initial context"],
|
||||||
|
["Replacement context"],
|
||||||
|
])
|
||||||
|
yield* replaySessionProjection(sessionID)
|
||||||
|
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false })
|
||||||
|
yield* session.resume(sessionID)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("preserves effective System updates while compaction replacement is blocked", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* setup
|
||||||
|
const session = yield* SessionV2.Service
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
|
||||||
|
|
||||||
|
requests.length = 0
|
||||||
|
response = []
|
||||||
|
yield* session.resume(sessionID)
|
||||||
|
systemBaseline = "Changed context"
|
||||||
|
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
|
||||||
|
yield* session.resume(sessionID)
|
||||||
|
yield* events.publish(SessionEvent.Compaction.Started, {
|
||||||
|
sessionID,
|
||||||
|
messageID: SessionMessage.ID.create(),
|
||||||
|
timestamp: DateTime.makeUnsafe(1),
|
||||||
|
reason: "manual",
|
||||||
|
})
|
||||||
|
yield* events.publish(SessionEvent.Compaction.Ended, {
|
||||||
|
sessionID,
|
||||||
|
timestamp: DateTime.makeUnsafe(2),
|
||||||
|
text: "summary",
|
||||||
|
})
|
||||||
|
systemUnavailable = true
|
||||||
|
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false })
|
||||||
|
yield* session.resume(sessionID)
|
||||||
|
|
||||||
|
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Initial context"])
|
||||||
|
expect(
|
||||||
|
requests
|
||||||
|
.at(-1)
|
||||||
|
?.messages.some(
|
||||||
|
(message) =>
|
||||||
|
message.role === "system" &&
|
||||||
|
message.content[0]?.type === "text" &&
|
||||||
|
message.content[0].text === "Changed context",
|
||||||
|
),
|
||||||
|
).toBe(true)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("projects reasoning and tool events without executing or continuing tools", () =>
|
it.effect("projects reasoning and tool events without executing or continuing tools", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* setup
|
yield* setup
|
||||||
@ -667,6 +1124,50 @@ describe("SessionRunnerLLM", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("reloads a model switch before a tool-driven continuation turn", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* setup
|
||||||
|
const session = yield* SessionV2.Service
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Echo this" }), resume: false })
|
||||||
|
|
||||||
|
requests.length = 0
|
||||||
|
responses = [
|
||||||
|
[
|
||||||
|
LLMEvent.stepStart({ index: 0 }),
|
||||||
|
LLMEvent.toolCall({ id: "call-echo", name: "echo", input: { text: "hello" } }),
|
||||||
|
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||||
|
LLMEvent.finish({ reason: "tool-calls" }),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
LLMEvent.stepStart({ index: 0 }),
|
||||||
|
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
|
||||||
|
LLMEvent.finish({ reason: "stop" }),
|
||||||
|
],
|
||||||
|
]
|
||||||
|
toolExecutionGate = yield* Deferred.make<void>()
|
||||||
|
toolExecutionsStarted = yield* Deferred.make<void>()
|
||||||
|
toolExecutionsReady = 1
|
||||||
|
const run = yield* Effect.forkChild(session.resume(sessionID))
|
||||||
|
yield* Deferred.await(toolExecutionsStarted)
|
||||||
|
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||||
|
sessionID,
|
||||||
|
messageID: SessionMessage.ID.create(),
|
||||||
|
timestamp: DateTime.makeUnsafe(1),
|
||||||
|
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
|
||||||
|
})
|
||||||
|
systemBaseline = "Replacement context"
|
||||||
|
yield* Deferred.succeed(toolExecutionGate, undefined)
|
||||||
|
yield* Fiber.join(run)
|
||||||
|
|
||||||
|
expect(requests.map((request) => request.model)).toEqual([model, replacementModel])
|
||||||
|
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
|
||||||
|
["Initial context"],
|
||||||
|
["Replacement context"],
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("restores durable reasoning provider metadata in a second-turn request", () =>
|
it.effect("restores durable reasoning provider metadata in a second-turn request", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* setup
|
yield* setup
|
||||||
|
|||||||
@ -1,70 +0,0 @@
|
|||||||
import { describe, expect } from "bun:test"
|
|
||||||
import { Effect, Layer } from "effect"
|
|
||||||
import * as TestClock from "effect/testing/TestClock"
|
|
||||||
import { Location } from "@opencode-ai/core/location"
|
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
|
||||||
import { SessionSystemContext } from "@opencode-ai/core/session-system-context"
|
|
||||||
import { SystemContext } from "@opencode-ai/core/system-context"
|
|
||||||
import { location } from "./fixture/location"
|
|
||||||
import { testEffect } from "./lib/effect"
|
|
||||||
|
|
||||||
const directory = AbsolutePath.make("/repo/packages/core")
|
|
||||||
const projectDirectory = AbsolutePath.make("/repo")
|
|
||||||
const timestamp = Date.parse("2026-06-03T12:00:00.000Z")
|
|
||||||
const localDate = (time: number) => new Date(time).toDateString()
|
|
||||||
const it = testEffect(
|
|
||||||
SessionSystemContext.locationLayer.pipe(
|
|
||||||
Layer.provide(
|
|
||||||
Layer.succeed(
|
|
||||||
Location.Service,
|
|
||||||
Location.Service.of(
|
|
||||||
location({ directory }, { projectDirectory, vcs: { type: "git", store: AbsolutePath.make("/repo/.git") } }),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
describe("SessionSystemContext", () => {
|
|
||||||
it.effect("loads location-scoped environment and host-local date context", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
yield* TestClock.setTime(timestamp)
|
|
||||||
const context = yield* SessionSystemContext.Service
|
|
||||||
const initialized = SystemContext.initialize(yield* context.load())
|
|
||||||
|
|
||||||
expect(initialized.baseline).toEqual([
|
|
||||||
{
|
|
||||||
key: SystemContext.Key.make("core/environment"),
|
|
||||||
text: [
|
|
||||||
"Here is some useful information about the environment you are running in:",
|
|
||||||
"<env>",
|
|
||||||
` Working directory: ${directory}`,
|
|
||||||
` Workspace root folder: ${projectDirectory}`,
|
|
||||||
" Is directory a git repo: yes",
|
|
||||||
` Platform: ${process.platform}`,
|
|
||||||
"</env>",
|
|
||||||
].join("\n"),
|
|
||||||
},
|
|
||||||
{ key: SystemContext.Key.make("core/date"), text: `Today's date: ${localDate(timestamp)}` },
|
|
||||||
])
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("refreshes the date without repeating unchanged environment context", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
yield* TestClock.setTime(timestamp)
|
|
||||||
const context = yield* SessionSystemContext.Service
|
|
||||||
const initialized = SystemContext.initialize(yield* context.load())
|
|
||||||
|
|
||||||
yield* TestClock.setTime(timestamp + 24 * 60 * 60 * 1000)
|
|
||||||
const refreshed = SystemContext.refresh(yield* context.load(), initialized.checkpoint)
|
|
||||||
|
|
||||||
expect(refreshed.changes).toEqual([
|
|
||||||
{
|
|
||||||
key: SystemContext.Key.make("core/date"),
|
|
||||||
text: `Today's date is now: ${localDate(timestamp + 24 * 60 * 60 * 1000)}`,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
127
packages/core/test/system-context-builtins.test.ts
Normal file
127
packages/core/test/system-context-builtins.test.ts
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Effect, Layer } from "effect"
|
||||||
|
import * as TestClock from "effect/testing/TestClock"
|
||||||
|
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 { SystemContextRegistry } from "@opencode-ai/core/system-context-registry"
|
||||||
|
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 it = testEffect(
|
||||||
|
SystemContextBuiltIns.locationLayer.pipe(
|
||||||
|
Layer.provide(FSUtil.defaultLayer),
|
||||||
|
Layer.provide(Global.layerWith({ config: "/global" })),
|
||||||
|
Layer.provide(locationLayer),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
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(FSUtil.defaultLayer))
|
||||||
|
const itWithInstructions = testEffect(
|
||||||
|
SystemContextBuiltIns.locationLayer.pipe(
|
||||||
|
Layer.provide(instructionFS),
|
||||||
|
Layer.provide(Global.layerWith({ config: "/global" })),
|
||||||
|
Layer.provide(locationLayer),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
describe("SystemContextBuiltIns", () => {
|
||||||
|
it.effect("loads location-scoped environment and host-local date context", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* TestClock.setTime(timestamp)
|
||||||
|
const context = yield* SystemContextRegistry.Service
|
||||||
|
const initialized = yield* SystemContext.initialize(yield* context.load())
|
||||||
|
|
||||||
|
expect(initialized.baseline).toBe(
|
||||||
|
[
|
||||||
|
"Here is some useful information about the environment you are running in:",
|
||||||
|
"<env>",
|
||||||
|
` Working directory: ${directory}`,
|
||||||
|
` Workspace root folder: ${projectDirectory}`,
|
||||||
|
" Is directory a git repo: yes",
|
||||||
|
` Platform: ${process.platform}`,
|
||||||
|
"</env>",
|
||||||
|
"",
|
||||||
|
`Today's date: ${localDate(timestamp)}`,
|
||||||
|
].join("\n"),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("reconciles the date without repeating unchanged environment context", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* TestClock.setTime(timestamp)
|
||||||
|
const context = yield* SystemContextRegistry.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.snapshot)
|
||||||
|
|
||||||
|
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* SystemContextRegistry.Service
|
||||||
|
const initialized = yield* SystemContext.initialize(yield* context.load())
|
||||||
|
|
||||||
|
yield* TestClock.setTime(timestamp + 60 * 60 * 1000)
|
||||||
|
expect(yield* SystemContext.reconcile(yield* context.load(), initialized.snapshot)).toEqual({ _tag: "Unchanged" })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
itWithInstructions.effect("composes ambient instructions after built-in context", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* TestClock.setTime(timestamp)
|
||||||
|
const context = yield* SystemContextRegistry.Service
|
||||||
|
|
||||||
|
expect((yield* SystemContext.initialize(yield* context.load())).baseline).toBe(
|
||||||
|
[
|
||||||
|
"Here is some useful information about the environment you are running in:",
|
||||||
|
"<env>",
|
||||||
|
` Working directory: ${directory}`,
|
||||||
|
` Workspace root folder: ${projectDirectory}`,
|
||||||
|
" Is directory a git repo: yes",
|
||||||
|
` Platform: ${process.platform}`,
|
||||||
|
"</env>",
|
||||||
|
"",
|
||||||
|
`Today's date: ${localDate(timestamp)}`,
|
||||||
|
"",
|
||||||
|
`Instructions from: ${instructionFile}\nBe precise.`,
|
||||||
|
].join("\n"),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
113
packages/core/test/system-context-registry.test.ts
Normal file
113
packages/core/test/system-context-registry.test.ts
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Cause, Effect, Exit, Schema, Scope } from "effect"
|
||||||
|
import { SystemContext } from "@opencode-ai/core/system-context"
|
||||||
|
import { SystemContextRegistry } from "@opencode-ai/core/system-context-registry"
|
||||||
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
|
const contribution = (key: string, text: string, sourceKey = key) => ({
|
||||||
|
key: SystemContext.Key.make(key),
|
||||||
|
load: Effect.succeed(
|
||||||
|
SystemContext.make({
|
||||||
|
key: SystemContext.Key.make(sourceKey),
|
||||||
|
codec: Schema.toCodecJson(Schema.String),
|
||||||
|
load: Effect.succeed(text),
|
||||||
|
baseline: String,
|
||||||
|
update: (_previous, current) => current,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
const it = testEffect(SystemContextRegistry.layer)
|
||||||
|
|
||||||
|
describe("SystemContextRegistry", () => {
|
||||||
|
it.effect("loads empty system context when there are no contributions", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const registry = yield* SystemContextRegistry.Service
|
||||||
|
|
||||||
|
expect(yield* SystemContext.initialize(yield* registry.load())).toEqual({ baseline: "", snapshot: {} })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("loads scoped contributions in stable key order", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const registry = yield* SystemContextRegistry.Service
|
||||||
|
yield* registry.contribute(contribution("test/second", "second"))
|
||||||
|
yield* registry.contribute(contribution("test/first", "first"))
|
||||||
|
|
||||||
|
expect((yield* SystemContext.initialize(yield* registry.load())).baseline).toBe("first\n\nsecond")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("re-evaluates contribution producers on each load", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const registry = yield* SystemContextRegistry.Service
|
||||||
|
let loads = 0
|
||||||
|
yield* registry.contribute({
|
||||||
|
key: SystemContext.Key.make("test/dynamic"),
|
||||||
|
load: Effect.sync(() => {
|
||||||
|
loads++
|
||||||
|
return SystemContext.empty
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* registry.load()
|
||||||
|
yield* registry.load()
|
||||||
|
|
||||||
|
expect(loads).toBe(2)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("propagates contribution producer failures", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const registry = yield* SystemContextRegistry.Service
|
||||||
|
const failure = new Error("contribution failed")
|
||||||
|
yield* registry.contribute({ key: SystemContext.Key.make("test/failure"), load: Effect.die(failure) })
|
||||||
|
|
||||||
|
const exit = yield* registry.load().pipe(Effect.exit)
|
||||||
|
|
||||||
|
expect(Exit.isFailure(exit)).toBe(true)
|
||||||
|
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBe(failure)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("rejects duplicate source keys from separate contributions", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const registry = yield* SystemContextRegistry.Service
|
||||||
|
yield* registry.contribute(contribution("test/first", "first", "test/duplicate"))
|
||||||
|
yield* registry.contribute(contribution("test/second", "second", "test/duplicate"))
|
||||||
|
|
||||||
|
const exit = yield* registry.load().pipe(Effect.exit)
|
||||||
|
|
||||||
|
expect(Exit.isFailure(exit)).toBe(true)
|
||||||
|
if (Exit.isFailure(exit)) {
|
||||||
|
expect(Cause.squash(exit.cause)).toBeInstanceOf(SystemContext.DuplicateKeyError)
|
||||||
|
expect(Cause.squash(exit.cause)).toMatchObject({ key: SystemContext.Key.make("test/duplicate") })
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("rejects duplicate contribution keys", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const registry = yield* SystemContextRegistry.Service
|
||||||
|
yield* registry.contribute(contribution("test/duplicate", "first"))
|
||||||
|
|
||||||
|
const exit = yield* registry.contribute(contribution("test/duplicate", "second", "test/other")).pipe(Effect.exit)
|
||||||
|
|
||||||
|
expect(Exit.isFailure(exit)).toBe(true)
|
||||||
|
if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("Duplicate system context contribution key")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("removes a contribution when its owning scope closes", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const registry = yield* SystemContextRegistry.Service
|
||||||
|
const scope = yield* Scope.make()
|
||||||
|
yield* registry.contribute(contribution("test/scoped", "scoped")).pipe(Scope.provide(scope))
|
||||||
|
|
||||||
|
expect((yield* SystemContext.initialize(yield* registry.load())).baseline).toBe("scoped")
|
||||||
|
|
||||||
|
yield* Scope.close(scope, Exit.void)
|
||||||
|
expect(yield* SystemContext.initialize(yield* registry.load())).toEqual({ baseline: "", snapshot: {} })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
@ -1,188 +1,307 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect, Schema } from "effect"
|
import { Cause, Effect, Exit, Schema } from "effect"
|
||||||
import { SystemContext } from "@opencode-ai/core/system-context"
|
import { SystemContext } from "@opencode-ai/core/system-context"
|
||||||
import { Hash } from "@opencode-ai/core/util/hash"
|
import { it } from "./lib/effect"
|
||||||
|
|
||||||
const key = SystemContext.Key.make
|
const key = SystemContext.Key.make
|
||||||
|
const stringContext = (input: {
|
||||||
|
key: string
|
||||||
|
value: string | SystemContext.Unavailable
|
||||||
|
baseline?: (value: string) => string
|
||||||
|
update?: (previous: string, current: string) => string
|
||||||
|
removed?: (value: string) => string
|
||||||
|
}) =>
|
||||||
|
SystemContext.make({
|
||||||
|
key: key(input.key),
|
||||||
|
codec: Schema.toCodecJson(Schema.String),
|
||||||
|
load: Effect.succeed(input.value),
|
||||||
|
baseline: input.baseline ?? String,
|
||||||
|
update: input.update ?? ((_previous, current) => current),
|
||||||
|
removed: input.removed,
|
||||||
|
})
|
||||||
|
|
||||||
describe("SystemContext", () => {
|
describe("SystemContext", () => {
|
||||||
test("loads one coherent sample and initializes a deterministic baseline", async () => {
|
it.effect("stores the canonical JSON encoding of the loaded value", () =>
|
||||||
let loads = 0
|
Effect.gen(function* () {
|
||||||
const context = SystemContext.struct({
|
const context = SystemContext.make({
|
||||||
date: SystemContext.value({
|
|
||||||
key: key("core/date"),
|
key: key("core/date"),
|
||||||
|
codec: Schema.toCodecJson(Schema.DateFromString),
|
||||||
|
load: Effect.succeed(new Date("2026-06-03T12:00:00.000Z")),
|
||||||
|
baseline: (date) => date.toISOString(),
|
||||||
|
update: (_previous, date) => date.toISOString(),
|
||||||
|
removed: () => "Date removed",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect((yield* SystemContext.initialize(context)).snapshot["core/date"].value).toBe("2026-06-03T12:00:00.000Z")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("loads once and initializes a baseline with a structured snapshot", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
let loads = 0
|
||||||
|
const context = SystemContext.combine([
|
||||||
|
SystemContext.make({
|
||||||
|
key: key("core/date"),
|
||||||
|
codec: Schema.toCodecJson(Schema.String),
|
||||||
|
load: Effect.sync(() => {
|
||||||
|
loads++
|
||||||
|
return "2026-06-03"
|
||||||
|
}),
|
||||||
|
baseline: (date) => `Today's date is ${date}.`,
|
||||||
|
update: (previous, current) => `The date changed from ${previous} to ${current}.`,
|
||||||
|
removed: () => "The date was removed.",
|
||||||
|
}),
|
||||||
|
stringContext({ key: "core/location", value: "/repo", baseline: (value) => `Directory: ${value}` }),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(yield* SystemContext.initialize(context)).toEqual({
|
||||||
|
baseline: "Today's date is 2026-06-03.\n\nDirectory: /repo",
|
||||||
|
snapshot: {
|
||||||
|
"core/date": { value: "2026-06-03", removed: "The date was removed." },
|
||||||
|
"core/location": { value: "/repo" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(loads).toBe(1)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("renders updates only after a structured value changes", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const previous = {
|
||||||
|
"core/date": { value: "2026-06-03", removed: "The date was removed." },
|
||||||
|
"core/location": { value: "/repo", removed: "Removed: /repo" },
|
||||||
|
}
|
||||||
|
const changed = SystemContext.combine([
|
||||||
|
stringContext({
|
||||||
|
key: "core/date",
|
||||||
|
value: "2026-06-04",
|
||||||
|
update: (before, current) => `The date changed from ${before} to ${current}.`,
|
||||||
|
removed: () => "The date was removed.",
|
||||||
|
}),
|
||||||
|
stringContext({ key: "core/location", value: "/repo" }),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(yield* SystemContext.reconcile(changed, previous)).toEqual({
|
||||||
|
_tag: "Updated",
|
||||||
|
text: "The date changed from 2026-06-03 to 2026-06-04.",
|
||||||
|
snapshot: {
|
||||||
|
"core/date": { value: "2026-06-04", removed: "The date was removed." },
|
||||||
|
"core/location": { value: "/repo", removed: "Removed: /repo" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(
|
||||||
|
yield* SystemContext.reconcile(
|
||||||
|
SystemContext.combine([
|
||||||
|
stringContext({ key: "core/date", value: "2026-06-03", removed: () => "The date was removed." }),
|
||||||
|
stringContext({ key: "core/location", value: "/repo" }),
|
||||||
|
]),
|
||||||
|
previous,
|
||||||
|
),
|
||||||
|
).toEqual({ _tag: "Unchanged" })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("uses the baseline for a newly added source", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const context = stringContext({
|
||||||
|
key: "core/skills",
|
||||||
|
value: "effect",
|
||||||
|
baseline: (skill) => `Available skill: ${skill}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(yield* SystemContext.reconcile(context, {})).toEqual({
|
||||||
|
_tag: "Updated",
|
||||||
|
text: "Available skill: effect",
|
||||||
|
snapshot: { "core/skills": { value: "effect" } },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("retains admitted snapshots 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 })
|
||||||
|
|
||||||
|
expect(yield* SystemContext.reconcile(context, previous)).toEqual({ _tag: "Unchanged" })
|
||||||
|
expect(yield* SystemContext.replace(context, previous)).toEqual({ _tag: "ReplacementBlocked" })
|
||||||
|
expect(yield* SystemContext.replace(context, {})).toMatchObject({ _tag: "ReplacementReady" })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("blocks initialization while a source is unavailable", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const exit = yield* SystemContext.initialize(
|
||||||
|
stringContext({ key: "core/remote", value: SystemContext.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")] }),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("emits the previously stored removal message", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
expect(
|
||||||
|
yield* SystemContext.reconcile(SystemContext.empty, {
|
||||||
|
"core/instructions": { value: "contents", removed: "Instructions removed; stop applying them." },
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
_tag: "Updated",
|
||||||
|
text: "Instructions removed; stop applying them.",
|
||||||
|
snapshot: {},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("requests replacement when a source without removal text disappears", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
expect(
|
||||||
|
yield* SystemContext.reconcile(SystemContext.empty, { "core/date": { value: "2026-06-04" } }),
|
||||||
|
).toMatchObject({
|
||||||
|
_tag: "ReplacementReady",
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("renders multiple removals in stable key order", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
expect(
|
||||||
|
yield* SystemContext.reconcile(SystemContext.empty, {
|
||||||
|
"core/z": { value: "z", removed: "Removed z" },
|
||||||
|
"core/a": { value: "a", removed: "Removed a" },
|
||||||
|
}),
|
||||||
|
).toMatchObject({ _tag: "Updated", text: "Removed a\n\nRemoved z" })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("rejects empty model-visible renderings", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const exit = yield* SystemContext.initialize(
|
||||||
|
stringContext({ key: "core/empty", value: "value", baseline: () => "" }),
|
||||||
|
).pipe(Effect.exit)
|
||||||
|
|
||||||
|
expect(Exit.isFailure(exit)).toBe(true)
|
||||||
|
if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("rendered an empty baseline")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("requests replacement when a stored value no longer decodes", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
expect(
|
||||||
|
yield* SystemContext.reconcile(stringContext({ key: "core/date", value: "2026-06-04" }), {
|
||||||
|
"core/date": { value: 42, removed: "Date removed" },
|
||||||
|
}),
|
||||||
|
).toMatchObject({ _tag: "ReplacementReady" })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("replaces from one coherent source observation", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
let loads = 0
|
||||||
|
const context = SystemContext.make({
|
||||||
|
key: key("core/date"),
|
||||||
|
codec: Schema.toCodecJson(Schema.String),
|
||||||
load: Effect.sync(() => {
|
load: Effect.sync(() => {
|
||||||
loads++
|
loads++
|
||||||
return { baseline: "Today's date is 2026-06-03.", update: "The current date is 2026-06-03." }
|
return "2026-06-04"
|
||||||
}),
|
}),
|
||||||
}),
|
baseline: String,
|
||||||
location: SystemContext.value({
|
update: (_previous, current) => current,
|
||||||
key: key("core/location"),
|
})
|
||||||
load: Effect.succeed({ baseline: "Working directory: /repo", update: "The working directory is /repo." }),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
const initialized = SystemContext.initialize(await Effect.runPromise(SystemContext.load(context)))
|
expect(yield* SystemContext.reconcile(context, { "core/date": { value: 42 } })).toMatchObject({
|
||||||
|
_tag: "ReplacementReady",
|
||||||
|
generation: { baseline: "2026-06-04" },
|
||||||
|
})
|
||||||
|
expect(loads).toBe(1)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
expect(loads).toBe(1)
|
it.effect("does not render discarded updates while replacing", () =>
|
||||||
expect(initialized).toEqual({
|
Effect.gen(function* () {
|
||||||
baseline: [
|
let updates = 0
|
||||||
{ key: key("core/date"), text: "Today's date is 2026-06-03." },
|
const context = SystemContext.combine([
|
||||||
{ key: key("core/location"), text: "Working directory: /repo" },
|
stringContext({
|
||||||
],
|
key: "core/date",
|
||||||
checkpoint: {
|
value: "2026-06-04",
|
||||||
"core/date": Hash.sha256("The current date is 2026-06-03."),
|
update: () => {
|
||||||
"core/location": Hash.sha256("The working directory is /repo."),
|
updates++
|
||||||
},
|
return "updated"
|
||||||
})
|
},
|
||||||
})
|
}),
|
||||||
|
stringContext({ key: "core/location", value: "/repo" }),
|
||||||
|
])
|
||||||
|
|
||||||
test("emits changed and newly registered components in declaration order", async () => {
|
expect(
|
||||||
const context = SystemContext.struct({
|
yield* SystemContext.reconcile(context, {
|
||||||
date: SystemContext.value({
|
"core/date": { value: "2026-06-03" },
|
||||||
key: key("core/date"),
|
"core/location": { value: 42 },
|
||||||
load: Effect.succeed({ baseline: "Today's date is 2026-06-04.", update: "The current date is 2026-06-04." }),
|
}),
|
||||||
}),
|
).toMatchObject({ _tag: "ReplacementReady" })
|
||||||
location: SystemContext.value({
|
expect(updates).toBe(0)
|
||||||
key: key("core/location"),
|
}),
|
||||||
load: Effect.succeed({ baseline: "Working directory: /repo", update: "The working directory is /repo." }),
|
)
|
||||||
}),
|
|
||||||
skills: SystemContext.value({
|
|
||||||
key: key("core/skills"),
|
|
||||||
load: Effect.succeed({ baseline: "Available skills: effect", update: "Available skills: effect" }),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
const refreshed = SystemContext.refresh(await Effect.runPromise(SystemContext.load(context)), {
|
it.effect("blocks an incompatible replacement while another admitted source is unavailable", () =>
|
||||||
"core/date": Hash.sha256("The current date is 2026-06-03."),
|
Effect.gen(function* () {
|
||||||
"core/location": Hash.sha256("The working directory is /repo."),
|
const previous = {
|
||||||
})
|
"core/date": { value: 42, removed: "Date removed" },
|
||||||
|
"core/remote": { value: "instructions", removed: "Instructions removed" },
|
||||||
|
}
|
||||||
|
const context = SystemContext.combine([
|
||||||
|
stringContext({ key: "core/date", value: "2026-06-04" }),
|
||||||
|
stringContext({ key: "core/remote", value: SystemContext.unavailable }),
|
||||||
|
])
|
||||||
|
|
||||||
expect(refreshed).toEqual({
|
expect(yield* SystemContext.reconcile(context, previous)).toEqual({ _tag: "ReplacementBlocked" })
|
||||||
changes: [
|
expect(yield* SystemContext.replace(context, previous)).toEqual({ _tag: "ReplacementBlocked" })
|
||||||
{ key: key("core/date"), text: "The current date is 2026-06-04." },
|
}),
|
||||||
{ key: key("core/skills"), text: "Available skills: effect" },
|
)
|
||||||
],
|
|
||||||
checkpoint: {
|
|
||||||
"core/date": Hash.sha256("The current date is 2026-06-04."),
|
|
||||||
"core/location": Hash.sha256("The working directory is /repo."),
|
|
||||||
"core/skills": Hash.sha256("Available skills: effect"),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
expect(SystemContext.render(refreshed.changes)).toBe("The current date is 2026-06-04.\n\nAvailable skills: effect")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("omits unavailable initial context and admits it after its first successful load", async () => {
|
it.effect("rejects duplicate source keys", () =>
|
||||||
let available = false
|
Effect.sync(() => {
|
||||||
const context = SystemContext.struct({
|
expect(() =>
|
||||||
remote: SystemContext.value({
|
SystemContext.combine([
|
||||||
key: key("core/remote-instructions"),
|
stringContext({ key: "core/date", value: "one" }),
|
||||||
load: Effect.sync(() =>
|
stringContext({ key: "core/date", value: "two" }),
|
||||||
available
|
]),
|
||||||
? { baseline: "Remote instructions: available", update: "Remote instructions are now available." }
|
).toThrow(new SystemContext.DuplicateKeyError({ key: key("core/date") }))
|
||||||
: SystemContext.unavailable,
|
}),
|
||||||
),
|
)
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
const initialized = SystemContext.initialize(await Effect.runPromise(SystemContext.load(context)))
|
it.effect("combines contexts in order", () =>
|
||||||
available = true
|
Effect.gen(function* () {
|
||||||
const refreshed = SystemContext.refresh(
|
expect(
|
||||||
await Effect.runPromise(SystemContext.load(context)),
|
(yield* SystemContext.initialize(
|
||||||
initialized.checkpoint,
|
SystemContext.combine([
|
||||||
)
|
stringContext({ key: "core/date", value: "date" }),
|
||||||
|
stringContext({ key: "core/location", value: "location" }),
|
||||||
|
]),
|
||||||
|
)).baseline,
|
||||||
|
).toBe("date\n\nlocation")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
expect(initialized).toEqual({ baseline: [], checkpoint: {} })
|
it.effect("requires namespaced source keys", () =>
|
||||||
expect(refreshed.changes).toEqual([
|
Effect.sync(() => {
|
||||||
{ key: key("core/remote-instructions"), text: "Remote instructions are now available." },
|
const decodeKey = Schema.decodeUnknownSync(SystemContext.Key)
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("retains an existing checkpoint while context is unavailable", async () => {
|
expect(decodeKey("core/date")).toBe(key("core/date"))
|
||||||
const previous = { "core/remote-instructions": Hash.sha256("Remote instructions: old") }
|
expect(() => decodeKey("date")).toThrow()
|
||||||
const context = SystemContext.struct({
|
}),
|
||||||
remote: SystemContext.value({
|
)
|
||||||
key: key("core/remote-instructions"),
|
|
||||||
load: Effect.succeed(SystemContext.unavailable),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
const refreshed = SystemContext.refresh(await Effect.runPromise(SystemContext.load(context)), previous)
|
it.effect("requires namespaced durable snapshot keys", () =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
const decodeSnapshot = Schema.decodeUnknownSync(SystemContext.Snapshot)
|
||||||
|
|
||||||
expect(refreshed).toEqual({ changes: [], checkpoint: previous })
|
expect(Object.keys(decodeSnapshot({ "core/date": { value: "date" } }))).toEqual(["core/date"])
|
||||||
})
|
expect(() => decodeSnapshot({ date: { value: "date" } })).toThrow()
|
||||||
|
expect(() => decodeSnapshot({ "core/date": { value: "date", removed: "" } })).toThrow()
|
||||||
test("drops checkpoints for removed components", async () => {
|
}),
|
||||||
const context = SystemContext.struct({
|
)
|
||||||
date: SystemContext.value({
|
|
||||||
key: key("core/date"),
|
|
||||||
load: Effect.succeed({ baseline: "Today's date is 2026-06-03.", update: "The current date is 2026-06-03." }),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
const refreshed = SystemContext.refresh(await Effect.runPromise(SystemContext.load(context)), {
|
|
||||||
"core/date": Hash.sha256("The current date is 2026-06-03."),
|
|
||||||
"plugin/removed": Hash.sha256("Removed plugin context"),
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(refreshed).toEqual({
|
|
||||||
changes: [],
|
|
||||||
checkpoint: { "core/date": Hash.sha256("The current date is 2026-06-03.") },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("ignores inherited checkpoint properties", async () => {
|
|
||||||
const context = SystemContext.struct({
|
|
||||||
date: SystemContext.value({
|
|
||||||
key: key("core/date"),
|
|
||||||
load: Effect.succeed({ baseline: "Today's date is 2026-06-03.", update: "The current date is 2026-06-03." }),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
const previous = Object.create({
|
|
||||||
"core/date": Hash.sha256("The current date is 2026-06-03."),
|
|
||||||
}) as SystemContext.Checkpoint
|
|
||||||
|
|
||||||
const refreshed = SystemContext.refresh(await Effect.runPromise(SystemContext.load(context)), previous)
|
|
||||||
|
|
||||||
expect(refreshed.changes).toEqual([{ key: key("core/date"), text: "The current date is 2026-06-03." }])
|
|
||||||
expect(Object.hasOwn(refreshed.checkpoint, "core/date")).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("preserves unexpected loader failures", async () => {
|
|
||||||
const context = SystemContext.struct({
|
|
||||||
broken: SystemContext.value({
|
|
||||||
key: key("plugin/broken"),
|
|
||||||
load: Effect.fail("broken loader"),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
await expect(Effect.runPromise(SystemContext.load(context))).rejects.toBe("broken loader")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("rejects duplicate component keys", () => {
|
|
||||||
expect(() =>
|
|
||||||
SystemContext.struct({
|
|
||||||
one: SystemContext.value({ key: key("core/date"), load: Effect.succeed({ baseline: "one", update: "one" }) }),
|
|
||||||
two: SystemContext.value({ key: key("core/date"), load: Effect.succeed({ baseline: "two", update: "two" }) }),
|
|
||||||
}),
|
|
||||||
).toThrow(new SystemContext.DuplicateKeyError({ key: key("core/date") }))
|
|
||||||
})
|
|
||||||
|
|
||||||
test("rejects duplicate component keys at the interpreter boundary", async () => {
|
|
||||||
const component = SystemContext.value({
|
|
||||||
key: key("core/date"),
|
|
||||||
load: Effect.succeed({ baseline: "date", update: "date" }),
|
|
||||||
})
|
|
||||||
const context: SystemContext.SystemContext = { components: [component, component] }
|
|
||||||
|
|
||||||
await expect(Effect.runPromise(SystemContext.load(context))).rejects.toBeInstanceOf(SystemContext.DuplicateKeyError)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("requires namespaced component keys", () => {
|
|
||||||
const decode = Schema.decodeUnknownSync(SystemContext.Key)
|
|
||||||
|
|
||||||
expect(decode("core/date")).toBe(key("core/date"))
|
|
||||||
expect(() => decode("date")).toThrow()
|
|
||||||
expect(() => decode("core/")).toThrow()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@ -351,38 +351,29 @@ const endsInServerToolUse = (message: LLMRequest["messages"][number]) => {
|
|||||||
return message.role === "assistant" && last?.type === "tool-call" && last.providerExecuted === true
|
return message.role === "assistant" && last?.type === "tool-call" && last.providerExecuted === true
|
||||||
}
|
}
|
||||||
|
|
||||||
const endsInLocalToolUse = (message: LLMRequest["messages"][number]) => {
|
const canUseNativeSystemUpdate = (messages: LLMRequest["messages"], index: number) => {
|
||||||
const last = message.content.at(-1)
|
|
||||||
return message.role === "assistant" && last?.type === "tool-call" && last.providerExecuted !== true
|
|
||||||
}
|
|
||||||
|
|
||||||
const validateNativeSystemUpdate = Effect.fn("AnthropicMessages.validateNativeSystemUpdate")(function* (
|
|
||||||
messages: LLMRequest["messages"],
|
|
||||||
index: number,
|
|
||||||
) {
|
|
||||||
const previous = messages[index - 1]
|
const previous = messages[index - 1]
|
||||||
const next = messages[index + 1]
|
const next = messages[index + 1]
|
||||||
if (!previous)
|
return (
|
||||||
return yield* invalid(
|
previous !== undefined &&
|
||||||
"Anthropic Messages chronological system updates cannot be the first message; use LLMRequest.system",
|
previous.role !== "system" &&
|
||||||
)
|
(previous.role === "user" || previous.role === "tool" || endsInServerToolUse(previous)) &&
|
||||||
if (previous.role === "system")
|
next?.role !== "system" &&
|
||||||
return yield* invalid("Anthropic Messages chronological system updates cannot be consecutive")
|
(next === undefined || next.role === "assistant")
|
||||||
if (endsInLocalToolUse(previous))
|
)
|
||||||
return yield* invalid(
|
}
|
||||||
"Anthropic Messages chronological system updates cannot appear between a local tool call and its tool result",
|
|
||||||
)
|
const splitsLocalToolResults = (messages: LLMRequest["messages"], index: number) => {
|
||||||
if (previous.role !== "user" && previous.role !== "tool" && !endsInServerToolUse(previous))
|
const pending = new Set<string>()
|
||||||
return yield* invalid(
|
for (const message of messages.slice(0, index)) {
|
||||||
"Anthropic Messages chronological system updates must follow a user message, tool result, or assistant server tool use",
|
for (const part of message.content) {
|
||||||
)
|
if (message.role === "assistant" && part.type === "tool-call" && part.providerExecuted !== true)
|
||||||
if (next?.role === "system")
|
pending.add(part.id)
|
||||||
return yield* invalid("Anthropic Messages chronological system updates cannot be consecutive")
|
if (message.role === "tool" && part.type === "tool-result") pending.delete(part.id)
|
||||||
if (next && next.role !== "assistant")
|
}
|
||||||
return yield* invalid(
|
}
|
||||||
"Anthropic Messages chronological system updates must end the messages array or immediately precede an assistant message",
|
return pending.size > 0
|
||||||
)
|
}
|
||||||
})
|
|
||||||
|
|
||||||
const lowerNativeSystemUpdate = Effect.fn("AnthropicMessages.lowerNativeSystemUpdate")(function* (
|
const lowerNativeSystemUpdate = Effect.fn("AnthropicMessages.lowerNativeSystemUpdate")(function* (
|
||||||
message: LLMRequest["messages"][number],
|
message: LLMRequest["messages"][number],
|
||||||
@ -407,8 +398,9 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
|||||||
|
|
||||||
for (const [index, message] of request.messages.entries()) {
|
for (const [index, message] of request.messages.entries()) {
|
||||||
if (message.role === "system") {
|
if (message.role === "system") {
|
||||||
if (supportsNativeSystemUpdates(request)) {
|
if (splitsLocalToolResults(request.messages, index))
|
||||||
yield* validateNativeSystemUpdate(request.messages, index)
|
return yield* invalid("Anthropic Messages system updates cannot split a local tool call from its tool result")
|
||||||
|
if (supportsNativeSystemUpdates(request) && canUseNativeSystemUpdate(request.messages, index)) {
|
||||||
messages.push(yield* lowerNativeSystemUpdate(message, breakpoints))
|
messages.push(yield* lowerNativeSystemUpdate(message, breakpoints))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@ -125,26 +125,65 @@ describe("Anthropic Messages route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("rejects invalid native chronological system update placement", () =>
|
it.effect("falls back for unsupported native chronological system update placement", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const placementError = (messages: Parameters<typeof LLM.request>[0]["messages"]) =>
|
expect(
|
||||||
LLMClient.prepare(LLM.request({ model: opus48, messages, cache: "none" })).pipe(Effect.flip)
|
(yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||||
|
LLM.request({
|
||||||
|
model: opus48,
|
||||||
|
messages: [Message.assistant("Plain."), Message.system("After plain assistant.")],
|
||||||
|
cache: "none",
|
||||||
|
}),
|
||||||
|
)).body.messages,
|
||||||
|
).toEqual([
|
||||||
|
{ role: "assistant", content: [{ type: "text", text: "Plain." }] },
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [{ type: "text", text: "<system-update>\nAfter plain assistant.\n</system-update>" }],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(
|
||||||
|
(yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||||
|
LLM.request({ model: opus48, messages: [Message.system("First.")], cache: "none" }),
|
||||||
|
)).body.messages,
|
||||||
|
).toEqual([{ role: "user", content: [{ type: "text", text: "<system-update>\nFirst.\n</system-update>" }] }])
|
||||||
|
expect(
|
||||||
|
(yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||||
|
LLM.request({
|
||||||
|
model: opus48,
|
||||||
|
messages: [Message.user("Before."), Message.system("One."), Message.system("Two.")],
|
||||||
|
cache: "none",
|
||||||
|
}),
|
||||||
|
)).body.messages,
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "Before." },
|
||||||
|
{ type: "text", text: "<system-update>\nOne.\n</system-update>" },
|
||||||
|
{ type: "text", text: "<system-update>\nTwo.\n</system-update>" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
expect((yield* placementError([Message.system("First.")])).message).toContain("cannot be the first message")
|
it.effect("rejects a system update between a local tool call and its result", () =>
|
||||||
expect(
|
Effect.gen(function* () {
|
||||||
(yield* placementError([Message.user("Before."), Message.system("One."), Message.system("Two.")])).message,
|
const error = yield* LLMClient.prepare(
|
||||||
).toContain("cannot be consecutive")
|
LLM.request({
|
||||||
expect(
|
model: opus48,
|
||||||
(yield* placementError([Message.assistant("Plain."), Message.system("After plain assistant.")])).message,
|
messages: [
|
||||||
).toContain("must follow a user message, tool result, or assistant server tool use")
|
Message.user("Use the tool."),
|
||||||
expect(
|
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
|
||||||
(yield* placementError([
|
Message.system("Too early."),
|
||||||
Message.user("Use the tool."),
|
Message.tool({ id: "call_1", name: "lookup", result: "Done." }),
|
||||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
|
],
|
||||||
Message.system("Too early."),
|
cache: "none",
|
||||||
Message.tool({ id: "call_1", name: "lookup", result: "Done." }),
|
}),
|
||||||
])).message,
|
).pipe(Effect.flip)
|
||||||
).toContain("cannot appear between a local tool call and its tool result")
|
|
||||||
|
expect(error.message).toContain("system updates cannot split a local tool call from its tool result")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -123,7 +123,11 @@ describeRecordedGoldenScenarios([
|
|||||||
prefix: "gemini",
|
prefix: "gemini",
|
||||||
model: gemini,
|
model: gemini,
|
||||||
requires: ["GOOGLE_GENERATIVE_AI_API_KEY"],
|
requires: ["GOOGLE_GENERATIVE_AI_API_KEY"],
|
||||||
scenarios: [{ id: "text", maxTokens: 80 }, "tool-call", { id: "image", maxTokens: 160 }],
|
scenarios: [
|
||||||
|
{ id: "text", maxTokens: 80 },
|
||||||
|
"tool-call",
|
||||||
|
{ id: "image", maxTokens: 160 },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "xAI Grok 3 Mini",
|
name: "xAI Grok 3 Mini",
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { expect } from "bun:test"
|
import { expect } from "bun:test"
|
||||||
import { Effect, Schema, Stream } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import {
|
import {
|
||||||
LLM,
|
LLM,
|
||||||
LLMEvent,
|
LLMEvent,
|
||||||
|
|||||||
@ -2,7 +2,15 @@ import { describe, expect, test } from "bun:test"
|
|||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import * as OpenAIChat from "../src/protocols/openai-chat"
|
import * as OpenAIChat from "../src/protocols/openai-chat"
|
||||||
import * as OpenAIResponses from "../src/protocols/openai-responses"
|
import * as OpenAIResponses from "../src/protocols/openai-responses"
|
||||||
import { ContentPart, LLMEvent, LLMRequest, Model, ModelID, ProviderID, Usage } from "../src/schema"
|
import {
|
||||||
|
ContentPart,
|
||||||
|
LLMEvent,
|
||||||
|
LLMRequest,
|
||||||
|
Model,
|
||||||
|
ModelID,
|
||||||
|
ProviderID,
|
||||||
|
Usage,
|
||||||
|
} from "../src/schema"
|
||||||
import { ProviderShared } from "../src/protocols/shared"
|
import { ProviderShared } from "../src/protocols/shared"
|
||||||
|
|
||||||
const model = new Model({
|
const model = new Model({
|
||||||
@ -43,17 +51,6 @@ describe("llm schema", () => {
|
|||||||
expect(decoded.model.route.id).toBe("openai-responses")
|
expect(decoded.model.route.id).toBe("openai-responses")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("decodes chronological system messages", () => {
|
|
||||||
const decoded = decodeLLMRequest({
|
|
||||||
model,
|
|
||||||
system: [],
|
|
||||||
messages: [{ role: "system", content: [{ type: "text", text: "Operator update." }] }],
|
|
||||||
tools: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(decoded.messages[0]).toMatchObject({ role: "system", content: [{ type: "text", text: "Operator update." }] })
|
|
||||||
})
|
|
||||||
|
|
||||||
test("rejects invalid event type", () => {
|
test("rejects invalid event type", () => {
|
||||||
expect(() => decodeLLMEvent({ type: "bogus" })).toThrow()
|
expect(() => decodeLLMEvent({ type: "bogus" })).toThrow()
|
||||||
})
|
})
|
||||||
|
|||||||
@ -175,6 +175,16 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
|
case "session.next.context.updated":
|
||||||
|
update(event.properties.sessionID, (draft) => {
|
||||||
|
prepend(draft, {
|
||||||
|
id: event.properties.messageID,
|
||||||
|
type: "system",
|
||||||
|
text: event.properties.text,
|
||||||
|
time: { created: event.properties.timestamp },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
break
|
||||||
case "session.next.synthetic":
|
case "session.next.synthetic":
|
||||||
update(event.properties.sessionID, (draft) => {
|
update(event.properties.sessionID, (draft) => {
|
||||||
prepend(draft, {
|
prepend(draft, {
|
||||||
|
|||||||
@ -104,6 +104,9 @@ function View(props: { api: TuiPluginApi; sessionID: string }) {
|
|||||||
<Match when={message.type === "synthetic"}>
|
<Match when={message.type === "synthetic"}>
|
||||||
<></>
|
<></>
|
||||||
</Match>
|
</Match>
|
||||||
|
<Match when={message.type === "system"}>
|
||||||
|
<></>
|
||||||
|
</Match>
|
||||||
<Match when={message.type === "shell"}>
|
<Match when={message.type === "shell"}>
|
||||||
<ShellMessage message={message as SessionMessageShell} />
|
<ShellMessage message={message as SessionMessageShell} />
|
||||||
</Match>
|
</Match>
|
||||||
|
|||||||
@ -261,6 +261,55 @@ test("sync v2 renders a promoted prompt when admission was missed", async () =>
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("sync v2 projects live context updates with their message ID", async () => {
|
||||||
|
const events = createEventSource()
|
||||||
|
const calls = createFetch()
|
||||||
|
let sync!: ReturnType<typeof useSyncV2>
|
||||||
|
let ready!: () => void
|
||||||
|
const mounted = new Promise<void>((resolve) => {
|
||||||
|
ready = resolve
|
||||||
|
})
|
||||||
|
|
||||||
|
function Probe() {
|
||||||
|
sync = useSyncV2()
|
||||||
|
onMount(ready)
|
||||||
|
return <box />
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await testRender(() => (
|
||||||
|
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||||
|
<ProjectProvider>
|
||||||
|
<SyncProviderV2>
|
||||||
|
<Probe />
|
||||||
|
</SyncProviderV2>
|
||||||
|
</ProjectProvider>
|
||||||
|
</SDKProvider>
|
||||||
|
))
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mounted
|
||||||
|
emitTwice(events, {
|
||||||
|
id: "evt_context_1",
|
||||||
|
type: "session.next.context.updated",
|
||||||
|
properties: {
|
||||||
|
sessionID: "session-1",
|
||||||
|
messageID: "msg_context_1",
|
||||||
|
timestamp: 1,
|
||||||
|
text: "Updated context",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await wait(() => sync.session.message.fromSession("session-1").length === 1)
|
||||||
|
expect(sync.session.message.fromSession("session-1")[0]).toMatchObject({
|
||||||
|
id: "msg_context_1",
|
||||||
|
type: "system",
|
||||||
|
text: "Updated context",
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("sync v2 preserves live events while snapshot hydration is in flight", async () => {
|
test("sync v2 preserves live events while snapshot hydration is in flight", async () => {
|
||||||
const events = createEventSource()
|
const events = createEventSource()
|
||||||
const response = Promise.withResolvers<Response>()
|
const response = Promise.withResolvers<Response>()
|
||||||
|
|||||||
@ -21,6 +21,7 @@ export type Event =
|
|||||||
| EventSessionNextPrompted
|
| EventSessionNextPrompted
|
||||||
| EventSessionNextPromptAdmitted
|
| EventSessionNextPromptAdmitted
|
||||||
| EventSessionNextPromptPromoted
|
| EventSessionNextPromptPromoted
|
||||||
|
| EventSessionNextContextUpdated
|
||||||
| EventSessionNextSynthetic
|
| EventSessionNextSynthetic
|
||||||
| EventSessionNextShellStarted
|
| EventSessionNextShellStarted
|
||||||
| EventSessionNextShellEnded
|
| EventSessionNextShellEnded
|
||||||
@ -867,6 +868,16 @@ export type GlobalEvent = {
|
|||||||
timeCreated: number
|
timeCreated: number
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
id: string
|
||||||
|
type: "session.next.context.updated"
|
||||||
|
properties: {
|
||||||
|
timestamp: number
|
||||||
|
sessionID: string
|
||||||
|
messageID: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
id: string
|
id: string
|
||||||
type: "session.next.synthetic"
|
type: "session.next.synthetic"
|
||||||
@ -1615,6 +1626,7 @@ export type GlobalEvent = {
|
|||||||
| SyncEventSessionNextPrompted
|
| SyncEventSessionNextPrompted
|
||||||
| SyncEventSessionNextPromptAdmitted
|
| SyncEventSessionNextPromptAdmitted
|
||||||
| SyncEventSessionNextPromptPromoted
|
| SyncEventSessionNextPromptPromoted
|
||||||
|
| SyncEventSessionNextContextUpdated
|
||||||
| SyncEventSessionNextSynthetic
|
| SyncEventSessionNextSynthetic
|
||||||
| SyncEventSessionNextShellStarted
|
| SyncEventSessionNextShellStarted
|
||||||
| SyncEventSessionNextShellEnded
|
| SyncEventSessionNextShellEnded
|
||||||
@ -3259,6 +3271,23 @@ export type SyncEventSessionNextPromptPromoted = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SyncEventSessionNextContextUpdated = {
|
||||||
|
type: "sync"
|
||||||
|
id: string
|
||||||
|
syncEvent: {
|
||||||
|
type: "session.next.context.updated.1"
|
||||||
|
id: string
|
||||||
|
seq: number
|
||||||
|
aggregateID: string
|
||||||
|
data: {
|
||||||
|
timestamp: number
|
||||||
|
sessionID: string
|
||||||
|
messageID: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type SyncEventSessionNextSynthetic = {
|
export type SyncEventSessionNextSynthetic = {
|
||||||
type: "sync"
|
type: "sync"
|
||||||
id: string
|
id: string
|
||||||
@ -3822,6 +3851,18 @@ export type SessionMessageSynthetic = {
|
|||||||
type: "synthetic"
|
type: "synthetic"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SessionMessageSystem = {
|
||||||
|
id: string
|
||||||
|
metadata?: {
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
time: {
|
||||||
|
created: number
|
||||||
|
}
|
||||||
|
type: "system"
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
export type SessionMessageShell = {
|
export type SessionMessageShell = {
|
||||||
id: string
|
id: string
|
||||||
metadata?: {
|
metadata?: {
|
||||||
@ -3980,6 +4021,7 @@ export type SessionMessage =
|
|||||||
| SessionMessageModelSwitched
|
| SessionMessageModelSwitched
|
||||||
| SessionMessageUser
|
| SessionMessageUser
|
||||||
| SessionMessageSynthetic
|
| SessionMessageSynthetic
|
||||||
|
| SessionMessageSystem
|
||||||
| SessionMessageShell
|
| SessionMessageShell
|
||||||
| SessionMessageAssistant
|
| SessionMessageAssistant
|
||||||
| SessionMessageCompaction
|
| SessionMessageCompaction
|
||||||
@ -4339,6 +4381,17 @@ export type EventSessionNextPromptPromoted = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type EventSessionNextContextUpdated = {
|
||||||
|
id: string
|
||||||
|
type: "session.next.context.updated"
|
||||||
|
properties: {
|
||||||
|
timestamp: number
|
||||||
|
sessionID: string
|
||||||
|
messageID: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type EventSessionNextSynthetic = {
|
export type EventSessionNextSynthetic = {
|
||||||
id: string
|
id: string
|
||||||
type: "session.next.synthetic"
|
type: "session.next.synthetic"
|
||||||
|
|||||||
@ -12026,6 +12026,9 @@
|
|||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/EventSessionNextPromptPromoted"
|
"$ref": "#/components/schemas/EventSessionNextPromptPromoted"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/EventSessionNextContextUpdated"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/EventSessionNextSynthetic"
|
"$ref": "#/components/schemas/EventSessionNextSynthetic"
|
||||||
},
|
},
|
||||||
@ -14621,6 +14624,42 @@
|
|||||||
"required": ["id", "type", "properties"],
|
"required": ["id", "type", "properties"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^evt_"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["session.next.context.updated"]
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"timestamp": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"sessionID": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^ses"
|
||||||
|
},
|
||||||
|
"messageID": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^msg_"
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["timestamp", "sessionID", "messageID", "text"],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["id", "type", "properties"],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@ -17142,6 +17181,9 @@
|
|||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/SyncEventSessionNextPromptPromoted"
|
"$ref": "#/components/schemas/SyncEventSessionNextPromptPromoted"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/SyncEventSessionNextContextUpdated"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/SyncEventSessionNextSynthetic"
|
"$ref": "#/components/schemas/SyncEventSessionNextSynthetic"
|
||||||
},
|
},
|
||||||
@ -21870,6 +21912,63 @@
|
|||||||
"required": ["type", "id", "syncEvent"],
|
"required": ["type", "id", "syncEvent"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
|
"SyncEventSessionNextContextUpdated": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["sync"]
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^evt_"
|
||||||
|
},
|
||||||
|
"syncEvent": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["session.next.context.updated.1"]
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^evt_"
|
||||||
|
},
|
||||||
|
"seq": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"aggregateID": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"timestamp": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"sessionID": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^ses"
|
||||||
|
},
|
||||||
|
"messageID": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^msg_"
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["timestamp", "sessionID", "messageID", "text"],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["type", "id", "seq", "aggregateID", "data"],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["type", "id", "syncEvent"],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
"SyncEventSessionNextSynthetic": {
|
"SyncEventSessionNextSynthetic": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@ -23634,6 +23733,37 @@
|
|||||||
"required": ["id", "time", "sessionID", "text", "type"],
|
"required": ["id", "time", "sessionID", "text", "type"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
|
"SessionMessageSystem": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^msg_"
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"time": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"created": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["created"],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["system"]
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["id", "time", "type", "text"],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
"SessionMessageShell": {
|
"SessionMessageShell": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@ -24071,6 +24201,9 @@
|
|||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/SessionMessageSynthetic"
|
"$ref": "#/components/schemas/SessionMessageSynthetic"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/SessionMessageSystem"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/SessionMessageShell"
|
"$ref": "#/components/schemas/SessionMessageShell"
|
||||||
},
|
},
|
||||||
@ -25174,6 +25307,42 @@
|
|||||||
"required": ["id", "type", "properties"],
|
"required": ["id", "type", "properties"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
|
"EventSessionNextContextUpdated": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^evt_"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["session.next.context.updated"]
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"timestamp": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"sessionID": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^ses"
|
||||||
|
},
|
||||||
|
"messageID": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^msg_"
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["timestamp", "sessionID", "messageID", "text"],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["id", "type", "properties"],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
"EventSessionNextSynthetic": {
|
"EventSessionNextSynthetic": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@ -686,3 +686,85 @@ Compatibility:
|
|||||||
|
|
||||||
- Foreground V2 bash execution is unchanged.
|
- Foreground V2 bash execution is unchanged.
|
||||||
- Reintroduce background bash only with durable status observation, completion delivery, and explicit cancellation semantics.
|
- Reintroduce background bash only with durable status observation, completion delivery, and explicit cancellation semantics.
|
||||||
|
|
||||||
|
## 2026-06-04: Add Durable Session Context Snapshots
|
||||||
|
|
||||||
|
Affected schema:
|
||||||
|
|
||||||
|
- Add `session_context_epoch` for one active immutable baseline string, structured JSON snapshot, and baseline sequence per Session.
|
||||||
|
|
||||||
|
Change:
|
||||||
|
|
||||||
|
- Lazily initialize one durable Context Epoch snapshot at the first safe provider-turn boundary.
|
||||||
|
- Lower its exact baseline string through `LLMRequest.system` for every provider turn in the epoch.
|
||||||
|
- Reuse the stored baseline verbatim after restart or producer changes instead of resampling privileged initial context.
|
||||||
|
- Compare later observations against an overwriteable codec-encoded structured snapshot rather than rendered-text hashes.
|
||||||
|
- Expose admitted chronological context as first-class `system` Session messages while keeping the active baseline in bounded context state.
|
||||||
|
|
||||||
|
Compatibility:
|
||||||
|
|
||||||
|
- The unpublished Context Epoch schema is consolidated into one database migration; baseline and structured snapshots are operational state rather than synchronized event history.
|
||||||
|
- Existing experimental V2 Session databases remain disposable across incompatible pre-launch event-schema changes.
|
||||||
|
- Chronological context updates, replacement epochs after compaction or model switches, project instructions, skills guidance, and plugin transforms remain follow-up slices.
|
||||||
|
|
||||||
|
## 2026-06-04: Admit Chronological Session Context Updates
|
||||||
|
|
||||||
|
Affected schema:
|
||||||
|
|
||||||
|
- Add synchronized `session.next.context.updated.1` Session events containing a durable System-message ID and only exact combined model-visible text.
|
||||||
|
- Add `session_context_epoch.revision` for transactional structured-snapshot advancement.
|
||||||
|
- Add the first-class `system` Session message projection for chronological context updates.
|
||||||
|
|
||||||
|
Change:
|
||||||
|
|
||||||
|
- Reconcile Location-scoped Context Sources at each safe provider-turn boundary using one coherent observation.
|
||||||
|
- Keep the stored baseline immutable while admitting changed source renderings as chronological `Message.system(...)` history.
|
||||||
|
- Advance the overwriteable structured snapshot atomically with the rendered System-message event.
|
||||||
|
- Emit the previously stored model-meaningful removal rendering when a source is removed.
|
||||||
|
- Reject chronological system updates that would split a local tool call from its result across provider protocols; use wrapped user fallback when Anthropic native system-update placement is unsupported.
|
||||||
|
|
||||||
|
Compatibility:
|
||||||
|
|
||||||
|
- The synchronized event log retains only text actually shown to the model, not internal structured snapshots.
|
||||||
|
- Existing experimental V2 Session databases remain disposable across incompatible pre-launch event-schema changes.
|
||||||
|
- Replacement epochs after compaction or model switches, skills guidance, and plugin-defined context remain follow-up slices.
|
||||||
|
|
||||||
|
## 2026-06-04: Replace Session Context Epochs Lazily
|
||||||
|
|
||||||
|
Affected schema:
|
||||||
|
|
||||||
|
- Add nullable `session_context_epoch.replacement_seq` for idempotent lazy replacement requests.
|
||||||
|
|
||||||
|
Change:
|
||||||
|
|
||||||
|
- Mark the active Context Epoch for replacement after a model switch or completed compaction projection.
|
||||||
|
- Persist the triggering aggregate sequence so same-target replay cannot reopen an already-settled replacement.
|
||||||
|
- Render and overwrite the fresh immutable baseline and structured snapshot lazily at the next safe provider-turn boundary.
|
||||||
|
- Exclude chronological System messages from earlier epochs when assembling active provider history.
|
||||||
|
|
||||||
|
Compatibility:
|
||||||
|
|
||||||
|
- Baseline replacement is bounded operational state and does not add permanent synchronized events.
|
||||||
|
- Existing experimental V2 Session databases remain disposable across incompatible pre-launch event-schema changes.
|
||||||
|
- Compaction execution, skills guidance, and plugin-defined context remain follow-up slices.
|
||||||
|
|
||||||
|
## 2026-06-05: Register Ambient System Context Producers
|
||||||
|
|
||||||
|
Affected schema:
|
||||||
|
|
||||||
|
- No database schema changes.
|
||||||
|
|
||||||
|
Change:
|
||||||
|
|
||||||
|
- Replace the Session-specific context loader with a Location-scoped registry of stable-keyed scoped context producers.
|
||||||
|
- Register environment/date and ambient instruction producers independently, then evaluate producers concurrently in stable contribution-key order.
|
||||||
|
- Directly discover and read global plus upward project `AGENTS.md` files at each safe provider-turn boundary.
|
||||||
|
- Preserve admitted instructions across transient scan/read failures and block first-epoch initialization while any context source is unavailable.
|
||||||
|
- Retry Context Epoch preparation until stable after optimistic revision mismatches.
|
||||||
|
- Clear the active Context Epoch when a Session moves so the destination initializes a complete baseline before promoting more input.
|
||||||
|
- Fence Context Epoch initialization against the authoritative Session Location so a concurrent old-Location runner cannot recreate stale privileged context after a move.
|
||||||
|
- Canonicalize ambient instruction traversal boundaries, honor `OPENCODE_DISABLE_PROJECT_CONFIG`, and make non-empty aggregate updates explicitly supersede previously loaded instructions.
|
||||||
|
|
||||||
|
Compatibility:
|
||||||
|
|
||||||
|
- Watcher-backed per-file `Refreshable` instruction observations, configured sources, nested discovery, and plugin-defined context remain follow-up slices.
|
||||||
|
|||||||
@ -37,6 +37,66 @@ The local runner issues one explicit `llm.stream(request)` per provider turn, pr
|
|||||||
|
|
||||||
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.
|
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
|
||||||
|
|
||||||
|
V2 Sessions persist the exact privileged System Context shown to the model. A Context Epoch owns one immutable baseline plus a model-hidden structured snapshot used to compare independently observed Context Sources. Environment facts, the host-local date, and ambient global/upward-project `AGENTS.md` files are the initial registered sources.
|
||||||
|
|
||||||
|
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 provider turns, 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.
|
||||||
|
|
||||||
|
```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 ─────────────────────────────────────────────────────────────────────────▶
|
||||||
|
```
|
||||||
|
|
||||||
|
Model switches and completed compactions request lazy baseline replacement. A Session move clears the epoch so the destination Location must initialize a complete baseline before promoting more input. Epoch creation is fenced against the authoritative Session Location, preventing an old-Location runner from recreating stale privileged context after a concurrent move.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Session Epoch
|
||||||
|
│ │
|
||||||
|
├─ initialize complete baseline ──▶
|
||||||
|
│ │
|
||||||
|
│ ├─────────────────────────────────╮
|
||||||
|
│ │ reconcile chronological update │
|
||||||
|
│ ◀─────────────────────────────────╯
|
||||||
|
│ │
|
||||||
|
├─ request replacement ───────────▶
|
||||||
|
│ │
|
||||||
|
│ ├─────────────────────────────────────╮
|
||||||
|
│ │ replace after complete observation │
|
||||||
|
│ ◀─────────────────────────────────────╯
|
||||||
|
│ │
|
||||||
|
├─ clear after Location move ─────▶
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Current Context Epoch follow-ups:
|
||||||
|
|
||||||
|
- Add configured, remote, and nested instruction sources with explicit precedence and removal semantics.
|
||||||
|
- Add durable post-crash activity recovery for promoted or provider-dispatched work.
|
||||||
|
- Integrate actual automatic/context-pressure compaction with epoch replacement.
|
||||||
|
- 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.
|
||||||
|
- Add clustered Session execution ownership and stale-runtime fencing.
|
||||||
|
|
||||||
Provider timeout, retry, and watchdog policy is intentionally deferred. The runner does not impose a universal provider-stream inactivity or absolute timeout. A future slice should design configurable policy around provider behavior, durable failure reporting, and local drain-chain release rather than hardcoding one default for every provider.
|
Provider timeout, retry, and watchdog policy is intentionally deferred. The runner does not impose a universal provider-stream inactivity or absolute timeout. A future slice should design configurable policy around provider behavior, durable failure reporting, and local drain-chain release rather than hardcoding one default for every provider.
|
||||||
|
|
||||||
Inbox delivery is explicit:
|
Inbox delivery is explicit:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user