fix(core): finalize v2 session context epochs
This commit is contained in:
parent
b28546a6a5
commit
cd812e2045
33 changed files with 1245 additions and 791 deletions
11
CONTEXT.md
11
CONTEXT.md
|
|
@ -46,22 +46,23 @@ The point immediately before a provider call, after durable input promotion and
|
||||||
- Changes from multiple **Context Sources** admitted at one safe boundary combine into one **Mid-Conversation System Message**.
|
- 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 Snapshot** 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.
|
||||||
|
- Initial **System Context** preparation precedes the first durable input promotion so an unavailable baseline leaves that input pending and retryable; ordinary reconciliation remains after promotion.
|
||||||
- Compaction starts a new **Context Epoch** with a freshly rendered **Baseline System Context** and **Context Snapshot**; prior **Mid-Conversation System Messages** remain durable audit history but leave projected model history.
|
- Compaction starts a new **Context Epoch** with a freshly rendered **Baseline System Context** and **Context Snapshot**; prior **Mid-Conversation System Messages** remain durable audit history but leave projected model history.
|
||||||
- A newly registered core or plugin-defined **Context Source** absent from the current snapshot emits its baseline rendering once at the next **Safe Provider-Turn Boundary**.
|
- 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**.
|
||||||
- **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.
|
- **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.
|
- 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.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, replaced, or replacement blocked.
|
- `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.
|
- `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 Source** 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.
|
||||||
- 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.
|
||||||
- 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**.
|
- 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, instruction, and plugin-defined context producers register through the **System Context Registry** with stable contribution keys so plugin hot reload and Location-scope cleanup add and remove sources predictably.
|
- 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 Session-message history; normal user-facing transcript surfaces may hide them.
|
- **Mid-Conversation System Messages** remain durable Session-message history; normal user-facing transcript surfaces may hide them.
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
{
|
{
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "sqlite",
|
"dialect": "sqlite",
|
||||||
"id": "65b52d0f-2bbe-483f-a7ec-9d2a7fa29f57",
|
"id": "40f7b9b8-83b4-4ea0-a59f-76a489679d88",
|
||||||
"prevIds": [
|
"prevIds": ["84c6ad6c-6116-48e1-b973-6fee4593496b"],
|
||||||
"fc92fa34-8074-44c3-88f0-a5417f7fd92d"
|
|
||||||
],
|
|
||||||
"ddl": [
|
"ddl": [
|
||||||
{
|
{
|
||||||
"name": "workspace",
|
"name": "workspace",
|
||||||
|
|
@ -838,19 +836,9 @@
|
||||||
"entityType": "columns",
|
"entityType": "columns",
|
||||||
"table": "session_context_epoch"
|
"table": "session_context_epoch"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"type": "integer",
|
|
||||||
"notNull": false,
|
|
||||||
"autoincrement": true,
|
|
||||||
"default": null,
|
|
||||||
"generated": null,
|
|
||||||
"name": "seq",
|
|
||||||
"entityType": "columns",
|
|
||||||
"table": "session_input"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"notNull": true,
|
"notNull": false,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
"default": null,
|
"default": null,
|
||||||
"generated": null,
|
"generated": null,
|
||||||
|
|
@ -888,6 +876,16 @@
|
||||||
"entityType": "columns",
|
"entityType": "columns",
|
||||||
"table": "session_input"
|
"table": "session_input"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"name": "admitted_seq",
|
||||||
|
"entityType": "columns",
|
||||||
|
"table": "session_input"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
|
|
@ -1399,13 +1397,9 @@
|
||||||
"table": "session_share"
|
"table": "session_share"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["project_id"],
|
||||||
"project_id"
|
|
||||||
],
|
|
||||||
"tableTo": "project",
|
"tableTo": "project",
|
||||||
"columnsTo": [
|
"columnsTo": ["id"],
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "CASCADE",
|
"onDelete": "CASCADE",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
|
|
@ -1414,13 +1408,9 @@
|
||||||
"table": "workspace"
|
"table": "workspace"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["active_account_id"],
|
||||||
"active_account_id"
|
|
||||||
],
|
|
||||||
"tableTo": "account",
|
"tableTo": "account",
|
||||||
"columnsTo": [
|
"columnsTo": ["id"],
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "SET NULL",
|
"onDelete": "SET NULL",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
|
|
@ -1429,13 +1419,9 @@
|
||||||
"table": "account_state"
|
"table": "account_state"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["aggregate_id"],
|
||||||
"aggregate_id"
|
|
||||||
],
|
|
||||||
"tableTo": "event_sequence",
|
"tableTo": "event_sequence",
|
||||||
"columnsTo": [
|
"columnsTo": ["aggregate_id"],
|
||||||
"aggregate_id"
|
|
||||||
],
|
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "CASCADE",
|
"onDelete": "CASCADE",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
|
|
@ -1444,13 +1430,9 @@
|
||||||
"table": "event"
|
"table": "event"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["project_id"],
|
||||||
"project_id"
|
|
||||||
],
|
|
||||||
"tableTo": "project",
|
"tableTo": "project",
|
||||||
"columnsTo": [
|
"columnsTo": ["id"],
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "CASCADE",
|
"onDelete": "CASCADE",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
|
|
@ -1459,13 +1441,9 @@
|
||||||
"table": "permission"
|
"table": "permission"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["project_id"],
|
||||||
"project_id"
|
|
||||||
],
|
|
||||||
"tableTo": "project",
|
"tableTo": "project",
|
||||||
"columnsTo": [
|
"columnsTo": ["id"],
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "CASCADE",
|
"onDelete": "CASCADE",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
|
|
@ -1474,13 +1452,9 @@
|
||||||
"table": "project_directory"
|
"table": "project_directory"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["session_id"],
|
||||||
"session_id"
|
|
||||||
],
|
|
||||||
"tableTo": "session",
|
"tableTo": "session",
|
||||||
"columnsTo": [
|
"columnsTo": ["id"],
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "CASCADE",
|
"onDelete": "CASCADE",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
|
|
@ -1489,13 +1463,9 @@
|
||||||
"table": "message"
|
"table": "message"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["message_id"],
|
||||||
"message_id"
|
|
||||||
],
|
|
||||||
"tableTo": "message",
|
"tableTo": "message",
|
||||||
"columnsTo": [
|
"columnsTo": ["id"],
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "CASCADE",
|
"onDelete": "CASCADE",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
|
|
@ -1504,13 +1474,9 @@
|
||||||
"table": "part"
|
"table": "part"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["session_id"],
|
||||||
"session_id"
|
|
||||||
],
|
|
||||||
"tableTo": "session",
|
"tableTo": "session",
|
||||||
"columnsTo": [
|
"columnsTo": ["id"],
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "CASCADE",
|
"onDelete": "CASCADE",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
|
|
@ -1519,13 +1485,9 @@
|
||||||
"table": "session_context_epoch"
|
"table": "session_context_epoch"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["session_id"],
|
||||||
"session_id"
|
|
||||||
],
|
|
||||||
"tableTo": "session",
|
"tableTo": "session",
|
||||||
"columnsTo": [
|
"columnsTo": ["id"],
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "CASCADE",
|
"onDelete": "CASCADE",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
|
|
@ -1534,13 +1496,9 @@
|
||||||
"table": "session_input"
|
"table": "session_input"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["session_id"],
|
||||||
"session_id"
|
|
||||||
],
|
|
||||||
"tableTo": "session",
|
"tableTo": "session",
|
||||||
"columnsTo": [
|
"columnsTo": ["id"],
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "CASCADE",
|
"onDelete": "CASCADE",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
|
|
@ -1549,13 +1507,9 @@
|
||||||
"table": "session_message"
|
"table": "session_message"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["project_id"],
|
||||||
"project_id"
|
|
||||||
],
|
|
||||||
"tableTo": "project",
|
"tableTo": "project",
|
||||||
"columnsTo": [
|
"columnsTo": ["id"],
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "CASCADE",
|
"onDelete": "CASCADE",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
|
|
@ -1564,13 +1518,9 @@
|
||||||
"table": "session"
|
"table": "session"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["session_id"],
|
||||||
"session_id"
|
|
||||||
],
|
|
||||||
"tableTo": "session",
|
"tableTo": "session",
|
||||||
"columnsTo": [
|
"columnsTo": ["id"],
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "CASCADE",
|
"onDelete": "CASCADE",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
|
|
@ -1579,13 +1529,9 @@
|
||||||
"table": "todo"
|
"table": "todo"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["session_id"],
|
||||||
"session_id"
|
|
||||||
],
|
|
||||||
"tableTo": "session",
|
"tableTo": "session",
|
||||||
"columnsTo": [
|
"columnsTo": ["id"],
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onUpdate": "NO ACTION",
|
"onUpdate": "NO ACTION",
|
||||||
"onDelete": "CASCADE",
|
"onDelete": "CASCADE",
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
|
|
@ -1594,165 +1540,126 @@
|
||||||
"table": "session_share"
|
"table": "session_share"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["email", "url"],
|
||||||
"email",
|
|
||||||
"url"
|
|
||||||
],
|
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "control_account_pk",
|
"name": "control_account_pk",
|
||||||
"entityType": "pks",
|
"entityType": "pks",
|
||||||
"table": "control_account"
|
"table": "control_account"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["project_id", "directory"],
|
||||||
"project_id",
|
|
||||||
"directory"
|
|
||||||
],
|
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "project_directory_pk",
|
"name": "project_directory_pk",
|
||||||
"entityType": "pks",
|
"entityType": "pks",
|
||||||
"table": "project_directory"
|
"table": "project_directory"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["session_id", "position"],
|
||||||
"session_id",
|
|
||||||
"position"
|
|
||||||
],
|
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "todo_pk",
|
"name": "todo_pk",
|
||||||
"entityType": "pks",
|
"entityType": "pks",
|
||||||
"table": "todo"
|
"table": "todo"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["id"],
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "workspace_pk",
|
"name": "workspace_pk",
|
||||||
"table": "workspace",
|
"table": "workspace",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["name"],
|
||||||
"name"
|
|
||||||
],
|
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "data_migration_pk",
|
"name": "data_migration_pk",
|
||||||
"table": "data_migration",
|
"table": "data_migration",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["id"],
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "account_state_pk",
|
"name": "account_state_pk",
|
||||||
"table": "account_state",
|
"table": "account_state",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["id"],
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "account_pk",
|
"name": "account_pk",
|
||||||
"table": "account",
|
"table": "account",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["aggregate_id"],
|
||||||
"aggregate_id"
|
|
||||||
],
|
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "event_sequence_pk",
|
"name": "event_sequence_pk",
|
||||||
"table": "event_sequence",
|
"table": "event_sequence",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["id"],
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "event_pk",
|
"name": "event_pk",
|
||||||
"table": "event",
|
"table": "event",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["id"],
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "permission_pk",
|
"name": "permission_pk",
|
||||||
"table": "permission",
|
"table": "permission",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["id"],
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "project_pk",
|
"name": "project_pk",
|
||||||
"table": "project",
|
"table": "project",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["id"],
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "message_pk",
|
"name": "message_pk",
|
||||||
"table": "message",
|
"table": "message",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["id"],
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "part_pk",
|
"name": "part_pk",
|
||||||
"table": "part",
|
"table": "part",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["session_id"],
|
||||||
"session_id"
|
|
||||||
],
|
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "session_context_epoch_pk",
|
"name": "session_context_epoch_pk",
|
||||||
"table": "session_context_epoch",
|
"table": "session_context_epoch",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["id"],
|
||||||
"seq"
|
|
||||||
],
|
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "session_input_pk",
|
"name": "session_input_pk",
|
||||||
"table": "session_input",
|
"table": "session_input",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["id"],
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "session_message_pk",
|
"name": "session_message_pk",
|
||||||
"table": "session_message",
|
"table": "session_message",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["id"],
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "session_pk",
|
"name": "session_pk",
|
||||||
"table": "session",
|
"table": "session",
|
||||||
"entityType": "pks"
|
"entityType": "pks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": ["session_id"],
|
||||||
"session_id"
|
|
||||||
],
|
|
||||||
"nameExplicit": false,
|
"nameExplicit": false,
|
||||||
"name": "session_share_pk",
|
"name": "session_share_pk",
|
||||||
"table": "session_share",
|
"table": "session_share",
|
||||||
|
|
@ -1769,7 +1676,7 @@
|
||||||
"isExpression": false
|
"isExpression": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"isUnique": false,
|
"isUnique": true,
|
||||||
"where": null,
|
"where": null,
|
||||||
"origin": "manual",
|
"origin": "manual",
|
||||||
"name": "event_aggregate_seq_idx",
|
"name": "event_aggregate_seq_idx",
|
||||||
|
|
@ -1889,7 +1796,7 @@
|
||||||
"isExpression": false
|
"isExpression": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"value": "seq",
|
"value": "admitted_seq",
|
||||||
"isExpression": false
|
"isExpression": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
@ -1900,6 +1807,42 @@
|
||||||
"entityType": "indexes",
|
"entityType": "indexes",
|
||||||
"table": "session_input"
|
"table": "session_input"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"value": "session_id",
|
||||||
|
"isExpression": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "admitted_seq",
|
||||||
|
"isExpression": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"where": null,
|
||||||
|
"origin": "manual",
|
||||||
|
"name": "session_input_session_admitted_seq_idx",
|
||||||
|
"entityType": "indexes",
|
||||||
|
"table": "session_input"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"value": "session_id",
|
||||||
|
"isExpression": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "promoted_seq",
|
||||||
|
"isExpression": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"where": null,
|
||||||
|
"origin": "manual",
|
||||||
|
"name": "session_input_session_promoted_seq_idx",
|
||||||
|
"entityType": "indexes",
|
||||||
|
"table": "session_input"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
|
|
@ -1911,7 +1854,7 @@
|
||||||
"isExpression": false
|
"isExpression": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"isUnique": false,
|
"isUnique": true,
|
||||||
"where": null,
|
"where": null,
|
||||||
"origin": "manual",
|
"origin": "manual",
|
||||||
"name": "session_message_session_seq_idx",
|
"name": "session_message_session_seq_idx",
|
||||||
|
|
@ -2031,15 +1974,6 @@
|
||||||
"name": "todo_session_idx",
|
"name": "todo_session_idx",
|
||||||
"entityType": "indexes",
|
"entityType": "indexes",
|
||||||
"table": "todo"
|
"table": "todo"
|
||||||
},
|
|
||||||
{
|
|
||||||
"columns": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"nameExplicit": false,
|
|
||||||
"name": "session_input_id_unique",
|
|
||||||
"entityType": "uniques",
|
|
||||||
"table": "session_input"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"renames": []
|
"renames": []
|
||||||
2
packages/core/src/database/migration.gen.ts
generated
2
packages/core/src/database/migration.gen.ts
generated
|
|
@ -32,6 +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/20260604234609_add_session_context_snapshot"),
|
import("./migration/20260605003541_add_session_context_snapshot"),
|
||||||
])
|
])
|
||||||
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { Effect } from "effect"
|
||||||
import type { DatabaseMigration } from "../migration"
|
import type { DatabaseMigration } from "../migration"
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
id: "20260604234609_add_session_context_snapshot",
|
id: "20260605003541_add_session_context_snapshot",
|
||||||
up(tx) {
|
up(tx) {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
yield* tx.run(`
|
yield* tx.run(`
|
||||||
|
|
@ -155,7 +155,6 @@ export interface Interface {
|
||||||
readonly aggregateID: string
|
readonly aggregateID: string
|
||||||
readonly after?: Cursor
|
readonly after?: Cursor
|
||||||
}) => Stream.Stream<CursorEvent>
|
}) => Stream.Stream<CursorEvent>
|
||||||
readonly sequence: (aggregateID: string) => Effect.Effect<number>
|
|
||||||
readonly sync: (handler: Sync) => Effect.Effect<Unsubscribe>
|
readonly sync: (handler: Sync) => Effect.Effect<Unsubscribe>
|
||||||
readonly listen: (listener: Listener) => Effect.Effect<Unsubscribe>
|
readonly listen: (listener: Listener) => Effect.Effect<Unsubscribe>
|
||||||
readonly beforeCommit: (guard: CommitGuard) => Effect.Effect<void>
|
readonly beforeCommit: (guard: CommitGuard) => Effect.Effect<void>
|
||||||
|
|
@ -337,9 +336,6 @@ export const layerWith = (options?: LayerOptions) =>
|
||||||
yield* projector({ ...event, seq } as Payload)
|
yield* projector({ ...event, seq } as Payload)
|
||||||
}
|
}
|
||||||
if (commit) yield* commit(seq)
|
if (commit) yield* commit(seq)
|
||||||
const encoded = syncRegistry
|
|
||||||
.get(versionedType(definition.type, sync.version))!
|
|
||||||
.encode(event.data)
|
|
||||||
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 }])
|
||||||
|
|
@ -390,7 +386,10 @@ export const layerWith = (options?: LayerOptions) =>
|
||||||
const durable = registry.get(event.type)?.sync !== undefined
|
const durable = registry.get(event.type)?.sync !== undefined
|
||||||
if (!durable && options?.commit)
|
if (!durable && options?.commit)
|
||||||
return yield* Effect.die(
|
return yield* Effect.die(
|
||||||
new InvalidSyncEventError({ type: event.type, message: "Local commit hooks require a synchronized event" }),
|
new InvalidSyncEventError({
|
||||||
|
type: event.type,
|
||||||
|
message: "Local commit hooks require a synchronized event",
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
if (durable) {
|
if (durable) {
|
||||||
const committed = yield* commitSyncEvent(event as Payload, undefined, options?.commit)
|
const committed = yield* commitSyncEvent(event as Payload, undefined, options?.commit)
|
||||||
|
|
@ -438,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>, options)
|
data,
|
||||||
|
} as Payload<D>,
|
||||||
|
options,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -534,14 +536,6 @@ export const layerWith = (options?: LayerOptions) =>
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
}
|
}
|
||||||
|
|
||||||
const sequence = (aggregateID: string) =>
|
|
||||||
db
|
|
||||||
.select({ seq: EventSequenceTable.seq })
|
|
||||||
.from(EventSequenceTable)
|
|
||||||
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
|
|
||||||
.get()
|
|
||||||
.pipe(Effect.orDie, Effect.map((row) => row?.seq ?? -1))
|
|
||||||
|
|
||||||
const subscribe = <D extends Definition>(definition: D): Stream.Stream<Payload<D>> =>
|
const subscribe = <D extends Definition>(definition: D): Stream.Stream<Payload<D>> =>
|
||||||
Stream.unwrap(getOrCreate(definition).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub)))).pipe(
|
Stream.unwrap(getOrCreate(definition).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub)))).pipe(
|
||||||
Stream.map((event) => event as Payload<D>),
|
Stream.map((event) => event as Payload<D>),
|
||||||
|
|
@ -669,7 +663,6 @@ export const layerWith = (options?: LayerOptions) =>
|
||||||
subscribe,
|
subscribe,
|
||||||
all: streamAll,
|
all: streamAll,
|
||||||
aggregateEvents: streamEvents,
|
aggregateEvents: streamEvents,
|
||||||
sequence,
|
|
||||||
sync,
|
sync,
|
||||||
listen,
|
listen,
|
||||||
beforeCommit,
|
beforeCommit,
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ class File extends Schema.Class<File>("InstructionContext.File")({
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
const Files = Schema.Array(File)
|
const Files = Schema.Array(File)
|
||||||
|
const key = SystemContext.Key.make("core/instructions")
|
||||||
|
|
||||||
export const layer = Layer.effectDiscard(
|
export const layer = Layer.effectDiscard(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|
@ -25,7 +26,7 @@ export const layer = Layer.effectDiscard(
|
||||||
|
|
||||||
const source = (value: ReadonlyArray<File> | SystemContext.Unavailable) =>
|
const source = (value: ReadonlyArray<File> | SystemContext.Unavailable) =>
|
||||||
SystemContext.make({
|
SystemContext.make({
|
||||||
key: SystemContext.Key.make("core/instructions"),
|
key,
|
||||||
codec: Schema.toCodecJson(Files),
|
codec: Schema.toCodecJson(Files),
|
||||||
load: Effect.succeed(value),
|
load: Effect.succeed(value),
|
||||||
baseline: render,
|
baseline: render,
|
||||||
|
|
@ -43,29 +44,37 @@ export const layer = Layer.effectDiscard(
|
||||||
const files = yield* Effect.forEach(
|
const files = yield* Effect.forEach(
|
||||||
paths,
|
paths,
|
||||||
(path) =>
|
(path) =>
|
||||||
fs.readFileStringSafe(path).pipe(
|
fs
|
||||||
Effect.map((content) => (content === undefined ? undefined : new File({ path: AbsolutePath.make(path), content }))),
|
.readFileStringSafe(path)
|
||||||
),
|
.pipe(
|
||||||
|
Effect.map((content) =>
|
||||||
|
content === undefined ? undefined : new File({ path: AbsolutePath.make(path), content }),
|
||||||
|
),
|
||||||
|
),
|
||||||
{ concurrency: "unbounded" },
|
{ concurrency: "unbounded" },
|
||||||
)
|
)
|
||||||
if (files.some((file, index) => file === undefined && discovered.has(paths[index]))) return SystemContext.unavailable
|
if (files.some((file, index) => file === undefined && discovered.has(paths[index])))
|
||||||
|
return SystemContext.unavailable
|
||||||
return files.filter((file): file is File => file !== undefined)
|
return files.filter((file): file is File => file !== undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
yield* registry.contribute({
|
yield* registry.contribute({
|
||||||
key: "core/instructions",
|
key,
|
||||||
load: observe().pipe(
|
load: observe().pipe(
|
||||||
Effect.map((files) =>
|
Effect.map((files) =>
|
||||||
files === SystemContext.unavailable ? source(files) : files.length === 0 ? SystemContext.empty : source(files),
|
files === SystemContext.unavailable
|
||||||
|
? source(files)
|
||||||
|
: files.length === 0
|
||||||
|
? SystemContext.empty
|
||||||
|
: source(files),
|
||||||
),
|
),
|
||||||
Effect.catch(() => Effect.succeed(source(SystemContext.unavailable))),
|
Effect.catch(() => Effect.succeed(source(SystemContext.unavailable))),
|
||||||
|
Effect.catchDefect(() => Effect.succeed(source(SystemContext.unavailable))),
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const locationLayer = layer
|
|
||||||
|
|
||||||
function render(files: ReadonlyArray<File>) {
|
function render(files: ReadonlyArray<File>) {
|
||||||
return files.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n")
|
return files.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,51 @@ import { EventV2 } from "../event"
|
||||||
import { SystemContext } from "../system-context"
|
import { SystemContext } from "../system-context"
|
||||||
import { SystemContextRegistry } from "../system-context-registry"
|
import { SystemContextRegistry } from "../system-context-registry"
|
||||||
import { SessionEvent } from "./event"
|
import { SessionEvent } from "./event"
|
||||||
|
import { SessionInput } from "./input"
|
||||||
import { SessionMessageID } from "./message-id"
|
import { SessionMessageID } from "./message-id"
|
||||||
import { SessionSchema } from "./schema"
|
import { SessionSchema } from "./schema"
|
||||||
import { SessionContextEpochTable } from "./sql"
|
import { SessionContextEpochTable } from "./sql"
|
||||||
|
|
||||||
type DatabaseService = Database.Interface["db"]
|
type DatabaseService = Database.Interface["db"]
|
||||||
|
|
||||||
export const prepare = Effect.fn("SessionContextEpoch.prepare")(function* (
|
class RevisionMismatch 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,
|
||||||
|
): Effect.Effect<Prepared | undefined, SystemContext.InitializationBlocked> {
|
||||||
|
return retryRevisionMismatch(() => initializeOnce(db, context, sessionID)).pipe(
|
||||||
|
Effect.withSpan("SessionContextEpoch.initialize"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prepare(
|
||||||
|
db: DatabaseService,
|
||||||
|
events: EventV2.Interface,
|
||||||
|
context: SystemContextRegistry.Interface,
|
||||||
|
sessionID: SessionSchema.ID,
|
||||||
|
): Effect.Effect<Prepared, SystemContext.InitializationBlocked> {
|
||||||
|
return retryRevisionMismatch(() => prepareOnce(db, events, context, sessionID)).pipe(
|
||||||
|
Effect.withSpan("SessionContextEpoch.prepare"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const prepareOnce = Effect.fnUntraced(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
events: EventV2.Interface,
|
events: EventV2.Interface,
|
||||||
context: SystemContextRegistry.Interface,
|
context: SystemContextRegistry.Interface,
|
||||||
|
|
@ -22,17 +60,19 @@ export const prepare = Effect.fn("SessionContextEpoch.prepare")(function* (
|
||||||
const [value, stored] = yield* Effect.all([context.load(), find(db, sessionID)], { concurrency: "unbounded" })
|
const [value, stored] = yield* Effect.all([context.load(), find(db, sessionID)], { concurrency: "unbounded" })
|
||||||
if (!stored) {
|
if (!stored) {
|
||||||
const generation = yield* SystemContext.initialize(value)
|
const generation = yield* SystemContext.initialize(value)
|
||||||
const baselineSeq = yield* initialize(db, events, sessionID, generation)
|
const baselineSeq = yield* insert(db, sessionID, generation)
|
||||||
return { baseline: generation.baseline, baselineSeq }
|
return { baseline: generation.baseline, baselineSeq }
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshot = yield* Schema.decodeUnknownEffect(SystemContext.Snapshot)(stored.snapshot).pipe(Effect.orDie)
|
const snapshot = yield* Schema.decodeUnknownEffect(SystemContext.Snapshot)(stored.snapshot).pipe(Effect.orDie)
|
||||||
const result =
|
const result =
|
||||||
stored.replacement_seq === null ? yield* SystemContext.reconcile(value, snapshot) : yield* SystemContext.replace(value, snapshot)
|
stored.replacement_seq === null
|
||||||
|
? yield* SystemContext.reconcile(value, snapshot)
|
||||||
|
: yield* SystemContext.replace(value, snapshot)
|
||||||
if (result._tag === "Unchanged" || result._tag === "ReplacementBlocked")
|
if (result._tag === "Unchanged" || result._tag === "ReplacementBlocked")
|
||||||
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
|
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
|
||||||
if (result._tag === "Replaced") {
|
if (result._tag === "ReplacementReady") {
|
||||||
const replacementSeq = stored.replacement_seq ?? (yield* events.sequence(sessionID))
|
const replacementSeq = stored.replacement_seq ?? (yield* SessionInput.latestSeq(db, sessionID))
|
||||||
yield* replace(db, sessionID, stored.revision, replacementSeq, result.generation)
|
yield* replace(db, sessionID, stored.revision, replacementSeq, result.generation)
|
||||||
return { baseline: result.generation.baseline, baselineSeq: replacementSeq }
|
return { baseline: result.generation.baseline, baselineSeq: replacementSeq }
|
||||||
}
|
}
|
||||||
|
|
@ -45,6 +85,28 @@ export const prepare = Effect.fn("SessionContextEpoch.prepare")(function* (
|
||||||
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
|
return { baseline: stored.baseline, baselineSeq: stored.baseline_seq }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const initializeOnce = Effect.fnUntraced(function* (
|
||||||
|
db: DatabaseService,
|
||||||
|
context: SystemContextRegistry.Interface,
|
||||||
|
sessionID: SessionSchema.ID,
|
||||||
|
) {
|
||||||
|
if (yield* exists(db, sessionID)) return
|
||||||
|
const generation = yield* context.load().pipe(Effect.flatMap(SystemContext.initialize))
|
||||||
|
const baselineSeq = yield* insert(db, sessionID, 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) {
|
const find = Effect.fn("SessionContextEpoch.find")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||||
return yield* db
|
return yield* db
|
||||||
.select()
|
.select()
|
||||||
|
|
@ -73,9 +135,8 @@ export const requestReplacement = Effect.fn("SessionContextEpoch.requestReplacem
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
})
|
})
|
||||||
|
|
||||||
const initialize = Effect.fnUntraced(function* (
|
const insert = Effect.fnUntraced(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
events: EventV2.Interface,
|
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
generation: SystemContext.Generation,
|
generation: SystemContext.Generation,
|
||||||
) {
|
) {
|
||||||
|
|
@ -83,7 +144,7 @@ const initialize = Effect.fnUntraced(function* (
|
||||||
.transaction(
|
.transaction(
|
||||||
() =>
|
() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const baselineSeq = yield* events.sequence(sessionID)
|
const baselineSeq = yield* SessionInput.latestSeq(db, sessionID)
|
||||||
yield* db
|
yield* db
|
||||||
.insert(SessionContextEpochTable)
|
.insert(SessionContextEpochTable)
|
||||||
.values({
|
.values({
|
||||||
|
|
@ -93,8 +154,13 @@ const initialize = Effect.fnUntraced(function* (
|
||||||
baseline_seq: baselineSeq,
|
baseline_seq: baselineSeq,
|
||||||
revision: 0,
|
revision: 0,
|
||||||
})
|
})
|
||||||
.run()
|
.onConflictDoNothing()
|
||||||
.pipe(Effect.orDie)
|
.returning({ sessionID: SessionContextEpochTable.session_id })
|
||||||
|
.get()
|
||||||
|
.pipe(
|
||||||
|
Effect.orDie,
|
||||||
|
Effect.flatMap((inserted) => (inserted ? Effect.void : Effect.die(new RevisionMismatch()))),
|
||||||
|
)
|
||||||
return baselineSeq
|
return baselineSeq
|
||||||
}),
|
}),
|
||||||
{ behavior: "immediate" },
|
{ behavior: "immediate" },
|
||||||
|
|
@ -109,33 +175,22 @@ const replace = Effect.fnUntraced(function* (
|
||||||
baselineSeq: number,
|
baselineSeq: number,
|
||||||
generation: SystemContext.Generation,
|
generation: SystemContext.Generation,
|
||||||
) {
|
) {
|
||||||
yield* db
|
const updated = yield* db
|
||||||
.transaction(
|
.update(SessionContextEpochTable)
|
||||||
() =>
|
.set({
|
||||||
Effect.gen(function* () {
|
baseline: generation.baseline,
|
||||||
const updated = yield* db
|
snapshot: generation.snapshot,
|
||||||
.update(SessionContextEpochTable)
|
baseline_seq: baselineSeq,
|
||||||
.set({
|
replacement_seq: null,
|
||||||
baseline: generation.baseline,
|
revision: expectedRevision + 1,
|
||||||
snapshot: generation.snapshot,
|
})
|
||||||
baseline_seq: baselineSeq,
|
.where(
|
||||||
replacement_seq: null,
|
and(eq(SessionContextEpochTable.session_id, sessionID), eq(SessionContextEpochTable.revision, expectedRevision)),
|
||||||
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("Session context epoch revision mismatch")
|
|
||||||
}),
|
|
||||||
{ behavior: "immediate" },
|
|
||||||
)
|
)
|
||||||
|
.returning({ revision: SessionContextEpochTable.revision })
|
||||||
|
.get()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
|
if (!updated) return yield* Effect.die(new RevisionMismatch())
|
||||||
})
|
})
|
||||||
|
|
||||||
const advance = Effect.fnUntraced(function* (
|
const advance = Effect.fnUntraced(function* (
|
||||||
|
|
@ -157,5 +212,5 @@ const advance = Effect.fnUntraced(function* (
|
||||||
.returning({ revision: SessionContextEpochTable.revision })
|
.returning({ revision: SessionContextEpochTable.revision })
|
||||||
.get()
|
.get()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
if (!updated) return yield* Effect.die("Session context epoch revision mismatch")
|
if (!updated) return yield* Effect.die(new RevisionMismatch())
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -75,10 +75,7 @@ export const load = Effect.fn("SessionContext.load")(function* (db: DatabaseServ
|
||||||
],
|
],
|
||||||
{ concurrency: "unbounded" },
|
{ concurrency: "unbounded" },
|
||||||
)
|
)
|
||||||
return yield* Effect.forEach(
|
return yield* Effect.forEach(yield* messageRows(db, sessionID, compaction, epoch?.baselineSeq), decodeMessageRow)
|
||||||
yield* messageRows(db, sessionID, compaction, epoch?.baselineSeq),
|
|
||||||
decodeMessageRow,
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const loadForRunner = Effect.fn("SessionContext.loadForRunner")(function* (
|
export const loadForRunner = Effect.fn("SessionContext.loadForRunner")(function* (
|
||||||
|
|
@ -86,7 +83,10 @@ export const loadForRunner = Effect.fn("SessionContext.loadForRunner")(function*
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
baselineSeq: number,
|
baselineSeq: number,
|
||||||
) {
|
) {
|
||||||
return yield* Effect.forEach(yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID), baselineSeq), decodeMessageRow)
|
return yield* Effect.forEach(
|
||||||
|
yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID), baselineSeq),
|
||||||
|
decodeMessageRow,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export * as SessionContext from "./context"
|
export * as SessionContext from "./context"
|
||||||
|
|
|
||||||
|
|
@ -176,7 +176,16 @@ export class Compaction extends Schema.Class<Compaction>("Session.Message.Compac
|
||||||
...Base,
|
...Base,
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export const Message = Schema.Union([AgentSwitched, ModelSwitched, User, Synthetic, System, 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" })
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -422,7 +422,9 @@ export const layer = Layer.effectDiscard(
|
||||||
)
|
)
|
||||||
yield* events.project(SessionEvent.ContextUpdated, (event) => {
|
yield* events.project(SessionEvent.ContextUpdated, (event) => {
|
||||||
if (!event.replay || event.seq === undefined) return run(db, 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)))
|
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))
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,7 @@ export const layer = Layer.effect(
|
||||||
promotion: "steer" | "queue" | undefined,
|
promotion: "steer" | "queue" | undefined,
|
||||||
) {
|
) {
|
||||||
const session = yield* getSession(sessionID)
|
const session = yield* getSession(sessionID)
|
||||||
|
const initialized = yield* SessionContextEpoch.initialize(db, systemContext, session.id)
|
||||||
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
|
||||||
|
|
@ -150,7 +151,7 @@ export const layer = Layer.effect(
|
||||||
yield* SessionInput.promoteSteers(db, events, session.id, cutoff)
|
yield* SessionInput.promoteSteers(db, events, session.id, cutoff)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const system = yield* SessionContextEpoch.prepare(db, events, systemContext, session.id)
|
const system = initialized ?? (yield* SessionContextEpoch.prepare(db, events, systemContext, session.id))
|
||||||
const context = yield* getRunnerContext(session.id, system.baselineSeq)
|
const context = yield* getRunnerContext(session.id, system.baselineSeq)
|
||||||
const request = LLM.request({
|
const request = LLM.request({
|
||||||
model,
|
model,
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ const builtIns = Layer.effectDiscard(
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
yield* registry.contribute({ key: "core/builtins", load: Effect.succeed(context) })
|
yield* registry.contribute({ key: SystemContext.Key.make("core/builtins"), load: Effect.succeed(context) })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { Context, Effect, Layer, Ref, Scope } from "effect"
|
||||||
import { SystemContext } from "./system-context"
|
import { SystemContext } from "./system-context"
|
||||||
|
|
||||||
export interface Contribution {
|
export interface Contribution {
|
||||||
readonly key: string
|
readonly key: SystemContext.Key
|
||||||
readonly load: Effect.Effect<SystemContext.SystemContext>
|
readonly load: Effect.Effect<SystemContext.SystemContext>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -36,7 +36,7 @@ export const layer = Layer.effect(
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
load: Effect.fn("SystemContextRegistry.load")(function* () {
|
load: Effect.fn("SystemContextRegistry.load")(function* () {
|
||||||
const current = (yield* Ref.get(contributions)).toSorted((a, b) => a.key.localeCompare(b.key))
|
const current = (yield* Ref.get(contributions)).toSorted((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
|
||||||
return SystemContext.combine(
|
return SystemContext.combine(
|
||||||
yield* Effect.forEach(current, (contribution) => contribution.load, { concurrency: "unbounded" }),
|
yield* Effect.forEach(current, (contribution) => contribution.load, { concurrency: "unbounded" }),
|
||||||
)
|
)
|
||||||
|
|
@ -44,5 +44,3 @@ export const layer = Layer.effect(
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const locationLayer = layer
|
|
||||||
|
|
|
||||||
|
|
@ -67,8 +67,8 @@ export interface Updated {
|
||||||
readonly snapshot: Snapshot
|
readonly snapshot: Snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Replaced {
|
export interface ReplacementReady {
|
||||||
readonly _tag: "Replaced"
|
readonly _tag: "ReplacementReady"
|
||||||
readonly generation: Generation
|
readonly generation: Generation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,9 +76,14 @@ export interface ReplacementBlocked {
|
||||||
readonly _tag: "ReplacementBlocked"
|
readonly _tag: "ReplacementBlocked"
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ReplacementResult = Replaced | ReplacementBlocked
|
export type ReplacementResult = ReplacementReady | ReplacementBlocked
|
||||||
export type ReconcileResult = { readonly _tag: "Unchanged" } | Updated | ReplacementResult
|
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,
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -186,8 +191,14 @@ const observe = (value: SystemContext) =>
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Creates the immutable baseline and durable snapshot for a new generation. */
|
/** Creates the immutable baseline and durable snapshot for a new generation. */
|
||||||
export function initialize(value: SystemContext): Effect.Effect<Generation> {
|
export function initialize(value: SystemContext): Effect.Effect<Generation, InitializationBlocked> {
|
||||||
return observe(value).pipe(Effect.map(initializeObservation))
|
return observe(value).pipe(
|
||||||
|
Effect.flatMap((entries) => {
|
||||||
|
const unavailable = entries.flatMap((entry) => (entry._tag === "Unavailable" ? [entry.key] : []))
|
||||||
|
if (unavailable.length > 0) return new InitializationBlocked({ keys: unavailable })
|
||||||
|
return Effect.succeed(initializeObservation(entries))
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function initializeObservation(entries: ReadonlyArray<Entry>): Generation {
|
function initializeObservation(entries: ReadonlyArray<Entry>): Generation {
|
||||||
|
|
@ -272,7 +283,7 @@ export function replace(value: SystemContext, previous: Snapshot): Effect.Effect
|
||||||
function replaceObservation(entries: ReadonlyArray<Entry>, previous: Snapshot): ReplacementResult {
|
function replaceObservation(entries: ReadonlyArray<Entry>, previous: Snapshot): ReplacementResult {
|
||||||
if (entries.some((entry) => entry._tag === "Unavailable" && getSnapshot(previous, entry.key) !== undefined))
|
if (entries.some((entry) => entry._tag === "Unavailable" && getSnapshot(previous, entry.key) !== undefined))
|
||||||
return { _tag: "ReplacementBlocked" }
|
return { _tag: "ReplacementBlocked" }
|
||||||
return { _tag: "Replaced", generation: initializeObservation(entries) }
|
return { _tag: "ReplacementReady", generation: initializeObservation(entries) }
|
||||||
}
|
}
|
||||||
|
|
||||||
function context(sources: ReadonlyArray<PackedSource>): SystemContext {
|
function context(sources: ReadonlyArray<PackedSource>): SystemContext {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { $ } from "bun"
|
import { $ } from "bun"
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -17,43 +18,45 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
import { SessionSchema } from "@opencode-ai/core/session/schema"
|
import { SessionSchema } from "@opencode-ai/core/session/schema"
|
||||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||||
import sessionMetadataMigration from "@opencode-ai/core/database/migration/20260511173437_session-metadata"
|
import sessionMetadataMigration from "@opencode-ai/core/database/migration/20260511173437_session-metadata"
|
||||||
import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient"
|
|
||||||
import { Database } from "@opencode-ai/core/database/database"
|
import { Database } from "@opencode-ai/core/database/database"
|
||||||
import { tmpdir } from "./fixture/tmpdir"
|
import { tmpdir } from "./fixture/tmpdir"
|
||||||
|
import { testEffect } from "./lib/effect"
|
||||||
const run = <A, E>(effect: Effect.Effect<A, E, SqlClientService>) =>
|
|
||||||
Effect.runPromise(
|
|
||||||
effect.pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })), Effect.scoped),
|
|
||||||
)
|
|
||||||
|
|
||||||
const makeDb = EffectDrizzleSqlite.makeWithDefaults()
|
const makeDb = EffectDrizzleSqlite.makeWithDefaults()
|
||||||
|
const it = testEffect(SqliteClient.layer({ filename: ":memory:", disableWAL: true }))
|
||||||
|
|
||||||
describe("DatabaseMigration", () => {
|
describe("DatabaseMigration", () => {
|
||||||
test("serializes concurrent embedded initialization for one database path", async () => {
|
it.effect("serializes concurrent embedded initialization for one database path", () =>
|
||||||
await using tmp = await tmpdir()
|
Effect.promise(async () => {
|
||||||
const filename = path.join(tmp.path, "embedded.sqlite")
|
await using tmp = await tmpdir()
|
||||||
const layers = [Database.layerFromPath(filename), Database.layerFromPath(filename)]
|
const filename = path.join(tmp.path, "embedded.sqlite")
|
||||||
|
const layers = [Database.layerFromPath(filename), Database.layerFromPath(filename)]
|
||||||
|
|
||||||
await Effect.runPromise(
|
await Effect.runPromise(
|
||||||
Effect.all(
|
Effect.all(
|
||||||
layers.map((layer) => Effect.scoped(Layer.build(layer))),
|
layers.map((layer) => Effect.scoped(Layer.build(layer))),
|
||||||
{ concurrency: "unbounded" },
|
{ concurrency: "unbounded" },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
if (process.platform === "linux") {
|
if (process.platform === "linux") {
|
||||||
test("declared schema has no ungenerated migrations", async () => {
|
it.effect(
|
||||||
const result = await $`bun ${fileURLToPath(new URL("../script/migration.ts", import.meta.url))} --check`
|
"declared schema has no ungenerated migrations",
|
||||||
.quiet()
|
() =>
|
||||||
.nothrow()
|
Effect.promise(async () => {
|
||||||
expect(result.exitCode, result.stderr.toString()).toBe(0)
|
const result = await $`bun ${fileURLToPath(new URL("../script/migration.ts", import.meta.url))} --check`
|
||||||
expect(result.stdout.toString()).toContain("No schema changes, nothing to migrate")
|
.quiet()
|
||||||
}, 30_000)
|
.nothrow()
|
||||||
|
expect(result.exitCode, result.stderr.toString()).toBe(0)
|
||||||
|
expect(result.stdout.toString()).toContain("No schema changes, nothing to migrate")
|
||||||
|
}),
|
||||||
|
30_000,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("applies tracked migrations to an empty database", async () => {
|
it.effect("applies tracked migrations to an empty database", () =>
|
||||||
await run(
|
Effect.gen(function* () {
|
||||||
Effect.gen(function* () {
|
|
||||||
const db = yield* makeDb
|
const db = yield* makeDb
|
||||||
yield* DatabaseMigration.apply(db)
|
yield* DatabaseMigration.apply(db)
|
||||||
|
|
||||||
|
|
@ -66,10 +69,7 @@ describe("DatabaseMigration", () => {
|
||||||
expect(
|
expect(
|
||||||
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_context_epoch'`),
|
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_context_epoch'`),
|
||||||
).toEqual({ name: "session_context_epoch" })
|
).toEqual({ name: "session_context_epoch" })
|
||||||
expect(
|
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: migrations.length })
|
||||||
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_context_message'`),
|
|
||||||
).toEqual({ name: "session_context_message" })
|
|
||||||
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 31 })
|
|
||||||
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`,
|
||||||
|
|
@ -84,13 +84,11 @@ describe("DatabaseMigration", () => {
|
||||||
{ name: "session_message_session_time_created_id_idx" },
|
{ name: "session_message_session_time_created_id_idx" },
|
||||||
{ name: "session_message_session_type_seq_idx" },
|
{ name: "session_message_session_type_seq_idx" },
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
|
||||||
|
|
||||||
test("resets beta history and rebuilds event-sourced Session input storage", async () => {
|
it.effect("resets beta history and rebuilds event-sourced Session input storage", () =>
|
||||||
await run(
|
Effect.gen(function* () {
|
||||||
Effect.gen(function* () {
|
|
||||||
const db = yield* makeDb
|
const db = yield* makeDb
|
||||||
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, workspace_id text)`)
|
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, workspace_id text)`)
|
||||||
yield* db.run(sql`CREATE TABLE workspace (id text PRIMARY KEY)`)
|
yield* db.run(sql`CREATE TABLE workspace (id text PRIMARY KEY)`)
|
||||||
|
|
@ -160,13 +158,11 @@ describe("DatabaseMigration", () => {
|
||||||
expect.objectContaining({ name: "session_input_session_promoted_seq_idx", unique: 1 }),
|
expect.objectContaining({ name: "session_input_session_promoted_seq_idx", unique: 1 }),
|
||||||
expect.objectContaining({ name: "session_input_session_admitted_seq_idx", unique: 1 }),
|
expect.objectContaining({ name: "session_input_session_admitted_seq_idx", unique: 1 }),
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
|
||||||
|
|
||||||
test("resets incompatible projected Session messages before adding sequence order", async () => {
|
it.effect("resets incompatible projected Session messages before adding sequence order", () =>
|
||||||
await run(
|
Effect.gen(function* () {
|
||||||
Effect.gen(function* () {
|
|
||||||
const db = yield* makeDb
|
const db = yield* makeDb
|
||||||
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`)
|
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`)
|
||||||
yield* db.run(
|
yield* db.run(
|
||||||
|
|
@ -215,13 +211,11 @@ describe("DatabaseMigration", () => {
|
||||||
sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('fresh_projection', 'session', 'user', 7, 2, 2, '{}')`,
|
sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('fresh_projection', 'session', 'user', 7, 2, 2, '{}')`,
|
||||||
)
|
)
|
||||||
expect(yield* db.get(sql`SELECT id, seq FROM session_message`)).toEqual({ id: "fresh_projection", seq: 7 })
|
expect(yield* db.get(sql`SELECT id, seq FROM session_message`)).toEqual({ id: "fresh_projection", seq: 7 })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
|
||||||
|
|
||||||
test("runs session usage backfill in order with schema changes", async () => {
|
it.effect("runs session usage backfill in order with schema changes", () =>
|
||||||
await run(
|
Effect.gen(function* () {
|
||||||
Effect.gen(function* () {
|
|
||||||
const db = yield* makeDb
|
const db = yield* makeDb
|
||||||
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, time_updated integer NOT NULL)`)
|
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, time_updated integer NOT NULL)`)
|
||||||
yield* db.run(sql`CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL, data text NOT NULL)`)
|
yield* db.run(sql`CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL, data text NOT NULL)`)
|
||||||
|
|
@ -244,13 +238,11 @@ describe("DatabaseMigration", () => {
|
||||||
tokens_cache_read: 5,
|
tokens_cache_read: 5,
|
||||||
tokens_cache_write: 6,
|
tokens_cache_write: 6,
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
|
||||||
|
|
||||||
test("normalizes Windows storage paths and leaves POSIX paths untouched", async () => {
|
it.effect("normalizes Windows storage paths and leaves POSIX paths untouched", () =>
|
||||||
await run(
|
Effect.gen(function* () {
|
||||||
Effect.gen(function* () {
|
|
||||||
const db = yield* makeDb
|
const db = yield* makeDb
|
||||||
yield* db.run(sql`CREATE TABLE project (id text PRIMARY KEY, worktree text NOT NULL, sandboxes text NOT NULL)`)
|
yield* db.run(sql`CREATE TABLE project (id text PRIMARY KEY, worktree text NOT NULL, sandboxes text NOT NULL)`)
|
||||||
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, directory text NOT NULL, path text)`)
|
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, directory text NOT NULL, path text)`)
|
||||||
|
|
@ -295,14 +287,12 @@ describe("DatabaseMigration", () => {
|
||||||
directory: "/home/me/we\\ird",
|
directory: "/home/me/we\\ird",
|
||||||
path: "src\\weird",
|
path: "src\\weird",
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
|
||||||
|
|
||||||
test("maps native Windows paths through database columns", async () => {
|
it.effect("maps native Windows paths through database columns", () => {
|
||||||
if (process.platform !== "win32") return
|
if (process.platform !== "win32") return Effect.void
|
||||||
await run(
|
return Effect.gen(function* () {
|
||||||
Effect.gen(function* () {
|
|
||||||
const db = yield* makeDb
|
const db = yield* makeDb
|
||||||
yield* DatabaseMigration.apply(db)
|
yield* DatabaseMigration.apply(db)
|
||||||
const projectID = ProjectV2.ID.make("codec_project")
|
const projectID = ProjectV2.ID.make("codec_project")
|
||||||
|
|
@ -405,13 +395,11 @@ describe("DatabaseMigration", () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
Effect.runSync(db.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get()),
|
Effect.runSync(db.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get()),
|
||||||
).toThrow()
|
).toThrow()
|
||||||
}),
|
})
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("imports existing drizzle migration state", async () => {
|
it.effect("imports existing drizzle migration state", () =>
|
||||||
await run(
|
Effect.gen(function* () {
|
||||||
Effect.gen(function* () {
|
|
||||||
const db = yield* makeDb
|
const db = yield* makeDb
|
||||||
yield* db.run(
|
yield* db.run(
|
||||||
sql`CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash text NOT NULL, created_at numeric, name text, applied_at TEXT)`,
|
sql`CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash text NOT NULL, created_at numeric, name text, applied_at TEXT)`,
|
||||||
|
|
@ -424,13 +412,11 @@ describe("DatabaseMigration", () => {
|
||||||
yield* DatabaseMigration.applyOnly(db, [])
|
yield* DatabaseMigration.applyOnly(db, [])
|
||||||
|
|
||||||
expect(yield* db.get(sql`SELECT id FROM migration`)).toEqual({ id: "20260127222353_familiar_lady_ursula" })
|
expect(yield* db.get(sql`SELECT id FROM migration`)).toEqual({ id: "20260127222353_familiar_lady_ursula" })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
|
||||||
|
|
||||||
test("does not replay a migrated session metadata column", async () => {
|
it.effect("does not replay a migrated session metadata column", () =>
|
||||||
await run(
|
Effect.gen(function* () {
|
||||||
Effect.gen(function* () {
|
|
||||||
const db = yield* makeDb
|
const db = yield* makeDb
|
||||||
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, metadata text)`)
|
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, metadata text)`)
|
||||||
yield* db.run(
|
yield* db.run(
|
||||||
|
|
@ -444,13 +430,11 @@ describe("DatabaseMigration", () => {
|
||||||
yield* DatabaseMigration.applyOnly(db, [sessionMetadataMigration])
|
yield* DatabaseMigration.applyOnly(db, [sessionMetadataMigration])
|
||||||
|
|
||||||
expect(yield* db.all(sql`SELECT id FROM migration`)).toEqual([{ id: "20260511173437_session-metadata" }])
|
expect(yield* db.all(sql`SELECT id FROM migration`)).toEqual([{ id: "20260511173437_session-metadata" }])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
|
||||||
|
|
||||||
test("accepts the temporary replacement session metadata migration id", async () => {
|
it.effect("accepts the temporary replacement session metadata migration id", () =>
|
||||||
await run(
|
Effect.gen(function* () {
|
||||||
Effect.gen(function* () {
|
|
||||||
const db = yield* makeDb
|
const db = yield* makeDb
|
||||||
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, metadata text)`)
|
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, metadata text)`)
|
||||||
yield* db.run(sql`CREATE TABLE migration (id TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)`)
|
yield* db.run(sql`CREATE TABLE migration (id TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)`)
|
||||||
|
|
@ -462,13 +446,11 @@ describe("DatabaseMigration", () => {
|
||||||
{ id: "20260511173437_session-metadata" },
|
{ id: "20260511173437_session-metadata" },
|
||||||
{ id: "20260530232709_lovely_romulus" },
|
{ id: "20260530232709_lovely_romulus" },
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
|
||||||
|
|
||||||
test("skips drizzle import when migration table already has state", async () => {
|
it.effect("skips drizzle import when migration table already has state", () =>
|
||||||
await run(
|
Effect.gen(function* () {
|
||||||
Effect.gen(function* () {
|
|
||||||
const db = yield* makeDb
|
const db = yield* makeDb
|
||||||
yield* db.run(sql`CREATE TABLE migration (id TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)`)
|
yield* db.run(sql`CREATE TABLE migration (id TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)`)
|
||||||
yield* db.run(sql`INSERT INTO migration (id, time_completed) VALUES ('existing', 1)`)
|
yield* db.run(sql`INSERT INTO migration (id, time_completed) VALUES ('existing', 1)`)
|
||||||
|
|
@ -483,7 +465,6 @@ describe("DatabaseMigration", () => {
|
||||||
yield* DatabaseMigration.applyOnly(db, [])
|
yield* DatabaseMigration.applyOnly(db, [])
|
||||||
|
|
||||||
expect(yield* db.all(sql`SELECT id FROM migration ORDER BY id`)).toEqual([{ id: "existing" }])
|
expect(yield* db.all(sql`SELECT id FROM migration ORDER BY id`)).toEqual([{ id: "existing" }])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -194,11 +194,12 @@ describe("EventV2", () => {
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const events = yield* EventV2.Service
|
const events = yield* EventV2.Service
|
||||||
const received = new Array<string>()
|
const received = new Array<string>()
|
||||||
|
const aggregateID = EventV2.ID.create()
|
||||||
yield* events.project(SyncMessage, () => Effect.sync(() => received.push("projector")))
|
yield* events.project(SyncMessage, () => Effect.sync(() => received.push("projector")))
|
||||||
|
|
||||||
yield* events.publish(
|
yield* events.publish(
|
||||||
SyncMessage,
|
SyncMessage,
|
||||||
{ id: "one", text: "hello" },
|
{ id: aggregateID, text: "hello" },
|
||||||
{ commit: (seq) => Effect.sync(() => received.push(`commit:${seq}`)) },
|
{ commit: (seq) => Effect.sync(() => received.push(`commit:${seq}`)) },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -224,7 +225,9 @@ describe("EventV2", () => {
|
||||||
expect(String(exit)).toContain("commit failed")
|
expect(String(exit)).toContain("commit failed")
|
||||||
expect(yield* db.all("SELECT value FROM event_commit_probe")).toEqual([])
|
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(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).all()).toEqual([])
|
||||||
expect(yield* db.select().from(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).all()).toEqual([])
|
expect(
|
||||||
|
yield* db.select().from(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).all(),
|
||||||
|
).toEqual([])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,9 @@ describe("InstructionContext", () => {
|
||||||
const failingFS = Layer.effect(
|
const failingFS = Layer.effect(
|
||||||
FSUtil.Service,
|
FSUtil.Service,
|
||||||
FSUtil.Service.pipe(
|
FSUtil.Service.pipe(
|
||||||
Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.fail(new FSUtil.FileSystemError({ method: "up" })) })),
|
Effect.map((fs) =>
|
||||||
|
FSUtil.Service.of({ ...fs, up: () => Effect.fail(new FSUtil.FileSystemError({ method: "up" })) }),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
).pipe(Layer.provide(FSUtil.defaultLayer))
|
).pipe(Layer.provide(FSUtil.defaultLayer))
|
||||||
const context = yield* SystemContextRegistry.Service.pipe(
|
const context = yield* SystemContextRegistry.Service.pipe(
|
||||||
|
|
@ -126,10 +128,7 @@ describe("InstructionContext", () => {
|
||||||
Effect.provide(failingFS),
|
Effect.provide(failingFS),
|
||||||
Effect.provide(Global.layerWith({ config: "/global" })),
|
Effect.provide(Global.layerWith({ config: "/global" })),
|
||||||
Effect.provide(
|
Effect.provide(
|
||||||
Layer.succeed(
|
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/repo") }))),
|
||||||
Location.Service,
|
|
||||||
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -165,10 +164,7 @@ describe("InstructionContext", () => {
|
||||||
Effect.provide(racingFS),
|
Effect.provide(racingFS),
|
||||||
Effect.provide(Global.layerWith({ config: "/global" })),
|
Effect.provide(Global.layerWith({ config: "/global" })),
|
||||||
Effect.provide(
|
Effect.provide(
|
||||||
Layer.succeed(
|
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/repo") }))),
|
||||||
Location.Service,
|
|
||||||
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,375 +16,388 @@ const id = (value: string) => SessionMessage.ID.make(`msg_${value}`)
|
||||||
const model = Model.make({ id: "model", provider: "provider", route: OpenAIChat.route })
|
const model = Model.make({ id: "model", provider: "provider", route: OpenAIChat.route })
|
||||||
|
|
||||||
describe("toLLMMessages", () => {
|
describe("toLLMMessages", () => {
|
||||||
it.effect("maps every top-level V2 Session message type", () => Effect.sync(() => {
|
it.effect("maps every top-level V2 Session message type", () =>
|
||||||
const file = new FileAttachment({ uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" })
|
Effect.sync(() => {
|
||||||
const reference = new ReferenceAttachment({ name: "docs", kind: "local", uri: "file:///docs" })
|
const file = new FileAttachment({ uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" })
|
||||||
const messages = toLLMMessages(
|
const reference = new ReferenceAttachment({ name: "docs", kind: "local", uri: "file:///docs" })
|
||||||
[
|
const messages = toLLMMessages(
|
||||||
new SessionMessage.AgentSwitched({
|
|
||||||
id: id("agent"),
|
|
||||||
type: "agent-switched",
|
|
||||||
agent: "build",
|
|
||||||
time: { created },
|
|
||||||
}),
|
|
||||||
new SessionMessage.ModelSwitched({
|
|
||||||
id: id("model"),
|
|
||||||
type: "model-switched",
|
|
||||||
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
|
|
||||||
time: { created },
|
|
||||||
}),
|
|
||||||
new SessionMessage.User({
|
|
||||||
id: id("user"),
|
|
||||||
type: "user",
|
|
||||||
text: "Inspect this image",
|
|
||||||
files: [file],
|
|
||||||
agents: [new AgentAttachment({ name: "build" })],
|
|
||||||
references: [reference],
|
|
||||||
time: { created },
|
|
||||||
}),
|
|
||||||
new SessionMessage.Synthetic({
|
|
||||||
id: id("synthetic"),
|
|
||||||
type: "synthetic",
|
|
||||||
sessionID: SessionV2.ID.make("ses_translate"),
|
|
||||||
text: "Synthetic context",
|
|
||||||
time: { created },
|
|
||||||
}),
|
|
||||||
new SessionMessage.Shell({
|
|
||||||
id: id("shell"),
|
|
||||||
type: "shell",
|
|
||||||
callID: "shell-1",
|
|
||||||
command: "pwd",
|
|
||||||
output: "/project",
|
|
||||||
time: { created, completed: created },
|
|
||||||
}),
|
|
||||||
new SessionMessage.Compaction({
|
|
||||||
id: id("compaction"),
|
|
||||||
type: "compaction",
|
|
||||||
reason: "auto",
|
|
||||||
summary: "Earlier work",
|
|
||||||
time: { created },
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
model,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(messages.map((message) => message.role)).toEqual(["user", "user", "user", "user"])
|
|
||||||
expect(messages[0]).toEqual(
|
|
||||||
Message.make({
|
|
||||||
id: id("user"),
|
|
||||||
role: "user",
|
|
||||||
content: [
|
|
||||||
{ type: "text", text: "Inspect this image" },
|
|
||||||
{ type: "media", mediaType: "image/png", data: "data:image/png;base64,aGVsbG8=", filename: "hello.png" },
|
|
||||||
],
|
|
||||||
metadata: { agents: [{ name: "build" }], references: [reference] },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
expect(messages.slice(1).map((message) => message.content)).toEqual([
|
|
||||||
[{ type: "text", text: "Synthetic context" }],
|
|
||||||
[{ type: "text", text: "Shell command: pwd\n\n/project" }],
|
|
||||||
[{ type: "text", text: "Summary of earlier conversation:\nEarlier work" }],
|
|
||||||
])
|
|
||||||
}))
|
|
||||||
|
|
||||||
it.effect("maps durable Session system messages into chronological system messages", () => Effect.sync(() => {
|
|
||||||
expect(
|
|
||||||
toLLMMessages(
|
|
||||||
[
|
[
|
||||||
new SessionMessage.System({ id: id("system"), type: "system", text: "Updated context\n\nOther context", time: { created } }),
|
new SessionMessage.AgentSwitched({
|
||||||
|
id: id("agent"),
|
||||||
|
type: "agent-switched",
|
||||||
|
agent: "build",
|
||||||
|
time: { created },
|
||||||
|
}),
|
||||||
|
new SessionMessage.ModelSwitched({
|
||||||
|
id: id("model"),
|
||||||
|
type: "model-switched",
|
||||||
|
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
|
||||||
|
time: { created },
|
||||||
|
}),
|
||||||
|
new SessionMessage.User({
|
||||||
|
id: id("user"),
|
||||||
|
type: "user",
|
||||||
|
text: "Inspect this image",
|
||||||
|
files: [file],
|
||||||
|
agents: [new AgentAttachment({ name: "build" })],
|
||||||
|
references: [reference],
|
||||||
|
time: { created },
|
||||||
|
}),
|
||||||
|
new SessionMessage.Synthetic({
|
||||||
|
id: id("synthetic"),
|
||||||
|
type: "synthetic",
|
||||||
|
sessionID: SessionV2.ID.make("ses_translate"),
|
||||||
|
text: "Synthetic context",
|
||||||
|
time: { created },
|
||||||
|
}),
|
||||||
|
new SessionMessage.Shell({
|
||||||
|
id: id("shell"),
|
||||||
|
type: "shell",
|
||||||
|
callID: "shell-1",
|
||||||
|
command: "pwd",
|
||||||
|
output: "/project",
|
||||||
|
time: { created, completed: created },
|
||||||
|
}),
|
||||||
|
new SessionMessage.Compaction({
|
||||||
|
id: id("compaction"),
|
||||||
|
type: "compaction",
|
||||||
|
reason: "auto",
|
||||||
|
summary: "Earlier work",
|
||||||
|
time: { created },
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
model,
|
model,
|
||||||
),
|
)
|
||||||
).toEqual([
|
|
||||||
Message.system("Updated context\n\nOther context"),
|
|
||||||
])
|
|
||||||
}))
|
|
||||||
|
|
||||||
it.effect("expands assistant tool calls and settled outcomes into canonical tool messages", () => Effect.sync(() => {
|
expect(messages.map((message) => message.role)).toEqual(["user", "user", "user", "user"])
|
||||||
const messages = toLLMMessages(
|
expect(messages[0]).toEqual(
|
||||||
[
|
Message.make({
|
||||||
new SessionMessage.Assistant({
|
id: id("user"),
|
||||||
id: id("assistant"),
|
role: "user",
|
||||||
type: "assistant",
|
|
||||||
agent: "build",
|
|
||||||
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
|
|
||||||
content: [
|
content: [
|
||||||
new SessionMessage.AssistantText({ type: "text", id: "text-1", text: "Checking" }),
|
{ type: "text", text: "Inspect this image" },
|
||||||
new SessionMessage.AssistantReasoning({
|
{ type: "media", mediaType: "image/png", data: "data:image/png;base64,aGVsbG8=", filename: "hello.png" },
|
||||||
type: "reasoning",
|
],
|
||||||
id: "reasoning-1",
|
metadata: { agents: [{ name: "build" }], references: [reference] },
|
||||||
text: "Think",
|
}),
|
||||||
providerMetadata: { anthropic: { signature: "sig_1" } },
|
)
|
||||||
}),
|
expect(messages.slice(1).map((message) => message.content)).toEqual([
|
||||||
new SessionMessage.AssistantTool({
|
[{ type: "text", text: "Synthetic context" }],
|
||||||
type: "tool",
|
[{ type: "text", text: "Shell command: pwd\n\n/project" }],
|
||||||
id: "pending",
|
[{ type: "text", text: "Summary of earlier conversation:\nEarlier work" }],
|
||||||
name: "read",
|
])
|
||||||
state: new SessionMessage.ToolStatePending({ status: "pending", input: '{"path":"README.md"}' }),
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("maps durable Session system messages into chronological system messages", () =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
expect(
|
||||||
|
toLLMMessages(
|
||||||
|
[
|
||||||
|
new SessionMessage.System({
|
||||||
|
id: id("system"),
|
||||||
|
type: "system",
|
||||||
|
text: "Updated context\n\nOther context",
|
||||||
time: { created },
|
time: { created },
|
||||||
}),
|
}),
|
||||||
new SessionMessage.AssistantTool({
|
|
||||||
type: "tool",
|
|
||||||
id: "running",
|
|
||||||
name: "read",
|
|
||||||
state: new SessionMessage.ToolStateRunning({
|
|
||||||
status: "running",
|
|
||||||
input: { path: "README.md" },
|
|
||||||
content: [],
|
|
||||||
structured: {},
|
|
||||||
}),
|
|
||||||
time: { created },
|
|
||||||
}),
|
|
||||||
new SessionMessage.AssistantTool({
|
|
||||||
type: "tool",
|
|
||||||
id: "completed",
|
|
||||||
name: "read",
|
|
||||||
state: new SessionMessage.ToolStateCompleted({
|
|
||||||
status: "completed",
|
|
||||||
input: { path: "README.md" },
|
|
||||||
content: [
|
|
||||||
new ToolOutput.TextContent({ type: "text", text: "Hello" }),
|
|
||||||
new ToolOutput.FileContent({
|
|
||||||
type: "file",
|
|
||||||
source: { type: "data", data: "aGVsbG8=" },
|
|
||||||
mime: "image/png",
|
|
||||||
name: "hello.png",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
structured: {},
|
|
||||||
}),
|
|
||||||
time: { created, completed: created },
|
|
||||||
}),
|
|
||||||
new SessionMessage.AssistantTool({
|
|
||||||
type: "tool",
|
|
||||||
id: "hosted",
|
|
||||||
name: "web_search",
|
|
||||||
provider: {
|
|
||||||
executed: true,
|
|
||||||
metadata: { fake: { continuation: "hosted-call" } },
|
|
||||||
resultMetadata: { fake: { continuation: "hosted-result" } },
|
|
||||||
},
|
|
||||||
state: new SessionMessage.ToolStateCompleted({
|
|
||||||
status: "completed",
|
|
||||||
input: { query: "Effect" },
|
|
||||||
content: [new ToolOutput.TextContent({ type: "text", text: "Found it" })],
|
|
||||||
structured: {},
|
|
||||||
}),
|
|
||||||
time: { created, completed: created },
|
|
||||||
}),
|
|
||||||
new SessionMessage.AssistantTool({
|
|
||||||
type: "tool",
|
|
||||||
id: "hosted-failed",
|
|
||||||
name: "write",
|
|
||||||
provider: { executed: true, metadata: { fake: { continuation: "failed" } } },
|
|
||||||
state: new SessionMessage.ToolStateError({
|
|
||||||
status: "error",
|
|
||||||
input: { path: "README.md" },
|
|
||||||
content: [],
|
|
||||||
structured: {},
|
|
||||||
error: { type: "unknown", message: "Denied" },
|
|
||||||
}),
|
|
||||||
time: { created, completed: created },
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
time: { created, completed: created },
|
model,
|
||||||
}),
|
),
|
||||||
],
|
).toEqual([Message.system("Updated context\n\nOther context")])
|
||||||
model,
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(messages.map((message) => message.role)).toEqual(["assistant", "tool"])
|
it.effect("expands assistant tool calls and settled outcomes into canonical tool messages", () =>
|
||||||
expect(messages[0]?.content).toEqual([
|
Effect.sync(() => {
|
||||||
{ type: "text", text: "Checking" },
|
const messages = toLLMMessages(
|
||||||
{ type: "reasoning", text: "Think", providerMetadata: { anthropic: { signature: "sig_1" } } },
|
[
|
||||||
{ type: "tool-call", id: "pending", name: "read", input: { path: "README.md" } },
|
new SessionMessage.Assistant({
|
||||||
{ type: "tool-call", id: "running", name: "read", input: { path: "README.md" } },
|
id: id("assistant"),
|
||||||
{
|
type: "assistant",
|
||||||
type: "tool-call",
|
agent: "build",
|
||||||
id: "completed",
|
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
|
||||||
name: "read",
|
content: [
|
||||||
input: { path: "README.md" },
|
new SessionMessage.AssistantText({ type: "text", id: "text-1", text: "Checking" }),
|
||||||
},
|
new SessionMessage.AssistantReasoning({
|
||||||
{
|
type: "reasoning",
|
||||||
type: "tool-call",
|
id: "reasoning-1",
|
||||||
id: "hosted",
|
text: "Think",
|
||||||
name: "web_search",
|
providerMetadata: { anthropic: { signature: "sig_1" } },
|
||||||
input: { query: "Effect" },
|
}),
|
||||||
providerExecuted: true,
|
new SessionMessage.AssistantTool({
|
||||||
providerMetadata: { fake: { continuation: "hosted-call" } },
|
type: "tool",
|
||||||
},
|
id: "pending",
|
||||||
{
|
name: "read",
|
||||||
type: "tool-result",
|
state: new SessionMessage.ToolStatePending({ status: "pending", input: '{"path":"README.md"}' }),
|
||||||
id: "hosted",
|
time: { created },
|
||||||
name: "web_search",
|
}),
|
||||||
providerExecuted: true,
|
new SessionMessage.AssistantTool({
|
||||||
providerMetadata: { fake: { continuation: "hosted-result" } },
|
type: "tool",
|
||||||
result: { type: "text", value: "Found it" },
|
id: "running",
|
||||||
},
|
name: "read",
|
||||||
{
|
state: new SessionMessage.ToolStateRunning({
|
||||||
type: "tool-call",
|
status: "running",
|
||||||
id: "hosted-failed",
|
input: { path: "README.md" },
|
||||||
name: "write",
|
content: [],
|
||||||
input: { path: "README.md" },
|
structured: {},
|
||||||
providerExecuted: true,
|
}),
|
||||||
providerMetadata: { fake: { continuation: "failed" } },
|
time: { created },
|
||||||
},
|
}),
|
||||||
{
|
new SessionMessage.AssistantTool({
|
||||||
type: "tool-result",
|
type: "tool",
|
||||||
id: "hosted-failed",
|
id: "completed",
|
||||||
name: "write",
|
name: "read",
|
||||||
providerExecuted: true,
|
state: new SessionMessage.ToolStateCompleted({
|
||||||
providerMetadata: { fake: { continuation: "failed" } },
|
status: "completed",
|
||||||
result: {
|
input: { path: "README.md" },
|
||||||
type: "error",
|
content: [
|
||||||
value: { error: { type: "unknown", message: "Denied" }, content: [], structured: {} },
|
new ToolOutput.TextContent({ type: "text", text: "Hello" }),
|
||||||
|
new ToolOutput.FileContent({
|
||||||
|
type: "file",
|
||||||
|
source: { type: "data", data: "aGVsbG8=" },
|
||||||
|
mime: "image/png",
|
||||||
|
name: "hello.png",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
structured: {},
|
||||||
|
}),
|
||||||
|
time: { created, completed: created },
|
||||||
|
}),
|
||||||
|
new SessionMessage.AssistantTool({
|
||||||
|
type: "tool",
|
||||||
|
id: "hosted",
|
||||||
|
name: "web_search",
|
||||||
|
provider: {
|
||||||
|
executed: true,
|
||||||
|
metadata: { fake: { continuation: "hosted-call" } },
|
||||||
|
resultMetadata: { fake: { continuation: "hosted-result" } },
|
||||||
|
},
|
||||||
|
state: new SessionMessage.ToolStateCompleted({
|
||||||
|
status: "completed",
|
||||||
|
input: { query: "Effect" },
|
||||||
|
content: [new ToolOutput.TextContent({ type: "text", text: "Found it" })],
|
||||||
|
structured: {},
|
||||||
|
}),
|
||||||
|
time: { created, completed: created },
|
||||||
|
}),
|
||||||
|
new SessionMessage.AssistantTool({
|
||||||
|
type: "tool",
|
||||||
|
id: "hosted-failed",
|
||||||
|
name: "write",
|
||||||
|
provider: { executed: true, metadata: { fake: { continuation: "failed" } } },
|
||||||
|
state: new SessionMessage.ToolStateError({
|
||||||
|
status: "error",
|
||||||
|
input: { path: "README.md" },
|
||||||
|
content: [],
|
||||||
|
structured: {},
|
||||||
|
error: { type: "unknown", message: "Denied" },
|
||||||
|
}),
|
||||||
|
time: { created, completed: created },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
time: { created, completed: created },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
model,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(messages.map((message) => message.role)).toEqual(["assistant", "tool"])
|
||||||
|
expect(messages[0]?.content).toEqual([
|
||||||
|
{ type: "text", text: "Checking" },
|
||||||
|
{ type: "reasoning", text: "Think", providerMetadata: { anthropic: { signature: "sig_1" } } },
|
||||||
|
{ type: "tool-call", id: "pending", name: "read", input: { path: "README.md" } },
|
||||||
|
{ type: "tool-call", id: "running", name: "read", input: { path: "README.md" } },
|
||||||
|
{
|
||||||
|
type: "tool-call",
|
||||||
|
id: "completed",
|
||||||
|
name: "read",
|
||||||
|
input: { path: "README.md" },
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
])
|
type: "tool-call",
|
||||||
expect(messages[1]?.content).toEqual([
|
id: "hosted",
|
||||||
{
|
name: "web_search",
|
||||||
type: "tool-result",
|
input: { query: "Effect" },
|
||||||
id: "completed",
|
providerExecuted: true,
|
||||||
name: "read",
|
providerMetadata: { fake: { continuation: "hosted-call" } },
|
||||||
result: {
|
|
||||||
type: "content",
|
|
||||||
value: [
|
|
||||||
{ type: "text", text: "Hello" },
|
|
||||||
{ type: "media", mediaType: "image/png", data: "aGVsbG8=", filename: "hello.png" },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
])
|
type: "tool-result",
|
||||||
}))
|
id: "hosted",
|
||||||
|
name: "web_search",
|
||||||
|
providerExecuted: true,
|
||||||
|
providerMetadata: { fake: { continuation: "hosted-result" } },
|
||||||
|
result: { type: "text", value: "Found it" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "tool-call",
|
||||||
|
id: "hosted-failed",
|
||||||
|
name: "write",
|
||||||
|
input: { path: "README.md" },
|
||||||
|
providerExecuted: true,
|
||||||
|
providerMetadata: { fake: { continuation: "failed" } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "tool-result",
|
||||||
|
id: "hosted-failed",
|
||||||
|
name: "write",
|
||||||
|
providerExecuted: true,
|
||||||
|
providerMetadata: { fake: { continuation: "failed" } },
|
||||||
|
result: {
|
||||||
|
type: "error",
|
||||||
|
value: { error: { type: "unknown", message: "Denied" }, content: [], structured: {} },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(messages[1]?.content).toEqual([
|
||||||
|
{
|
||||||
|
type: "tool-result",
|
||||||
|
id: "completed",
|
||||||
|
name: "read",
|
||||||
|
result: {
|
||||||
|
type: "content",
|
||||||
|
value: [
|
||||||
|
{ type: "text", text: "Hello" },
|
||||||
|
{ type: "media", mediaType: "image/png", data: "aGVsbG8=", filename: "hello.png" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("restores OpenAI encrypted reasoning metadata", () => Effect.sync(() => {
|
it.effect("restores OpenAI encrypted reasoning metadata", () =>
|
||||||
const messages = toLLMMessages(
|
Effect.sync(() => {
|
||||||
[
|
const messages = toLLMMessages(
|
||||||
new SessionMessage.Assistant({
|
[
|
||||||
id: id("assistant-openai-reasoning"),
|
new SessionMessage.Assistant({
|
||||||
type: "assistant",
|
id: id("assistant-openai-reasoning"),
|
||||||
agent: "build",
|
type: "assistant",
|
||||||
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
|
agent: "build",
|
||||||
content: [
|
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
|
||||||
new SessionMessage.AssistantReasoning({
|
content: [
|
||||||
type: "reasoning",
|
new SessionMessage.AssistantReasoning({
|
||||||
id: "reasoning-openai",
|
type: "reasoning",
|
||||||
text: "Think",
|
id: "reasoning-openai",
|
||||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
text: "Think",
|
||||||
}),
|
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||||
],
|
|
||||||
time: { created, completed: created },
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
model,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(messages[0]?.content).toEqual([
|
|
||||||
{
|
|
||||||
type: "reasoning",
|
|
||||||
text: "Think",
|
|
||||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}))
|
|
||||||
|
|
||||||
it.effect("drops provider-native continuation metadata after a model switch", () => Effect.sync(() => {
|
|
||||||
const messages = toLLMMessages(
|
|
||||||
[
|
|
||||||
new SessionMessage.Assistant({
|
|
||||||
id: id("assistant-old-model"),
|
|
||||||
type: "assistant",
|
|
||||||
agent: "build",
|
|
||||||
model: { id: ModelV2.ID.make("old-model"), providerID: ProviderV2.ID.make("provider") },
|
|
||||||
content: [
|
|
||||||
new SessionMessage.AssistantReasoning({
|
|
||||||
type: "reasoning",
|
|
||||||
id: "reasoning-old-model",
|
|
||||||
text: "Visible thought",
|
|
||||||
providerMetadata: { anthropic: { signature: "sig_old" } },
|
|
||||||
}),
|
|
||||||
new SessionMessage.AssistantTool({
|
|
||||||
type: "tool",
|
|
||||||
id: "hosted-old-model",
|
|
||||||
name: "web_search",
|
|
||||||
provider: {
|
|
||||||
executed: true,
|
|
||||||
metadata: { openai: { itemId: "hosted-old-model" } },
|
|
||||||
resultMetadata: { openai: { itemId: "hosted-old-model" } },
|
|
||||||
},
|
|
||||||
state: new SessionMessage.ToolStateCompleted({
|
|
||||||
status: "completed",
|
|
||||||
input: { query: "Effect" },
|
|
||||||
content: [],
|
|
||||||
structured: {},
|
|
||||||
result: { type: "json", value: { status: "completed" } },
|
|
||||||
}),
|
}),
|
||||||
time: { created, completed: created },
|
],
|
||||||
}),
|
time: { created, completed: created },
|
||||||
new SessionMessage.AssistantTool({
|
}),
|
||||||
type: "tool",
|
],
|
||||||
id: "local-old-model",
|
model,
|
||||||
name: "read",
|
)
|
||||||
provider: {
|
|
||||||
executed: false,
|
|
||||||
metadata: { fake: { call: "old" } },
|
|
||||||
resultMetadata: { fake: { result: "old" } },
|
|
||||||
},
|
|
||||||
state: new SessionMessage.ToolStateCompleted({
|
|
||||||
status: "completed",
|
|
||||||
input: { path: "README.md" },
|
|
||||||
content: [],
|
|
||||||
structured: { text: "Hello" },
|
|
||||||
}),
|
|
||||||
time: { created, completed: created },
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
time: { created, completed: created },
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
model,
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(messages[0]?.content).toEqual([
|
expect(messages[0]?.content).toEqual([
|
||||||
{ type: "text", text: "Visible thought" },
|
{
|
||||||
{
|
type: "reasoning",
|
||||||
type: "tool-call",
|
text: "Think",
|
||||||
id: "hosted-old-model",
|
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||||
name: "web_search",
|
},
|
||||||
input: { query: "Effect" },
|
])
|
||||||
providerExecuted: true,
|
}),
|
||||||
providerMetadata: undefined,
|
)
|
||||||
},
|
|
||||||
{
|
it.effect("drops provider-native continuation metadata after a model switch", () =>
|
||||||
type: "tool-result",
|
Effect.sync(() => {
|
||||||
id: "hosted-old-model",
|
const messages = toLLMMessages(
|
||||||
name: "web_search",
|
[
|
||||||
result: { type: "json", value: { status: "completed" } },
|
new SessionMessage.Assistant({
|
||||||
providerExecuted: true,
|
id: id("assistant-old-model"),
|
||||||
cache: undefined,
|
type: "assistant",
|
||||||
metadata: undefined,
|
agent: "build",
|
||||||
providerMetadata: undefined,
|
model: { id: ModelV2.ID.make("old-model"), providerID: ProviderV2.ID.make("provider") },
|
||||||
},
|
content: [
|
||||||
{
|
new SessionMessage.AssistantReasoning({
|
||||||
type: "tool-call",
|
type: "reasoning",
|
||||||
id: "local-old-model",
|
id: "reasoning-old-model",
|
||||||
name: "read",
|
text: "Visible thought",
|
||||||
input: { path: "README.md" },
|
providerMetadata: { anthropic: { signature: "sig_old" } },
|
||||||
providerExecuted: false,
|
}),
|
||||||
providerMetadata: undefined,
|
new SessionMessage.AssistantTool({
|
||||||
},
|
type: "tool",
|
||||||
])
|
id: "hosted-old-model",
|
||||||
expect(messages[1]?.content).toEqual([
|
name: "web_search",
|
||||||
{
|
provider: {
|
||||||
type: "tool-result",
|
executed: true,
|
||||||
id: "local-old-model",
|
metadata: { openai: { itemId: "hosted-old-model" } },
|
||||||
name: "read",
|
resultMetadata: { openai: { itemId: "hosted-old-model" } },
|
||||||
result: { type: "json", value: { text: "Hello" } },
|
},
|
||||||
providerExecuted: false,
|
state: new SessionMessage.ToolStateCompleted({
|
||||||
cache: undefined,
|
status: "completed",
|
||||||
metadata: undefined,
|
input: { query: "Effect" },
|
||||||
providerMetadata: undefined,
|
content: [],
|
||||||
},
|
structured: {},
|
||||||
])
|
result: { type: "json", value: { status: "completed" } },
|
||||||
}))
|
}),
|
||||||
|
time: { created, completed: created },
|
||||||
|
}),
|
||||||
|
new SessionMessage.AssistantTool({
|
||||||
|
type: "tool",
|
||||||
|
id: "local-old-model",
|
||||||
|
name: "read",
|
||||||
|
provider: {
|
||||||
|
executed: false,
|
||||||
|
metadata: { fake: { call: "old" } },
|
||||||
|
resultMetadata: { fake: { result: "old" } },
|
||||||
|
},
|
||||||
|
state: new SessionMessage.ToolStateCompleted({
|
||||||
|
status: "completed",
|
||||||
|
input: { path: "README.md" },
|
||||||
|
content: [],
|
||||||
|
structured: { text: "Hello" },
|
||||||
|
}),
|
||||||
|
time: { created, completed: created },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
time: { created, completed: created },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
model,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(messages[0]?.content).toEqual([
|
||||||
|
{ type: "text", text: "Visible thought" },
|
||||||
|
{
|
||||||
|
type: "tool-call",
|
||||||
|
id: "hosted-old-model",
|
||||||
|
name: "web_search",
|
||||||
|
input: { query: "Effect" },
|
||||||
|
providerExecuted: true,
|
||||||
|
providerMetadata: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "tool-result",
|
||||||
|
id: "hosted-old-model",
|
||||||
|
name: "web_search",
|
||||||
|
result: { type: "json", value: { status: "completed" } },
|
||||||
|
providerExecuted: true,
|
||||||
|
cache: undefined,
|
||||||
|
metadata: undefined,
|
||||||
|
providerMetadata: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "tool-call",
|
||||||
|
id: "local-old-model",
|
||||||
|
name: "read",
|
||||||
|
input: { path: "README.md" },
|
||||||
|
providerExecuted: false,
|
||||||
|
providerMetadata: undefined,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(messages[1]?.content).toEqual([
|
||||||
|
{
|
||||||
|
type: "tool-result",
|
||||||
|
id: "local-old-model",
|
||||||
|
name: "read",
|
||||||
|
result: { type: "json", value: { text: "Hello" } },
|
||||||
|
providerExecuted: false,
|
||||||
|
cache: undefined,
|
||||||
|
metadata: undefined,
|
||||||
|
providerMetadata: undefined,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -57,14 +57,15 @@ 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 systemContextKey = SystemContext.Key.make("test/context")
|
||||||
const systemContext = Layer.effectDiscard(
|
const systemContext = Layer.effectDiscard(
|
||||||
SystemContextRegistry.Service.pipe(
|
SystemContextRegistry.Service.pipe(
|
||||||
Effect.flatMap((registry) =>
|
Effect.flatMap((registry) =>
|
||||||
registry.contribute({
|
registry.contribute({
|
||||||
key: "test/context",
|
key: systemContextKey,
|
||||||
load: Effect.succeed(
|
load: Effect.succeed(
|
||||||
SystemContext.make({
|
SystemContext.make({
|
||||||
key: SystemContext.Key.make("test/context"),
|
key: systemContextKey,
|
||||||
codec: Schema.toCodecJson(Schema.String),
|
codec: Schema.toCodecJson(Schema.String),
|
||||||
load: Effect.succeed("Recorded context"),
|
load: Effect.succeed("Recorded context"),
|
||||||
baseline: String,
|
baseline: String,
|
||||||
|
|
|
||||||
|
|
@ -32,13 +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 { SessionContextEpochTable, 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 { SystemContext } from "@opencode-ai/core/system-context"
|
||||||
import { SystemContextRegistry } from "@opencode-ai/core/system-context-registry"
|
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"
|
||||||
|
|
||||||
|
|
@ -145,11 +150,12 @@ const systemContextKey = SystemContext.Key.make("test/context")
|
||||||
let systemBaseline = "Initial context"
|
let systemBaseline = "Initial context"
|
||||||
let systemRemoved = false
|
let systemRemoved = false
|
||||||
let systemUnavailable = false
|
let systemUnavailable = false
|
||||||
|
let systemLoadHook = Effect.void
|
||||||
const systemContext = Layer.effectDiscard(
|
const systemContext = Layer.effectDiscard(
|
||||||
SystemContextRegistry.Service.pipe(
|
SystemContextRegistry.Service.pipe(
|
||||||
Effect.flatMap((registry) =>
|
Effect.flatMap((registry) =>
|
||||||
registry.contribute({
|
registry.contribute({
|
||||||
key: "test/context",
|
key: systemContextKey,
|
||||||
load: Effect.sync(() =>
|
load: Effect.sync(() =>
|
||||||
SystemContext.combine(
|
SystemContext.combine(
|
||||||
systemRemoved
|
systemRemoved
|
||||||
|
|
@ -158,7 +164,11 @@ const systemContext = Layer.effectDiscard(
|
||||||
SystemContext.make({
|
SystemContext.make({
|
||||||
key: systemContextKey,
|
key: systemContextKey,
|
||||||
codec: Schema.toCodecJson(Schema.String),
|
codec: Schema.toCodecJson(Schema.String),
|
||||||
load: Effect.sync(() => (systemUnavailable ? SystemContext.unavailable : systemBaseline)),
|
load: systemLoadHook.pipe(
|
||||||
|
Effect.andThen(
|
||||||
|
Effect.sync(() => (systemUnavailable ? SystemContext.unavailable : systemBaseline)),
|
||||||
|
),
|
||||||
|
),
|
||||||
baseline: String,
|
baseline: String,
|
||||||
update: (_previous, current) => current,
|
update: (_previous, current) => current,
|
||||||
removed: () => "System context source removed: test/context",
|
removed: () => "System context source removed: test/context",
|
||||||
|
|
@ -240,6 +250,7 @@ const setup = Effect.gen(function* () {
|
||||||
systemBaseline = "Initial context"
|
systemBaseline = "Initial context"
|
||||||
systemRemoved = false
|
systemRemoved = false
|
||||||
systemUnavailable = false
|
systemUnavailable = false
|
||||||
|
systemLoadHook = Effect.void
|
||||||
responses = undefined
|
responses = undefined
|
||||||
streamFailure = undefined
|
streamFailure = undefined
|
||||||
responseStream = undefined
|
responseStream = undefined
|
||||||
|
|
@ -552,6 +563,39 @@ 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("reuses one durable baseline after the context producer changes", () =>
|
it.effect("reuses one durable baseline after the context producer changes", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* setup
|
yield* setup
|
||||||
|
|
@ -622,6 +666,7 @@ describe("SessionRunnerLLM", () => {
|
||||||
yield* session.resume(sessionID)
|
yield* session.resume(sessionID)
|
||||||
yield* events.publish(SessionEvent.ModelSwitched, {
|
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||||
sessionID,
|
sessionID,
|
||||||
|
messageID: SessionMessage.ID.create(),
|
||||||
timestamp: DateTime.makeUnsafe(1),
|
timestamp: DateTime.makeUnsafe(1),
|
||||||
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
|
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
|
||||||
})
|
})
|
||||||
|
|
@ -661,6 +706,7 @@ describe("SessionRunnerLLM", () => {
|
||||||
yield* session.resume(sessionID)
|
yield* session.resume(sessionID)
|
||||||
yield* events.publish(SessionEvent.ModelSwitched, {
|
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||||
sessionID,
|
sessionID,
|
||||||
|
messageID: SessionMessage.ID.create(),
|
||||||
timestamp: DateTime.makeUnsafe(1),
|
timestamp: DateTime.makeUnsafe(1),
|
||||||
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
|
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
|
||||||
})
|
})
|
||||||
|
|
@ -692,15 +738,17 @@ describe("SessionRunnerLLM", () => {
|
||||||
|
|
||||||
yield* events.publish(SessionEvent.ModelSwitched, {
|
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||||
sessionID,
|
sessionID,
|
||||||
|
messageID: SessionMessage.ID.create(),
|
||||||
timestamp: DateTime.makeUnsafe(1),
|
timestamp: DateTime.makeUnsafe(1),
|
||||||
model: { id: ModelV2.ID.make("replacement-1"), providerID: ProviderV2.ID.make("fake") },
|
model: { id: ModelV2.ID.make("replacement-1"), providerID: ProviderV2.ID.make("fake") },
|
||||||
})
|
})
|
||||||
yield* events.publish(SessionEvent.ModelSwitched, {
|
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||||
sessionID,
|
sessionID,
|
||||||
|
messageID: SessionMessage.ID.create(),
|
||||||
timestamp: DateTime.makeUnsafe(2),
|
timestamp: DateTime.makeUnsafe(2),
|
||||||
model: { id: ModelV2.ID.make("replacement-2"), providerID: ProviderV2.ID.make("fake") },
|
model: { id: ModelV2.ID.make("replacement-2"), providerID: ProviderV2.ID.make("fake") },
|
||||||
})
|
})
|
||||||
const latest = yield* events.sequence(sessionID)
|
const latest = yield* SessionInput.latestSeq(db, sessionID)
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
yield* db
|
yield* db
|
||||||
|
|
@ -713,6 +761,40 @@ describe("SessionRunnerLLM", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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", () =>
|
it.effect("replays retained context projections while replacement is pending", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* setup
|
yield* setup
|
||||||
|
|
@ -728,6 +810,7 @@ describe("SessionRunnerLLM", () => {
|
||||||
yield* session.resume(sessionID)
|
yield* session.resume(sessionID)
|
||||||
yield* events.publish(SessionEvent.ModelSwitched, {
|
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||||
sessionID,
|
sessionID,
|
||||||
|
messageID: SessionMessage.ID.create(),
|
||||||
timestamp: DateTime.makeUnsafe(1),
|
timestamp: DateTime.makeUnsafe(1),
|
||||||
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
|
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
|
||||||
})
|
})
|
||||||
|
|
@ -752,6 +835,7 @@ describe("SessionRunnerLLM", () => {
|
||||||
yield* session.resume(sessionID)
|
yield* session.resume(sessionID)
|
||||||
yield* events.publish(SessionEvent.ModelSwitched, {
|
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||||
sessionID,
|
sessionID,
|
||||||
|
messageID: SessionMessage.ID.create(),
|
||||||
timestamp: DateTime.makeUnsafe(1),
|
timestamp: DateTime.makeUnsafe(1),
|
||||||
model: { id: ModelV2.ID.make("replacement-1"), providerID: ProviderV2.ID.make("fake") },
|
model: { id: ModelV2.ID.make("replacement-1"), providerID: ProviderV2.ID.make("fake") },
|
||||||
})
|
})
|
||||||
|
|
@ -760,6 +844,7 @@ describe("SessionRunnerLLM", () => {
|
||||||
yield* session.resume(sessionID)
|
yield* session.resume(sessionID)
|
||||||
yield* events.publish(SessionEvent.ModelSwitched, {
|
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||||
sessionID,
|
sessionID,
|
||||||
|
messageID: SessionMessage.ID.create(),
|
||||||
timestamp: DateTime.makeUnsafe(2),
|
timestamp: DateTime.makeUnsafe(2),
|
||||||
model: { id: ModelV2.ID.make("replacement-2"), providerID: ProviderV2.ID.make("fake") },
|
model: { id: ModelV2.ID.make("replacement-2"), providerID: ProviderV2.ID.make("fake") },
|
||||||
})
|
})
|
||||||
|
|
@ -784,6 +869,7 @@ describe("SessionRunnerLLM", () => {
|
||||||
yield* session.resume(sessionID)
|
yield* session.resume(sessionID)
|
||||||
yield* events.publish(SessionEvent.Compaction.Started, {
|
yield* events.publish(SessionEvent.Compaction.Started, {
|
||||||
sessionID,
|
sessionID,
|
||||||
|
messageID: SessionMessage.ID.create(),
|
||||||
timestamp: DateTime.makeUnsafe(1),
|
timestamp: DateTime.makeUnsafe(1),
|
||||||
reason: "manual",
|
reason: "manual",
|
||||||
})
|
})
|
||||||
|
|
@ -821,6 +907,7 @@ describe("SessionRunnerLLM", () => {
|
||||||
yield* session.resume(sessionID)
|
yield* session.resume(sessionID)
|
||||||
yield* events.publish(SessionEvent.Compaction.Started, {
|
yield* events.publish(SessionEvent.Compaction.Started, {
|
||||||
sessionID,
|
sessionID,
|
||||||
|
messageID: SessionMessage.ID.create(),
|
||||||
timestamp: DateTime.makeUnsafe(1),
|
timestamp: DateTime.makeUnsafe(1),
|
||||||
reason: "manual",
|
reason: "manual",
|
||||||
})
|
})
|
||||||
|
|
@ -834,7 +921,16 @@ describe("SessionRunnerLLM", () => {
|
||||||
yield* session.resume(sessionID)
|
yield* session.resume(sessionID)
|
||||||
|
|
||||||
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Initial context"])
|
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)
|
expect(
|
||||||
|
requests
|
||||||
|
.at(-1)
|
||||||
|
?.messages.some(
|
||||||
|
(message) =>
|
||||||
|
message.role === "system" &&
|
||||||
|
message.content[0]?.type === "text" &&
|
||||||
|
message.content[0].text === "Changed context",
|
||||||
|
),
|
||||||
|
).toBe(true)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1022,6 +1118,7 @@ describe("SessionRunnerLLM", () => {
|
||||||
yield* Deferred.await(toolExecutionsStarted)
|
yield* Deferred.await(toolExecutionsStarted)
|
||||||
yield* events.publish(SessionEvent.ModelSwitched, {
|
yield* events.publish(SessionEvent.ModelSwitched, {
|
||||||
sessionID,
|
sessionID,
|
||||||
|
messageID: SessionMessage.ID.create(),
|
||||||
timestamp: DateTime.makeUnsafe(1),
|
timestamp: DateTime.makeUnsafe(1),
|
||||||
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
|
model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") },
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { SystemContextRegistry } from "@opencode-ai/core/system-context-registry
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
const contribution = (key: string, text: string, sourceKey = key) => ({
|
const contribution = (key: string, text: string, sourceKey = key) => ({
|
||||||
key,
|
key: SystemContext.Key.make(key),
|
||||||
load: Effect.succeed(
|
load: Effect.succeed(
|
||||||
SystemContext.make({
|
SystemContext.make({
|
||||||
key: SystemContext.Key.make(sourceKey),
|
key: SystemContext.Key.make(sourceKey),
|
||||||
|
|
@ -43,7 +43,7 @@ describe("SystemContextRegistry", () => {
|
||||||
const registry = yield* SystemContextRegistry.Service
|
const registry = yield* SystemContextRegistry.Service
|
||||||
let loads = 0
|
let loads = 0
|
||||||
yield* registry.contribute({
|
yield* registry.contribute({
|
||||||
key: "test/dynamic",
|
key: SystemContext.Key.make("test/dynamic"),
|
||||||
load: Effect.sync(() => {
|
load: Effect.sync(() => {
|
||||||
loads++
|
loads++
|
||||||
return SystemContext.empty
|
return SystemContext.empty
|
||||||
|
|
@ -61,7 +61,7 @@ describe("SystemContextRegistry", () => {
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const registry = yield* SystemContextRegistry.Service
|
const registry = yield* SystemContextRegistry.Service
|
||||||
const failure = new Error("contribution failed")
|
const failure = new Error("contribution failed")
|
||||||
yield* registry.contribute({ key: "test/failure", load: Effect.die(failure) })
|
yield* registry.contribute({ key: SystemContext.Key.make("test/failure"), load: Effect.die(failure) })
|
||||||
|
|
||||||
const exit = yield* registry.load().pipe(Effect.exit)
|
const exit = yield* registry.load().pipe(Effect.exit)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -125,16 +125,21 @@ describe("SystemContext", () => {
|
||||||
|
|
||||||
expect(yield* SystemContext.reconcile(context, previous)).toEqual({ _tag: "Unchanged" })
|
expect(yield* SystemContext.reconcile(context, previous)).toEqual({ _tag: "Unchanged" })
|
||||||
expect(yield* SystemContext.replace(context, previous)).toEqual({ _tag: "ReplacementBlocked" })
|
expect(yield* SystemContext.replace(context, previous)).toEqual({ _tag: "ReplacementBlocked" })
|
||||||
expect(yield* SystemContext.replace(context, {})).toMatchObject({ _tag: "Replaced" })
|
expect(yield* SystemContext.replace(context, {})).toMatchObject({ _tag: "ReplacementReady" })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("omits unavailable sources from an initial baseline", () =>
|
it.effect("blocks initialization while a source is unavailable", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
expect(yield* SystemContext.initialize(stringContext({ key: "core/remote", value: SystemContext.unavailable }))).toEqual({
|
const exit = yield* SystemContext.initialize(
|
||||||
baseline: "",
|
stringContext({ key: "core/remote", value: SystemContext.unavailable }),
|
||||||
snapshot: {},
|
).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")] }),
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -154,8 +159,10 @@ describe("SystemContext", () => {
|
||||||
|
|
||||||
it.effect("requests replacement when a source without removal text disappears", () =>
|
it.effect("requests replacement when a source without removal text disappears", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
expect(yield* SystemContext.reconcile(SystemContext.empty, { "core/date": { value: "2026-06-04" } })).toMatchObject({
|
expect(
|
||||||
_tag: "Replaced",
|
yield* SystemContext.reconcile(SystemContext.empty, { "core/date": { value: "2026-06-04" } }),
|
||||||
|
).toMatchObject({
|
||||||
|
_tag: "ReplacementReady",
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
@ -188,7 +195,7 @@ describe("SystemContext", () => {
|
||||||
yield* SystemContext.reconcile(stringContext({ key: "core/date", value: "2026-06-04" }), {
|
yield* SystemContext.reconcile(stringContext({ key: "core/date", value: "2026-06-04" }), {
|
||||||
"core/date": { value: 42, removed: "Date removed" },
|
"core/date": { value: 42, removed: "Date removed" },
|
||||||
}),
|
}),
|
||||||
).toMatchObject({ _tag: "Replaced" })
|
).toMatchObject({ _tag: "ReplacementReady" })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -207,7 +214,7 @@ describe("SystemContext", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(yield* SystemContext.reconcile(context, { "core/date": { value: 42 } })).toMatchObject({
|
expect(yield* SystemContext.reconcile(context, { "core/date": { value: 42 } })).toMatchObject({
|
||||||
_tag: "Replaced",
|
_tag: "ReplacementReady",
|
||||||
generation: { baseline: "2026-06-04" },
|
generation: { baseline: "2026-06-04" },
|
||||||
})
|
})
|
||||||
expect(loads).toBe(1)
|
expect(loads).toBe(1)
|
||||||
|
|
@ -234,7 +241,7 @@ describe("SystemContext", () => {
|
||||||
"core/date": { value: "2026-06-03" },
|
"core/date": { value: "2026-06-03" },
|
||||||
"core/location": { value: 42 },
|
"core/location": { value: 42 },
|
||||||
}),
|
}),
|
||||||
).toMatchObject({ _tag: "Replaced" })
|
).toMatchObject({ _tag: "ReplacementReady" })
|
||||||
expect(updates).toBe(0)
|
expect(updates).toBe(0)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,7 @@
|
||||||
"route": "anthropic-messages",
|
"route": "anthropic-messages",
|
||||||
"transport": "http",
|
"transport": "http",
|
||||||
"model": "claude-haiku-4-5-20251001",
|
"model": "claude-haiku-4-5-20251001",
|
||||||
"tags": [
|
"tags": ["prefix:anthropic-messages", "provider:anthropic", "system", "chronological-system-update", "golden"]
|
||||||
"prefix:anthropic-messages",
|
|
||||||
"provider:anthropic",
|
|
||||||
"system",
|
|
||||||
"chronological-system-update",
|
|
||||||
"golden"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"interactions": [
|
"interactions": [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,7 @@
|
||||||
"route": "gemini",
|
"route": "gemini",
|
||||||
"transport": "http",
|
"transport": "http",
|
||||||
"model": "gemini-2.5-flash",
|
"model": "gemini-2.5-flash",
|
||||||
"tags": [
|
"tags": ["prefix:gemini", "provider:google", "system", "chronological-system-update", "golden"]
|
||||||
"prefix:gemini",
|
|
||||||
"provider:google",
|
|
||||||
"system",
|
|
||||||
"chronological-system-update",
|
|
||||||
"golden"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"interactions": [
|
"interactions": [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,7 @@
|
||||||
"route": "openai-chat",
|
"route": "openai-chat",
|
||||||
"transport": "http",
|
"transport": "http",
|
||||||
"model": "gpt-4o-mini",
|
"model": "gpt-4o-mini",
|
||||||
"tags": [
|
"tags": ["prefix:openai-chat", "provider:openai", "system", "chronological-system-update", "golden"]
|
||||||
"prefix:openai-chat",
|
|
||||||
"provider:openai",
|
|
||||||
"system",
|
|
||||||
"chronological-system-update",
|
|
||||||
"golden"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"interactions": [
|
"interactions": [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -177,7 +177,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
|
||||||
break
|
break
|
||||||
case "session.next.context.updated":
|
case "session.next.context.updated":
|
||||||
update(event.properties.sessionID, (draft) => {
|
update(event.properties.sessionID, (draft) => {
|
||||||
draft.unshift({
|
prepend(draft, {
|
||||||
id: event.properties.messageID,
|
id: event.properties.messageID,
|
||||||
type: "system",
|
type: "system",
|
||||||
text: event.properties.text,
|
text: event.properties.text,
|
||||||
|
|
|
||||||
|
|
@ -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>()
|
||||||
|
|
@ -309,6 +358,54 @@ test("sync v2 preserves live events while snapshot hydration is in flight", asyn
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("sync v2 deduplicates a buffered context update already present in the hydrated snapshot", async () => {
|
||||||
|
const events = createEventSource()
|
||||||
|
const response = Promise.withResolvers<Response>()
|
||||||
|
const calls = createFetch((url) => {
|
||||||
|
if (url.pathname === "/api/session/session-1/message") return response.promise
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
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
|
||||||
|
const hydration = sync.session.message.sync("session-1")
|
||||||
|
emitTwice(events, {
|
||||||
|
id: "evt_context_1",
|
||||||
|
type: "session.next.context.updated",
|
||||||
|
properties: { sessionID: "session-1", messageID: "msg_context_1", timestamp: 1, text: "Updated context" },
|
||||||
|
})
|
||||||
|
response.resolve(
|
||||||
|
json({ data: [{ id: "msg_context_1", type: "system", text: "Updated context", time: { created: 1 } }] }),
|
||||||
|
)
|
||||||
|
await hydration
|
||||||
|
|
||||||
|
expect(sync.session.message.fromSession("session-1")).toHaveLength(1)
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("sync v2 replaces stale cached rows while preserving in-flight live rows", async () => {
|
test("sync v2 replaces stale cached rows while preserving in-flight live rows", 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": {
|
||||||
|
|
|
||||||
|
|
@ -21,15 +21,15 @@ Watcher-backed caches are a later efficiency optimization for roots with proven
|
||||||
|
|
||||||
## Existing Pieces
|
## Existing Pieces
|
||||||
|
|
||||||
| Existing piece | Responsibility |
|
| Existing piece | Responsibility |
|
||||||
| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| `Watcher.locationLayer` | Publish advisory `file.watcher.updated` events for local filesystem changes. |
|
| `Watcher.locationLayer` | Publish advisory `file.watcher.updated` events for local filesystem changes. |
|
||||||
| `EventV2.subscribe(...)` | Expose advisory events as scoped Effect streams. |
|
| `EventV2.subscribe(...)` | Expose advisory events as scoped Effect streams. |
|
||||||
| `State.create(...)` | Rebuild replayable plugin and config contribution state from scoped transforms. |
|
| `State.create(...)` | Rebuild replayable plugin and config contribution state from scoped transforms. |
|
||||||
| `SynchronizedRef.modifyEffect(...)` | Serialize effectful state refresh and store the next value only after success. |
|
| `SynchronizedRef.modifyEffect(...)` | Serialize effectful state refresh and store the next value only after success. |
|
||||||
| `SystemContext` | Convert coherent source samples into one immutable baseline, chronological updates, unavailable state, and removal tombstones. |
|
| `SystemContext` | Convert coherent source samples into one immutable baseline, chronological updates, unavailable state, and removal tombstones. |
|
||||||
| `SystemContextRegistry` | Assemble Location-scoped built-in, instruction, and plugin context producers in stable contribution-key order. |
|
| `SystemContextRegistry` | Assemble Location-scoped built-in, instruction, and plugin context producers in stable contribution-key order. |
|
||||||
| `LocationServiceMap` | Own and clean up Location-scoped services, watcher subscriptions, and observation caches together. |
|
| `LocationServiceMap` | Own and clean up Location-scoped services, watcher subscriptions, and observation caches together. |
|
||||||
|
|
||||||
The missing reusable piece is deliberately small: retain the last successful value, mark it stale, and serialize refresh attempts.
|
The missing reusable piece is deliberately small: retain the last successful value, mark it stale, and serialize refresh attempts.
|
||||||
|
|
||||||
|
|
@ -153,10 +153,11 @@ embedded skill
|
||||||
Add a Location-scoped contributor to `SystemContextRegistry`:
|
Add a Location-scoped contributor to `SystemContextRegistry`:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
yield* registry.contribute({
|
yield *
|
||||||
key: "core/instructions",
|
registry.contribute({
|
||||||
load: loadAmbientInstructions(),
|
key: SystemContext.Key.make("core/instructions"),
|
||||||
})
|
load: loadAmbientInstructions(),
|
||||||
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
`InstructionContext` owns instruction discovery, deterministic ordering, and source loading. `SystemContextRegistry` owns contributor composition and lifecycle. `SystemContext` remains unaware of files and URLs.
|
`InstructionContext` owns instruction discovery, deterministic ordering, and source loading. `SystemContextRegistry` owns contributor composition and lifecycle. `SystemContext` remains unaware of files and URLs.
|
||||||
|
|
@ -213,15 +214,15 @@ temporary discovery or read failure
|
||||||
-> aggregate SystemContext.unavailable
|
-> aggregate SystemContext.unavailable
|
||||||
```
|
```
|
||||||
|
|
||||||
| Observation | Source outcome |
|
| Observation | Source outcome |
|
||||||
| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||||
| Local scan succeeds and discovers readable file | Include its exact contents in the available aggregate source. |
|
| Local scan succeeds and discovers readable file | Include its exact contents in the available aggregate source. |
|
||||||
| Local scan succeeds and a previously discovered file is absent | Remove it from the aggregate value; remove the aggregate source when no instructions remain. |
|
| Local scan succeeds and a previously discovered file is absent | Remove it from the aggregate value; remove the aggregate source when no instructions remain. |
|
||||||
| Local scan or file read fails transiently | Preserve the admitted aggregate source as `SystemContext.unavailable`; never emit mass removals. |
|
| Local scan or file read fails transiently | Preserve the admitted aggregate source as `SystemContext.unavailable`; never emit mass removals. |
|
||||||
| Empty local file | Include the empty exact content in the available aggregate source. |
|
| Empty local file | Include the empty exact content in the available aggregate source. |
|
||||||
| URL returns `2xx` body | Available source with exact contents. |
|
| URL returns `2xx` body | Available source with exact contents. |
|
||||||
| URL times out or returns transient failure | `SystemContext.unavailable`. |
|
| URL times out or returns transient failure | `SystemContext.unavailable`. |
|
||||||
| URL returns `404` or `410` | Decide the explicit removal contract before URL implementation. |
|
| URL returns `404` or `410` | Decide the explicit removal contract before URL implementation. |
|
||||||
|
|
||||||
Aggregate instruction removal text must be model-meaningful:
|
Aggregate instruction removal text must be model-meaningful:
|
||||||
|
|
||||||
|
|
@ -309,6 +310,18 @@ sequenceDiagram
|
||||||
|
|
||||||
If coverage is not proven, bypass the cache and observe directly whenever the safe boundary naturally requests current state. This is safe-turn refresh, not a background polling loop.
|
If coverage is not proven, bypass the cache and observe directly whenever the safe boundary naturally requests current state. This is safe-turn refresh, not a background polling loop.
|
||||||
|
|
||||||
|
When coverage is proven, cache each known candidate instruction path independently rather than invalidating one aggregate instruction cache:
|
||||||
|
|
||||||
|
```text
|
||||||
|
candidate instruction path
|
||||||
|
-> one Refreshable<File | Absent>
|
||||||
|
-> watcher event invalidates only the matching path
|
||||||
|
-> next safe provider boundary reloads only stale candidates
|
||||||
|
-> available candidates become ordered per-file Context Sources
|
||||||
|
```
|
||||||
|
|
||||||
|
Ambient candidates include the global `AGENTS.md` path and one `AGENTS.md` candidate in every applicable ancestor directory, including candidates that are currently absent so later additions are observable.
|
||||||
|
|
||||||
## URL Sources
|
## URL Sources
|
||||||
|
|
||||||
URLs never share an observation cache with local discovery.
|
URLs never share an observation cache with local discovery.
|
||||||
|
|
@ -372,31 +385,36 @@ Nested instructions discovered after successful read-tool activity remain a Sess
|
||||||
- Idle Sessions are not woken by local edits, URL timers, or plugin changes.
|
- Idle Sessions are not woken by local edits, URL timers, or plugin changes.
|
||||||
- Context Epoch admission remains serialized by the Session event transaction at the next naturally scheduled provider turn.
|
- Context Epoch admission remains serialized by the Session event transaction at the next naturally scheduled provider turn.
|
||||||
|
|
||||||
## Proposed Implementation Order
|
## Implementation Status And Follow-Up Order
|
||||||
|
|
||||||
|
Implemented in the direct-observation slice:
|
||||||
|
|
||||||
|
1. Add the Location-scoped `SystemContextRegistry` backed by stable-keyed scoped contributions.
|
||||||
|
2. Register built-in and ambient instruction producers with `SystemContextRegistry`.
|
||||||
|
3. Observe local instructions directly at each safe provider boundary.
|
||||||
|
4. Preserve admitted instructions after transient scan/read failure and block initial provider turns while context is unavailable.
|
||||||
|
5. Test ordering, edit, unlink, empty file, transient scan failure, discovered-then-missing races, durable restart behavior, and deterministic context admission.
|
||||||
|
|
||||||
|
Follow-up order:
|
||||||
|
|
||||||
1. Add and unit-test `Refreshable.make(load)` with `get` and `invalidate`.
|
1. Add and unit-test `Refreshable.make(load)` with `get` and `invalidate`.
|
||||||
2. Add model-meaningful instruction removal rendering support before unlink lands.
|
2. Add truthful root-specific watcher registration.
|
||||||
3. Add the Location-scoped `SystemContextRegistry` backed by stable-keyed scoped contributions.
|
3. Move ambient instructions from one directly observed aggregate to one watcher-invalidated Refreshable and Context Source per candidate file.
|
||||||
4. Register built-in and ambient instruction producers with `SystemContextRegistry`.
|
4. Add configured local exact paths and globs.
|
||||||
5. Observe local instructions directly at each safe provider boundary.
|
5. Add configured URL observations with explicit `404` and `410` semantics.
|
||||||
6. Test add, edit, unlink, empty file, transient scan failure, transient read failure, restart, and deterministic ordering.
|
6. Migrate local `SkillV2` directory observations to per-source refreshables after skill failure semantics are corrected.
|
||||||
7. Add configured local exact paths and globs.
|
7. Add durable Session-scoped nested read discovery.
|
||||||
8. Add configured URL observations with explicit `404` and `410` semantics.
|
|
||||||
9. Add root-specific watcher registration and watcher-backed `Refreshable` invalidation where coverage is proven.
|
|
||||||
10. Migrate local `SkillV2` directory observations to per-source refreshables after skill failure semantics are corrected.
|
|
||||||
11. Add durable Session-scoped nested read discovery.
|
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
1. Should the first local scan failure preserve prior discovered sources as unavailable, or fail the current provider turn until a coherent rescan succeeds?
|
1. Should configured URL sources treat `404` and `410` as confirmed removals?
|
||||||
2. Should configured URL sources treat `404` and `410` as confirmed removals?
|
2. What root-specific watcher API cleanly models ignore policy and callback health?
|
||||||
3. What root-specific watcher API cleanly models ignore policy and callback health?
|
3. Should own-process file mutations publish an advisory invalidation event synchronously after commit?
|
||||||
4. Should own-process file mutations publish an advisory invalidation event synchronously after commit?
|
|
||||||
|
|
||||||
## Compression Line
|
## Compression Line
|
||||||
|
|
||||||
```text
|
```text
|
||||||
State remembers what should be loaded.
|
SystemContextRegistry remembers which context producers participate.
|
||||||
Refreshable remembers whether a successful observation needs loading again.
|
Refreshable remembers whether a successful observation needs loading again.
|
||||||
Context Epoch remembers what the model was told.
|
Context Epoch remembers what the model was told.
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -711,7 +711,7 @@ Compatibility:
|
||||||
|
|
||||||
Affected schema:
|
Affected schema:
|
||||||
|
|
||||||
- Add synchronized `session.next.context.updated.1` Session events containing only exact combined model-visible text.
|
- 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 `session_context_epoch.revision` for transactional structured-snapshot advancement.
|
||||||
- Add the first-class `system` Session message projection for chronological context updates.
|
- Add the first-class `system` Session message projection for chronological context updates.
|
||||||
|
|
||||||
|
|
@ -727,7 +727,7 @@ Compatibility:
|
||||||
|
|
||||||
- The synchronized event log retains only text actually shown to the model, not internal structured snapshots.
|
- 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.
|
- Existing experimental V2 Session databases remain disposable across incompatible pre-launch event-schema changes.
|
||||||
- Replacement epochs after compaction or model switches, project instructions, skills guidance, and plugin transforms remain follow-up slices.
|
- 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
|
## 2026-06-04: Replace Session Context Epochs Lazily
|
||||||
|
|
||||||
|
|
@ -746,4 +746,22 @@ Compatibility:
|
||||||
|
|
||||||
- Baseline replacement is bounded operational state and does not add permanent synchronized events.
|
- 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.
|
- Existing experimental V2 Session databases remain disposable across incompatible pre-launch event-schema changes.
|
||||||
- Compaction execution, project instructions, skills guidance, and plugin transforms remain follow-up slices.
|
- 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.
|
||||||
|
|
||||||
|
Compatibility:
|
||||||
|
|
||||||
|
- Watcher-backed per-file `Refreshable` instruction observations, configured sources, nested discovery, and plugin-defined context remain follow-up slices.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue